@struq/skill 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
# Micro-Interactions
|
|
2
|
-
|
|
3
|
-
Garnish, not the meal: micro-interactions are the last 5% that make the first 95% feel intentional. They only land if the macro layer (type, spacing, reveals) is already right. A magnetic button on a templated layout is lipstick.
|
|
4
|
-
|
|
5
|
-
Budget rule: pick **2 to 3** from this file per site, executed perfectly, rather than all of them. Density of gimmicks reads as junior.
|
|
6
|
-
|
|
7
|
-
All hover-dependent effects must be gated: wrap in `@media (hover: hover)` / check `window.matchMedia("(hover: hover)").matches`. Magnetic buttons and cursor followers on touch devices are dead weight that still costs listeners.
|
|
8
|
-
|
|
9
|
-
**Library split (SOTA, not taste):** the high-frequency imperative effects here (magnetic buttons, custom cursor) stay on **GSAP `quickTo`**, because they update every frame and must share the one Lenis+GSAP ticker. A competing Motion spring loop is what makes otherwise-good motion feel subtly off. **Motion** owns the state/lifecycle layer: press/toggle feedback (`whileTap`), `AnimatePresence`, `layout`/`layoutId`, and `whileHover`/`whileInView` one-offs. Same rule as the SKILL.md decision table: *every frame or scroll-tied means GSAP; React state/lifecycle means Motion.* The weighted, smooth feel comes from one clock and tuned easing, not from the library choice.
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## 1. Magnetic button
|
|
14
|
-
|
|
15
|
-
**Term:** Magnetic / gravitational hover.
|
|
16
|
-
**Mechanic:** Within a proximity zone, the button drifts toward the cursor (and its label drifts slightly further, creating internal depth). On leave, it snaps home with an elastic ease. Implemented with `gsap.quickTo`, which gives pre-compiled tweens for high-frequency updates. Never `setState` per mousemove (re-render per frame) and never raw `gsap.to` per event (allocates a tween per pixel moved).
|
|
17
|
-
|
|
18
|
-
**When:** Primary CTAs, nav logo, social icons. 1 to 3 magnetic elements per viewport max.
|
|
19
|
-
**Cost:** Trivial with quickTo. The gate is taste, not perf.
|
|
20
|
-
|
|
21
|
-
```tsx
|
|
22
|
-
"use client";
|
|
23
|
-
|
|
24
|
-
import { useRef } from "react";
|
|
25
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
26
|
-
|
|
27
|
-
export function MagneticButton({ children, strength = 0.35 }: { children: React.ReactNode; strength?: number }) {
|
|
28
|
-
const wrap = useRef<HTMLDivElement>(null);
|
|
29
|
-
const label = useRef<HTMLSpanElement>(null);
|
|
30
|
-
|
|
31
|
-
useGSAP(
|
|
32
|
-
(_, contextSafe) => {
|
|
33
|
-
if (!window.matchMedia("(hover: hover)").matches) return;
|
|
34
|
-
|
|
35
|
-
const el = wrap.current!;
|
|
36
|
-
const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3.out" });
|
|
37
|
-
const yTo = gsap.quickTo(el, "y", { duration: 0.4, ease: "power3.out" });
|
|
38
|
-
const lx = gsap.quickTo(label.current, "x", { duration: 0.45, ease: "power3.out" });
|
|
39
|
-
const ly = gsap.quickTo(label.current, "y", { duration: 0.45, ease: "power3.out" });
|
|
40
|
-
|
|
41
|
-
const onMove = contextSafe!((e: MouseEvent) => {
|
|
42
|
-
const r = el.getBoundingClientRect();
|
|
43
|
-
const dx = e.clientX - (r.left + r.width / 2);
|
|
44
|
-
const dy = e.clientY - (r.top + r.height / 2);
|
|
45
|
-
xTo(dx * strength); yTo(dy * strength);
|
|
46
|
-
lx(dx * strength * 0.5); ly(dy * strength * 0.5); // label trails → internal parallax
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const onLeave = contextSafe!(() => {
|
|
50
|
-
gsap.to([el, label.current], { x: 0, y: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
el.addEventListener("mousemove", onMove);
|
|
54
|
-
el.addEventListener("mouseleave", onLeave);
|
|
55
|
-
return () => { el.removeEventListener("mousemove", onMove); el.removeEventListener("mouseleave", onLeave); };
|
|
56
|
-
},
|
|
57
|
-
{ scope: wrap }
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<div ref={wrap} className="inline-block">
|
|
62
|
-
<button className="group relative overflow-hidden rounded-full border border-line bg-surface-2
|
|
63
|
-
px-8 py-4 font-ui text-ui text-ink">
|
|
64
|
-
<span ref={label} className="relative z-10 inline-block">{children}</span>
|
|
65
|
-
{/* fill swell on hover: scales from bottom, CSS-only */}
|
|
66
|
-
<span aria-hidden className="absolute inset-0 origin-bottom scale-y-0 bg-accent
|
|
67
|
-
transition-transform duration-500 ease-out-expo group-hover:scale-y-100" />
|
|
68
|
-
</button>
|
|
69
|
-
</div>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
The two depth cues, the label trailing at 0.5× and the `elastic.out` return, are the difference between "follows the mouse" and "magnetic." Strength stays at or below 0.4. Buttons chasing cursors across the screen is the parody version.
|
|
75
|
-
|
|
76
|
-
**Cheap tell:** `hover:scale-105 transition` on the button. **Instead:** Proximity drift + trailing label + elastic return + fill swell.
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## 2. Custom cursor / follower
|
|
81
|
-
|
|
82
|
-
**Term:** Cursor follower / contextual cursor.
|
|
83
|
-
**Mechanic:** A fixed element lerps toward the real cursor (trailing it by its smoothing factor) and **changes state per hover context**: growing into a "View" pill over project cards, a circle over links, hiding over native inputs. The context-reactivity is the entire justification. A dot that only follows is decoration.
|
|
84
|
-
|
|
85
|
-
**When:** Image-heavy portfolios and campaign pages where "View / Drag / Play" affordances add real information.
|
|
86
|
-
**Skip when:** content/SaaS/utility products. Fighting the system cursor in a tool context is hostile. This is the #1 over-reached-for effect, so when in doubt, skip.
|
|
87
|
-
**Cost:** One quickTo pair on a `fixed` element, which is cheap. Never hide the native cursor without a perfect replacement, and never `cursor: none` globally (it breaks inputs, iframes, selection).
|
|
88
|
-
|
|
89
|
-
```tsx
|
|
90
|
-
"use client";
|
|
91
|
-
|
|
92
|
-
import { useEffect, useRef, useState } from "react";
|
|
93
|
-
import { gsap } from "@/lib/gsap";
|
|
94
|
-
|
|
95
|
-
export function Cursor() {
|
|
96
|
-
const dot = useRef<HTMLDivElement>(null);
|
|
97
|
-
const [variant, setVariant] = useState<"default" | "view">("default");
|
|
98
|
-
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
if (!window.matchMedia("(hover: hover)").matches) return;
|
|
101
|
-
|
|
102
|
-
// duration here IS the trail amount: 0.5-0.7 visible trail, 0.2 tight
|
|
103
|
-
const xTo = gsap.quickTo(dot.current, "x", { duration: 0.55, ease: "power3.out" });
|
|
104
|
-
const yTo = gsap.quickTo(dot.current, "y", { duration: 0.55, ease: "power3.out" });
|
|
105
|
-
|
|
106
|
-
const onMove = (e: MouseEvent) => { xTo(e.clientX); yTo(e.clientY); };
|
|
107
|
-
|
|
108
|
-
// Event delegation: one listener, data-cursor attributes opt elements in
|
|
109
|
-
const onOver = (e: MouseEvent) => {
|
|
110
|
-
const t = (e.target as HTMLElement).closest<HTMLElement>("[data-cursor]");
|
|
111
|
-
setVariant((t?.dataset.cursor as "view") ?? "default");
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
window.addEventListener("mousemove", onMove);
|
|
115
|
-
document.addEventListener("mouseover", onOver);
|
|
116
|
-
return () => { window.removeEventListener("mousemove", onMove); document.removeEventListener("mouseover", onOver); };
|
|
117
|
-
}, []);
|
|
118
|
-
|
|
119
|
-
return (
|
|
120
|
-
<div ref={dot} className="pointer-events-none fixed left-0 top-0 z-[100] -translate-x-1/2 -translate-y-1/2">
|
|
121
|
-
<div className={`flex items-center justify-center rounded-full bg-ink text-surface-0
|
|
122
|
-
transition-all duration-300 ease-out-expo
|
|
123
|
-
${variant === "view" ? "text-small h-20 w-20" : "h-2.5 w-2.5"}`}>
|
|
124
|
-
{variant === "view" && "View"}
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Usage: <article data-cursor="view">…</article>. The native cursor stays visible alongside.
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
**Cheap tell:** a ring + dot following the cursor everywhere, no state changes, native cursor hidden. **Instead:** Small dot that *becomes an affordance* over opted-in elements, delegation-driven, hover-gated.
|
|
134
|
-
|
|
135
|
-
---
|
|
136
|
-
|
|
137
|
-
## 3. Hover state choreography
|
|
138
|
-
|
|
139
|
-
**Term:** Compound hover / hover scene.
|
|
140
|
-
**Mechanic:** A hover state where 2 to 4 properties move on *different* timings: image scales while a caption rises while siblings dim. The compound timing is what reads as choreography. Mostly CSS via `group`; Motion/GSAP only when state must persist or sequence.
|
|
141
|
-
|
|
142
|
-
### Hover vocabulary (summary)
|
|
143
|
-
|
|
144
|
-
| Effect | What it is | Tool | Where |
|
|
145
|
-
|---|---|---|---|
|
|
146
|
-
| Link underline | Line exits one side, redraws from the other (origin flip) | CSS | §3a |
|
|
147
|
-
| Card hover scene | Image scale + caption rise + sibling dim on three clocks | CSS (`group`, `group/list`) | §3b |
|
|
148
|
-
| Text swap | Label rolls out of its mask while its twin rolls in | CSS (JS + SplitText for per-char) | §3c |
|
|
149
|
-
| Magnetic | Element and label drift toward the cursor, elastic return | GSAP `quickTo` | §1 |
|
|
150
|
-
| Cursor follower | Lerped dot that becomes "View / Drag / Play" over opted-in elements | GSAP `quickTo` + delegation | §2 |
|
|
151
|
-
| Image distortion on hover | Displacement/ripple of a texture under the pointer, WebGL only | R3F shader | [webgl-scenes.md](webgl-scenes.md) |
|
|
152
|
-
|
|
153
|
-
Pick from this table by genre (see the selection heuristic at the end). Hover distortion on images is the heaviest item here, so it needs a real poster fallback and the one-canvas budget from [webgl-r3f.md](webgl-r3f.md).
|
|
154
|
-
|
|
155
|
-
### a) Link underline: draws out one side, redraws from the other
|
|
156
|
-
|
|
157
|
-
```tsx
|
|
158
|
-
<a className="group relative inline-block text-ink" href="/work">
|
|
159
|
-
Selected work
|
|
160
|
-
<span aria-hidden className="absolute -bottom-0.5 left-0 h-px w-full origin-right scale-x-0
|
|
161
|
-
bg-current transition-transform duration-400 ease-out-quart
|
|
162
|
-
group-hover:origin-left group-hover:scale-x-100" />
|
|
163
|
-
</a>
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
The origin flip (`origin-right` at rest, `origin-left` on hover) makes the line *exit the way it came in*: exit stage right, re-enter stage left. It is the detail people feel and can't name.
|
|
167
|
-
|
|
168
|
-
### b) Card hover scene: image, caption, arrow, siblings
|
|
169
|
-
|
|
170
|
-
```tsx
|
|
171
|
-
export function WorkCard({ p }: { p: { img: string; title: string; tag: string } }) {
|
|
172
|
-
return (
|
|
173
|
-
// parent <ul> gets: className="group/list", enables sibling dimming.
|
|
174
|
-
// The <li> is the scroll-reveal target ([data-reveal], GSAP autoAlpha + y), so it carries
|
|
175
|
-
// NO transition and no opacity class: the dim lives on the inner <a>. A CSS opacity
|
|
176
|
-
// transition on a GSAP target leaves it stuck at opacity 0 (pitfalls.md §3.1).
|
|
177
|
-
<li data-reveal className="group">
|
|
178
|
-
<a href="#" className="block transition-opacity duration-500
|
|
179
|
-
group-hover/list:opacity-40 group-hover/list:hover:opacity-100">
|
|
180
|
-
<div className="overflow-hidden">
|
|
181
|
-
<img src={p.img} alt="" className="aspect-[4/3] w-full object-cover
|
|
182
|
-
transition-transform duration-700 ease-out-expo
|
|
183
|
-
group-hover:scale-[1.06]" />
|
|
184
|
-
</div>
|
|
185
|
-
<div className="mt-4 flex items-baseline justify-between">
|
|
186
|
-
<h3 className="type-h3 text-h3">{p.title}</h3>
|
|
187
|
-
<span className="text-small translate-x-2 text-ink-muted opacity-0
|
|
188
|
-
transition-all duration-400 delay-75 ease-out-quart
|
|
189
|
-
group-hover:translate-x-0 group-hover:opacity-100">
|
|
190
|
-
{p.tag} →
|
|
191
|
-
</span>
|
|
192
|
-
</div>
|
|
193
|
-
</a>
|
|
194
|
-
</li>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
Three timings: image `700ms` (heavy thing, moves slow), tag `400ms` + `delay-75` (responds after), siblings `500ms` dim. **Sibling dimming** (`group/list` named group: list dims everyone, hovered card restores itself) is the single highest-value hover trick. It turns a grid into a spotlight.
|
|
200
|
-
|
|
201
|
-
**Reveal the wrapper, transition the child.** The rule behind the markup above: an element GSAP animates (here the `li`, revealed by [scroll-reveals.md](scroll-reveals.md) §1) never also carries a CSS `transition-*` on a property GSAP writes (`opacity`, `transform`, `translate`, `scale`). Hover transitions go one level in. The same applies to Tailwind `translate-*`, `scale-*` and `rotate-*` offsets: GSAP sets those properties to `none` on its targets, so they belong on a child or a wrapper ([pitfalls.md](pitfalls.md) §3).
|
|
202
|
-
|
|
203
|
-
### c) Text swap (slot-machine label)
|
|
204
|
-
|
|
205
|
-
```tsx
|
|
206
|
-
<button className="group relative overflow-hidden px-6 py-3">
|
|
207
|
-
<span className="block transition-transform duration-400 ease-out-expo
|
|
208
|
-
group-hover:-translate-y-full">Get in touch</span>
|
|
209
|
-
<span aria-hidden className="absolute inset-x-6 top-full block transition-transform duration-400
|
|
210
|
-
ease-out-expo group-hover:-translate-y-full">Get in touch</span>
|
|
211
|
-
</button>
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
Label rolls up and out of its mask while its twin rolls in: the masked-reveal principle ([scroll-reveals.md](scroll-reveals.md)) at button scale. With per-char stagger it becomes the full slot-machine; that needs JS (SplitText chars + `stagger: 0.02`) and is worth it on exactly one element (the main CTA or logo).
|
|
215
|
-
|
|
216
|
-
**Cheap tell:** `hover:shadow-xl hover:-translate-y-1` on cards: everything moves at once, on one duration, with a shadow change for "lift." **Instead:** Image, caption, and siblings on three different clocks; elevation never via shadow swell.
|
|
217
|
-
|
|
218
|
-
---
|
|
219
|
-
|
|
220
|
-
## 4. Micro-feedback (press, focus, toggle)
|
|
221
|
-
|
|
222
|
-
The smallest layer: `whileTap` and friends. Motion is the right tool (state-driven):
|
|
223
|
-
|
|
224
|
-
```tsx
|
|
225
|
-
import { motion } from "motion/react";
|
|
226
|
-
|
|
227
|
-
<motion.button
|
|
228
|
-
whileTap={{ scale: 0.97 }}
|
|
229
|
-
transition={{ duration: 0.15, ease: [0.25, 1, 0.5, 1] }}
|
|
230
|
-
className="... focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
|
|
231
|
-
>
|
|
232
|
-
Submit
|
|
233
|
-
</motion.button>
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
- Press = scale **0.97**, 150ms. (Not 0.9, that's a toy.)
|
|
237
|
-
- Focus rings: visible, offset, in the accent. Designed, not suppressed. Keyboard users get states as considered as hover users'.
|
|
238
|
-
- Toggles/accordions animate `grid-template-rows: 0fr → 1fr` (or Motion `height: "auto"`), 300ms quart-out, with a chevron rotating on the same clock.
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
## 5. Animated icons (Lordicon / Lottie)
|
|
243
|
-
|
|
244
|
-
An animated icon is a micro-interaction and obeys this file's rules: budget 2 to 3 per view, hover-gated, reduced-motion renders static (the still frame). It's state/lifecycle-driven (a Lottie player toggled on hover/click), so triggering lives in **Motion** territory, not GSAP.
|
|
245
|
-
|
|
246
|
-
The discipline that separates "delightful" from "distracting" is **timing**, and it's locked:
|
|
247
|
-
|
|
248
|
-
| Trigger | Duration | Notes |
|
|
249
|
-
|---|---|---|
|
|
250
|
-
| Click / tap | **≤ 500ms** | must keep up with repeat clicks; longer lags the cursor and annoys by the 3rd press |
|
|
251
|
-
| Hover (decorative) | **1500-3000ms** | the delight sweet spot; more complex symbol → longer end |
|
|
252
|
-
| GIF fallback loop | **2000-5000ms delay between loops** | only if tech forces a looping GIF |
|
|
253
|
-
|
|
254
|
-
- **Never loop in a dashboard / standard UI**; trigger on hover instead. (Looping is for a page *loader*, not ambient decoration. Same as "nothing moves the user didn't cause," [feel-layer.md](feel-layer.md) §5.)
|
|
255
|
-
- **No flashing/flickering** (seizure hazard); **natural physics only**. Unnatural bounce or motion that contradicts the icon's meaning leaves users unsettled.
|
|
256
|
-
|
|
257
|
-
Full icon doctrine (source via Iconify, system vs decorative registers, color/state, labels, accessibility, touch targets) is in [iconography.md](iconography.md).
|
|
258
|
-
|
|
259
|
-
**Cheap tell:** a row of icons all looping forever, or a 700ms "success" spin that lags every click. **Instead:** One hover-triggered animated anchor that plays once, ≤500ms on click, static under reduced motion.
|
|
260
|
-
|
|
261
|
-
---
|
|
262
|
-
|
|
263
|
-
## Selection heuristic
|
|
264
|
-
|
|
265
|
-
| Site genre | Ship | Skip |
|
|
266
|
-
|---|---|---|
|
|
267
|
-
| Portfolio / studio | cursor "View" states, sibling dimming, magnetic CTA | none |
|
|
268
|
-
| Campaign / launch | magnetic CTA, text-swap buttons, underline choreography | cursor follower (usually) |
|
|
269
|
-
| Editorial / content | underline choreography, image scale-in-mask | magnetic, cursor |
|
|
270
|
-
| SaaS / product | press feedback, focus design, hover scenes on cards | cursor follower, magnetic (almost always) |
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# Mobile Composition: translating the art direction to 360-430px
|
|
2
|
-
|
|
3
|
-
The scaling knob ([scaling.md](scaling.md)) already solves *sizing* on mobile, so do **not** re-introduce breakpoint size ladders here. What the knob can't solve is *composition*: overlaps, asymmetric grids, and lockups are authored in twelve columns, and a naive `grid-cols-1` collapse turns art direction back into the centered-stack template. This file is the translation table.
|
|
4
|
-
|
|
5
|
-
The principle: **mobile keeps the attitude, not the geometry.** Asymmetry, trespass, and scale contrast must survive; the specific desktop arrangement doesn't. A mobile page that is a centered column of full-width blocks has lost the design even if every rem is correct.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. Translation table: every desktop technique, its mobile form
|
|
10
|
-
|
|
11
|
-
| Desktop technique | Mobile translation | What survives |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| **Layered lockup** ([composition.md](composition.md) §3): type and image sharing grid cells | Stack, but keep a *deliberate small overlap*: headline pulls `-mb-[0.5em]` over the image top (or image pulls under the headline's last line). Drop `mix-blend-difference` if the overlap zone gets busy at small width; a `pointer-events-none` scrim or plain ink type is more legible | the trespass |
|
|
14
|
-
| **Asymmetric 12-col rows** (features as editorial rows, [anti-patterns.md](anti-patterns.md) §2) | Single column with **alternating indents** (`even:pl-[10vw]`) or a hanging meta column (`grid-cols-[5rem_1fr]`, a year or a place in `text-small`). Never centered | the off-axis rhythm |
|
|
15
|
-
| **Section bleed / seam** ([composition.md](composition.md) §1) | Keep it: negative margins are width-independent. Reduce overlap depth ~50% (`-mb-16` not `-mb-32`) so the seam doesn't eat a whole viewport | the trespass |
|
|
16
|
-
| **Full-bleed breakout** ([composition.md](composition.md) §2) | The `content-grid` collapses gracefully (side lanes hit their 1.5rem floor), but full-bleed only *contrasts* if contained content is visibly inset. Keep body at the `content` lane; never let everything go edge-to-edge | contained vs escaping |
|
|
17
|
-
| **Off-canvas ghost type** ([composition.md](composition.md) §4) | Shrink to ~24-30vw and keep the crop, or drop it. At 18vw-of-360px it's a smudge: if it can't be huge, it shouldn't exist | scale contrast (or nothing) |
|
|
18
|
-
| **Hero composition** | Poster logic still applies: left-aligned h1 (centered mobile heroes = template), meta pushed to the hero's edges, media bleeding off one side rather than politely contained | the poster edges |
|
|
19
|
-
| **Sticky-stack cards** ([depth-parallax.md](depth-parallax.md) §3) | **Keep**: sticky stacking is touch-native. Cards go `min-h-[100svh]` → content-height + `top` offset; keep the recede scrub | everything |
|
|
20
|
-
| **Pinned / horizontal showpieces** | Never pin on touch. The §4 matchMedia + scroll-snap fallback in [pinning-scrub.md](pinning-scrub.md) is the law, not a suggestion | the content, natively |
|
|
21
|
-
| **Multi-speed parallax** ([depth-parallax.md](depth-parallax.md) §1) | Halve the deltas (0.95/1/1.06) or drop to window-parallax only, since small screens amplify relative motion | one breathing layer |
|
|
22
|
-
| **Footer-reveal** ([composition.md](composition.md) §5) | Keep if the footer fits `~60svh` at mobile width; otherwise release to normal flow (`fixed` + tall footers = covered content) | the close |
|
|
23
|
-
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
## 2. Mobile rhythm: the numbers
|
|
27
|
-
|
|
28
|
-
Section padding compresses to roughly **60% of the desktop role values**, keeping the *ratio* between roles (the asymmetry is the design; the absolute air is negotiable):
|
|
29
|
-
|
|
30
|
-
| Role | Desktop | Mobile |
|
|
31
|
-
|---|---|---|
|
|
32
|
-
| Hero | 12-20rem | 8-12rem |
|
|
33
|
-
| Dense content | 6-8rem | 4-5rem |
|
|
34
|
-
| Breather | 16-24rem | 10-14rem |
|
|
35
|
-
|
|
36
|
-
- Express the compression **structurally** (`py-16 md:py-28`, a layout decision and therefore allowed) or via a `--section-pad` token per role. This is not a size ladder, it's one deliberate rhythm change.
|
|
37
|
-
- **Display type floor:** hero h1 bottoms out at 2.875rem (46px at 390), or the pairing's own floor ([typography.md](typography.md) §3.1). The `text-display` token already handles it; check the *line count* instead: a mobile hero h1 over 4 lines needs shorter copy ([page-architecture.md](page-architecture.md) §5), not smaller type.
|
|
38
|
-
- Body measure self-solves via the root knob; check `min-h-[100vh]` sections use `svh` so mobile browser chrome doesn't cause jumps.
|
|
39
|
-
- Horizontal padding: 1.25 to 1.5rem lanes; keep at least one element per screen *ignoring* them (a bleed, a crop) so mobile keeps its grid-breaks too.
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## 3. What to cut vs keep
|
|
44
|
-
|
|
45
|
-
**Cut on touch (already hover-gated; verify, don't assume):** magnetic buttons, cursor followers, hover scenes ([micro-interactions.md](micro-interactions.md) gates them via `(hover: hover)`). Sibling-dimming and tag-slides need a non-hover story: default-visible tags, or active-state on tap.
|
|
46
|
-
|
|
47
|
-
**Keep, tuned:**
|
|
48
|
-
- Reveals and staggers: shorten travel (`y: 24` not 80) and durations ~15%; small screens exaggerate motion.
|
|
49
|
-
- Split-line headlines: `autoSplit` re-splits at mobile line breaks for free ([scroll-reveals.md](scroll-reveals.md) §3).
|
|
50
|
-
- The departure scrub ([pinning-scrub.md](pinning-scrub.md) §3): no pin, so it's touch-safe as-is.
|
|
51
|
-
- Lenis: keep mounted (the provider doctrine, [setup.md](setup.md) §2). It applies lightly or not at all to native touch scrolling, and the ScrollTrigger wiring stays consistent.
|
|
52
|
-
|
|
53
|
-
**Branch declaratively**, one `gsap.matchMedia()` block per component, the same pattern as reduced-motion ([setup.md](setup.md) §4). Never `if (window.innerWidth < 768)` at setup time: it doesn't revert on rotate/resize.
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## 4. The two-viewport check, plus 768 where columns change
|
|
58
|
-
|
|
59
|
-
Design every section at **1440 and 390 simultaneously**, not desktop-then-shrink. Then check **768** on every section whose column count changes at a breakpoint: `md` is 768px wide, and a 12-column split there gives a 5-column text lane about 280px wide, where an `h2` breaks into one word per line and an email input shrinks to nothing. Rule of thumb: **text-plus-media splits start at `lg`**, not `md`; between 768 and 1023 they stay stacked or go to an asymmetric single column. Offsets on revealed elements use padding or margin (`even:pl-[10vw]`), never a `translate-*` utility, which GSAP erases ([pitfalls.md](pitfalls.md) §3.2).
|
|
60
|
-
|
|
61
|
-
Before moving on, ask of the 390 view:
|
|
62
|
-
|
|
63
|
-
1. Is anything overlapping, bleeding, or cropped, or did it collapse to a centered stack?
|
|
64
|
-
2. Do two consecutive sections still have different skeletons?
|
|
65
|
-
3. Is the display type still *display* (≥3× body scale contrast)?
|
|
66
|
-
4. Can a thumb reach/operate everything (48px targets, no pin traps)?
|
|
67
|
-
5. Does the showpiece content still exist (as snap-scroll or stacked list), even though the showpiece mechanics don't?
|
|
68
|
-
|
|
69
|
-
**Cheap tell:** a mobile view where every section is a centered full-width column: same skeleton repeating, overlaps gone, ghost type gone, hero centered. The art direction evaporated below 768px.
|
|
70
|
-
**Instead:** The 390px page still has trespassing elements, alternating indents, one crop, poster-edge heroes, and compressed-but-asymmetric rhythm. It is recognizably the same design, recomposed.
|
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
# Navigation & Header: the most persistent component
|
|
2
|
-
|
|
3
|
-
The header is on screen for 100% of the session; the menu is the one interaction every visitor performs. Templates treat both as chrome (white sticky bar, shadow on scroll, hamburger dropdown); agencies treat the menu as **a scene**, often the most art-directed moment on the site. Assumes the providers from [setup.md](setup.md) and the z-ladder from [composition.md](composition.md) §5 (nav = `z-50`, overlay menu joins it).
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## 1. Header anatomy
|
|
8
|
-
|
|
9
|
-
- **Contents:** wordmark left, 3 to 5 links + one CTA right (or wordmark + menu button only, the "editorial minimal" header). Anything more belongs in the footer's nav echo ([page-architecture.md](page-architecture.md) §4).
|
|
10
|
-
- **Height:** 4 to 5rem, authored in rem so it rides the scaling knob ([scaling.md](scaling.md)).
|
|
11
|
-
- **Surface:** transparent over the hero, then, once real content scrolls under it, either a **hard surface swap** (`bg-surface-0` + `border-b border-line`, on-genre for editorial/brutalist) or `bg-surface-0/80` + `backdrop-blur-md` (the one sanctioned blur in the system, cinematic and luxury registers only; every other `backdrop-blur`, glass cards above all, stays on the blocklist in [../SKILL.md](../SKILL.md) AI-default blocklist). Never a drop shadow: the border *is* the elevation ([anti-patterns.md](anti-patterns.md) §1).
|
|
12
|
-
- **Links:** body-size or smaller, tracked; hover = the origin-flipping underline ([micro-interactions.md](micro-interactions.md) §3 (a)), not a color swap.
|
|
13
|
-
- Wordmark and CTA are prime candidates for the page's 1 to 3 magnetic elements ([micro-interactions.md](micro-interactions.md) §1).
|
|
14
|
-
|
|
15
|
-
**Cheap tell:** `sticky top-0 bg-white shadow-md` with 7 gray links and a blue button. **Instead:** Transparent → surfaced on scroll, hairline-separated, 3 to 5 links, one designed CTA.
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## 2. Scroll behavior: hide-on-down / show-on-up
|
|
20
|
-
|
|
21
|
-
The default for narrative pages: the header gets out of the way while reading (scrolling down) and returns the instant intent reverses (scrolling up). **Pick hide-on-scroll or shrink-on-scroll, never both.** Stacked behaviors read as nervous.
|
|
22
|
-
|
|
23
|
-
```tsx
|
|
24
|
-
"use client";
|
|
25
|
-
|
|
26
|
-
import { useRef } from "react";
|
|
27
|
-
import { gsap, useGSAP, ScrollTrigger } from "@/lib/gsap";
|
|
28
|
-
|
|
29
|
-
export function Header({ children }: { children: React.ReactNode }) {
|
|
30
|
-
const el = useRef<HTMLElement>(null);
|
|
31
|
-
|
|
32
|
-
useGSAP(() => {
|
|
33
|
-
const show = gsap.quickTo(el.current, "yPercent", { duration: 0.4, ease: "power4.out" });
|
|
34
|
-
|
|
35
|
-
ScrollTrigger.create({
|
|
36
|
-
start: "top top",
|
|
37
|
-
end: "max",
|
|
38
|
-
onUpdate: (self) => {
|
|
39
|
-
if (self.scroll() < 80) { show(0); el.current!.dataset.surfaced = "false"; return; }
|
|
40
|
-
el.current!.dataset.surfaced = "true";
|
|
41
|
-
show(self.direction === 1 ? -100 : 0); // down → hide, up → return
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
}, { scope: el });
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<header ref={el} data-surfaced="false"
|
|
48
|
-
className="fixed inset-x-0 top-0 z-50 flex h-16 items-center justify-between px-6
|
|
49
|
-
transition-colors duration-300
|
|
50
|
-
data-[surfaced=true]:border-b data-[surfaced=true]:border-line
|
|
51
|
-
data-[surfaced=true]:bg-surface-0/80 data-[surfaced=true]:backdrop-blur-md">
|
|
52
|
-
{children}
|
|
53
|
-
</header>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Craft notes:
|
|
59
|
-
- `quickTo` (not a tween per event): this fires every scroll frame; same one-clock rule as magnetic buttons.
|
|
60
|
-
- The **80px grace zone** keeps the header stable over the hero; the surface swap and the hide behavior arrive together.
|
|
61
|
-
- Direction flips mid-gesture must respond instantly. That's why show/hide is a `quickTo` chase, not a toggled class with a CSS transition fighting itself.
|
|
62
|
-
- The **shrink variant** (height 5rem → 3.5rem + wordmark scale 0.85, same trigger, `power3.out` 0.35s) suits content-heavy sites where the header should stay visible but quiet.
|
|
63
|
-
- Skip hiding entirely on short pages (< ~3 viewports): the behavior never gets room to read as intentional.
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
## 3. The full-screen overlay menu (the agency staple)
|
|
68
|
-
|
|
69
|
-
Menu opens = a full-viewport inverted panel wipes in; nav links render as **display type** with masked line staggers; a meta column carries contact/socials. The menu is a *destination*, not a dropdown.
|
|
70
|
-
|
|
71
|
-
**Colour: invert the page.** The recipes ship `bg-ink text-surface-0`, which reads on every light preset (Soft Modern, Warm Editorial, Luxury Minimal). On a dark preset (Dark Cinematic), where `ink` is the light colour, use `bg-surface-3 text-ink` instead. Never `bg-surface-3 text-surface-0` on a light preset: that is cream on cream. On the inverted panel, `surface-0` is the text and `surface-2` the muted tier; `ink-muted` on `ink` fails contrast ([vibe-presets.md](vibe-presets.md), token contract).
|
|
72
|
-
|
|
73
|
-
**Build it on an accessible primitive.** An overlay that covers the page is a modal dialog, and a modal needs a focus trap, Escape to close, `aria-modal` semantics, inert background content, and focus return to the trigger. Hand-rolling all of that is where menus break. Use **Radix Dialog** (`Dialog` from the unified `radix-ui` package that [setup.md](setup.md) installs) or an equivalent primitive (React Aria, Ark UI) and let it own accessibility while GSAP owns the motion. §3a shows the motion; §3b shows it wrapped in Radix, which is the version to ship.
|
|
74
|
-
|
|
75
|
-
### 3a. The motion (reference sketch)
|
|
76
|
-
|
|
77
|
-
```tsx
|
|
78
|
-
"use client";
|
|
79
|
-
|
|
80
|
-
import { useRef, useState } from "react";
|
|
81
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
82
|
-
import { useLenis } from "lenis/react";
|
|
83
|
-
|
|
84
|
-
const LINKS = [
|
|
85
|
-
{ href: "/work", label: "Work" },
|
|
86
|
-
{ href: "/studio", label: "Studio" },
|
|
87
|
-
{ href: "/journal", label: "Journal" },
|
|
88
|
-
{ href: "/contact", label: "Contact" },
|
|
89
|
-
];
|
|
90
|
-
|
|
91
|
-
export function OverlayMenu() {
|
|
92
|
-
const scope = useRef<HTMLDivElement>(null);
|
|
93
|
-
const panel = useRef<HTMLDivElement>(null);
|
|
94
|
-
const [open, setOpen] = useState(false);
|
|
95
|
-
const lenis = useLenis();
|
|
96
|
-
|
|
97
|
-
const { contextSafe } = useGSAP({ scope });
|
|
98
|
-
|
|
99
|
-
const toggle = contextSafe(() => {
|
|
100
|
-
const next = !open;
|
|
101
|
-
setOpen(next);
|
|
102
|
-
const links = gsap.utils.toArray<HTMLElement>("[data-menu-link]");
|
|
103
|
-
|
|
104
|
-
if (next) {
|
|
105
|
-
lenis?.stop(); // scroll lock: the Lenis-native way, no overflow:hidden fights
|
|
106
|
-
gsap.timeline()
|
|
107
|
-
.set(panel.current, { display: "block" })
|
|
108
|
-
.fromTo(panel.current, { yPercent: -100 }, { yPercent: 0, duration: 0.6, ease: "power4.inOut" })
|
|
109
|
-
.fromTo(links,
|
|
110
|
-
{ yPercent: 110 },
|
|
111
|
-
{ yPercent: 0, duration: 0.8, ease: "expo.out", stagger: 0.06 }, "-=0.15")
|
|
112
|
-
.fromTo("[data-menu-meta]", { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.5 }, "<0.1");
|
|
113
|
-
} else {
|
|
114
|
-
gsap.timeline({ onComplete: () => { gsap.set(panel.current, { display: "none" }); lenis?.start(); } })
|
|
115
|
-
.to(links, { yPercent: -110, duration: 0.35, ease: "power2.in", stagger: { each: 0.03, from: "end" } })
|
|
116
|
-
.to(panel.current, { yPercent: -100, duration: 0.5, ease: "power4.inOut" }, "-=0.1");
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
return (
|
|
121
|
-
<div ref={scope}>
|
|
122
|
-
<button onClick={toggle} aria-expanded={open} aria-controls="site-menu"
|
|
123
|
-
className="font-ui text-ui relative z-[60]">
|
|
124
|
-
{open ? "Close" : "Menu"}
|
|
125
|
-
</button>
|
|
126
|
-
|
|
127
|
-
<div ref={panel} id="site-menu" role="dialog" aria-modal="true"
|
|
128
|
-
className="fixed inset-0 z-50 hidden bg-ink text-surface-0">
|
|
129
|
-
<nav className="flex h-full flex-col justify-between px-6 pb-10 pt-32">
|
|
130
|
-
<ul>
|
|
131
|
-
{LINKS.map((l) => (
|
|
132
|
-
<li key={l.href} className="-my-[0.1em] overflow-hidden py-[0.1em]">{/* the mask, with descender room */}
|
|
133
|
-
<a data-menu-link href={l.href}
|
|
134
|
-
className="font-display text-display block">
|
|
135
|
-
{l.label}
|
|
136
|
-
</a>
|
|
137
|
-
</li>
|
|
138
|
-
))}
|
|
139
|
-
</ul>
|
|
140
|
-
<div data-menu-meta className="text-small flex justify-between">
|
|
141
|
-
<span>hello@studio.com</span><span>Oslo, 14:32</span>
|
|
142
|
-
</div>
|
|
143
|
-
</nav>
|
|
144
|
-
</div>
|
|
145
|
-
</div>
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Load-bearing details:
|
|
151
|
-
- **`lenis.stop()` / `lenis.start()`** is the scroll lock. Never `overflow: hidden` on body, which fights the smooth-scroll provider and jumps the page.
|
|
152
|
-
- Links live in `overflow-hidden` `<li>` masks: the masked line reveal ([scroll-reveals.md](scroll-reveals.md) §3) at menu scale, with the same 0.1em descender padding as `.split-line-mask`. The exit reverses with `power2.in` at ~60% duration and `from: "end"`, the designed exit rule ([transitions.md](transitions.md) §4).
|
|
153
|
-
- The menu button sits **above** the panel (`z-[60]`) and swaps its label. The close affordance is designed, not a reflexive close-icon glyph.
|
|
154
|
-
- Menu links can navigate through the page-flow wipe ([route-transitions.md](route-transitions.md)): close the menu, then `push(href)`, never both animations at once.
|
|
155
|
-
- **A11y is non-optional:** `role="dialog"` + `aria-modal`, `aria-expanded`/`aria-controls` on the trigger, `Esc` closes, focus moves into the panel on open and returns to the trigger on close, and focus is trapped while open. The sketch above declares the semantics but does not implement the trap or Escape; §3b gets all of it from the primitive. Reduced motion: panel and links fade 0.2s, no travel.
|
|
156
|
-
- In-page anchor variant (one-pagers): links call `lenis.scrollTo(target)` after close. `anchors: true` covers plain hash links already.
|
|
157
|
-
|
|
158
|
-
### 3b. The same menu on Radix Dialog (ship this)
|
|
159
|
-
|
|
160
|
-
Radix supplies the focus trap, Escape handling, `aria-modal`, inert background, and focus return to the trigger. GSAP keeps the choreography. The only wiring decisions are who controls `open`, and when the exit finishes.
|
|
161
|
-
|
|
162
|
-
```tsx
|
|
163
|
-
"use client";
|
|
164
|
-
|
|
165
|
-
import { useCallback, useRef, useState, type MouseEvent } from "react";
|
|
166
|
-
import { useRouter } from "next/navigation";
|
|
167
|
-
import { Dialog } from "radix-ui";
|
|
168
|
-
import { gsap } from "@/lib/gsap";
|
|
169
|
-
import { useLenis } from "lenis/react";
|
|
170
|
-
|
|
171
|
-
// LINKS as in §3a
|
|
172
|
-
|
|
173
|
-
export function OverlayMenu() {
|
|
174
|
-
const [open, setOpen] = useState(false);
|
|
175
|
-
const panel = useRef<HTMLDivElement | null>(null);
|
|
176
|
-
const enterTl = useRef<gsap.core.Timeline | null>(null);
|
|
177
|
-
const closing = useRef(false);
|
|
178
|
-
const pendingHref = useRef<string | null>(null);
|
|
179
|
-
const router = useRouter();
|
|
180
|
-
const lenis = useLenis();
|
|
181
|
-
|
|
182
|
-
// Enter: Radix mounts the content, the ref callback plays the timeline.
|
|
183
|
-
const setPanel = useCallback((node: HTMLDivElement | null) => {
|
|
184
|
-
panel.current = node;
|
|
185
|
-
if (!node) return;
|
|
186
|
-
const links = node.querySelectorAll("[data-menu-link]");
|
|
187
|
-
enterTl.current = gsap.timeline()
|
|
188
|
-
.fromTo(node, { yPercent: -100 }, { yPercent: 0, duration: 0.6, ease: "power4.inOut" })
|
|
189
|
-
.fromTo(links, { yPercent: 110 },
|
|
190
|
-
{ yPercent: 0, duration: 0.8, ease: "expo.out", stagger: 0.06 }, "-=0.15")
|
|
191
|
-
.fromTo(node.querySelector("[data-menu-meta]"), { autoAlpha: 0, y: 16 },
|
|
192
|
-
{ autoAlpha: 1, y: 0, duration: 0.5 }, "<0.1");
|
|
193
|
-
}, []);
|
|
194
|
-
|
|
195
|
-
// Radix calls this for Escape, the Close button, and outside interaction.
|
|
196
|
-
// Opening is immediate; closing plays the exit first, then unmounts.
|
|
197
|
-
const onOpenChange = (next: boolean) => {
|
|
198
|
-
if (next) { lenis?.stop(); setOpen(true); return; }
|
|
199
|
-
if (closing.current || !panel.current) return;
|
|
200
|
-
closing.current = true;
|
|
201
|
-
enterTl.current?.kill(); // a fast close must not fight a half-played entrance
|
|
202
|
-
const node = panel.current;
|
|
203
|
-
gsap.timeline({
|
|
204
|
-
onComplete: () => {
|
|
205
|
-
closing.current = false;
|
|
206
|
-
setOpen(false);
|
|
207
|
-
lenis?.start();
|
|
208
|
-
const href = pendingHref.current;
|
|
209
|
-
pendingHref.current = null;
|
|
210
|
-
if (href) router.push(href); // navigate only after the exit has finished
|
|
211
|
-
},
|
|
212
|
-
})
|
|
213
|
-
.to(node.querySelectorAll("[data-menu-link]"),
|
|
214
|
-
{ yPercent: -110, duration: 0.35, ease: "power2.in", stagger: { each: 0.03, from: "end" } })
|
|
215
|
-
.to(node, { yPercent: -100, duration: 0.5, ease: "power4.inOut" }, "-=0.1");
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
// Keep the real href (crawlers, middle-click, new tab) but take over a plain click.
|
|
219
|
-
const onLinkClick = (e: MouseEvent<HTMLAnchorElement>, href: string) => {
|
|
220
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
221
|
-
e.preventDefault();
|
|
222
|
-
pendingHref.current = href;
|
|
223
|
-
onOpenChange(false);
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
return (
|
|
227
|
-
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
228
|
-
{/* Mobile-only menu: desktop shows inline links in the header (§4), so the trigger
|
|
229
|
-
hides from md up. Drop md:hidden for an "editorial minimal" header on every width. */}
|
|
230
|
-
<Dialog.Trigger className="font-ui text-ui h-12 md:hidden">Menu</Dialog.Trigger>
|
|
231
|
-
|
|
232
|
-
<Dialog.Portal>
|
|
233
|
-
<Dialog.Content ref={setPanel} aria-describedby={undefined}
|
|
234
|
-
className="fixed inset-0 z-50 bg-ink text-surface-0">
|
|
235
|
-
<Dialog.Title className="sr-only">Site menu</Dialog.Title>
|
|
236
|
-
|
|
237
|
-
{/* The trigger is inert while the modal is open, so Close lives inside the panel.
|
|
238
|
-
It must sit EXACTLY over the header's Menu button: same right inset as the header's
|
|
239
|
-
px-6, same height (h-12) and the same vertical centre as the h-16 header bar, so the
|
|
240
|
-
label swaps in place instead of jumping. Change one, change both. */}
|
|
241
|
-
<Dialog.Close className="font-ui text-ui absolute right-6 top-2 h-12">
|
|
242
|
-
Close
|
|
243
|
-
</Dialog.Close>
|
|
244
|
-
|
|
245
|
-
<nav className="flex h-full flex-col justify-between px-6 pb-10 pt-32">
|
|
246
|
-
<ul>
|
|
247
|
-
{LINKS.map((l) => (
|
|
248
|
-
<li key={l.href} className="-my-[0.1em] overflow-hidden py-[0.1em]">
|
|
249
|
-
<a data-menu-link href={l.href} onClick={(e) => onLinkClick(e, l.href)}
|
|
250
|
-
className="font-display text-display block">
|
|
251
|
-
{l.label}
|
|
252
|
-
</a>
|
|
253
|
-
</li>
|
|
254
|
-
))}
|
|
255
|
-
</ul>
|
|
256
|
-
<div data-menu-meta className="text-small flex justify-between">
|
|
257
|
-
<span>hello@studio.com</span><span>Oslo, 14:32</span>
|
|
258
|
-
</div>
|
|
259
|
-
</nav>
|
|
260
|
-
</Dialog.Content>
|
|
261
|
-
</Dialog.Portal>
|
|
262
|
-
</Dialog.Root>
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
Notes on the Radix version:
|
|
268
|
-
- **Controlled `open`** is what lets the exit animation finish before Radix unmounts the content. `onOpenChange(false)` starts the exit; `setOpen(false)` runs only in the timeline's `onComplete`.
|
|
269
|
-
- **Scroll lock still coordinates with Lenis.** Radix applies its own modal scroll lock; keep `lenis.stop()` on open and `lenis.start()` after the exit so Lenis' wheel and touch handling halts too. Put `data-lenis-prevent` on any scrollable region inside the menu so it can scroll natively while Lenis is running elsewhere.
|
|
270
|
-
- **`Dialog.Title` is required** for screen readers (hide it visually with `sr-only`). `aria-describedby={undefined}` is the documented way to opt out of a description when there isn't one.
|
|
271
|
-
- **Focus** moves into the panel on open and returns to the trigger on close without extra code. Do not fight it with manual `focus()` calls.
|
|
272
|
-
- **Links navigate client-side after the exit.** A plain `<a href>` navigates immediately and cuts the exit short, and a full reload drops the Lenis and transition providers. `onLinkClick` stores the target in `pendingHref`, prevents the default, starts the exit, and `router.push` runs in the exit timeline's `onComplete`, so the motion stays strictly sequential. The `href` stays real for crawlers and modified clicks. On a site with route curtains, swap `useRouter` for `useTransitionRouter()` from the provider in [route-transitions.md](route-transitions.md) (its `push` is the same `navigate` that `TransitionLink` calls) so the menu exit hands straight into the curtain.
|
|
273
|
-
- **The enter timeline is kept in a ref and killed before the exit runs.** Escape pressed mid-entrance otherwise leaves two timelines writing the same `yPercent`.
|
|
274
|
-
- **Reduced motion:** branch with `gsap.matchMedia()` ([setup.md](setup.md) §4) and replace the travel with a 0.2s `autoAlpha` fade in both timelines.
|
|
275
|
-
|
|
276
|
-
**Cheap tell:** hamburger → white dropdown of body-size links sliding 200ms `ease-in-out`, with no focus trap and Escape ignored. **Instead:** A surface wipe, display-type links rising from masks, meta column, choreographed exit, scroll properly locked, and a real dialog primitive underneath.
|
|
277
|
-
|
|
278
|
-
---
|
|
279
|
-
|
|
280
|
-
## 4. Mobile
|
|
281
|
-
|
|
282
|
-
- **The overlay IS the mobile nav.** One menu system for all widths: desktop may *also* show inline links, mobile shows only wordmark + menu button. Never a separate "mobile hamburger dropdown." The usual split: the inline links carry `max-md:hidden`, the trigger carries `md:hidden` (§3b), and both live in the same header so the Close button can sit over Menu.
|
|
283
|
-
- Touch targets ≥ 48×48px for the menu button and each link row ([iconography.md](iconography.md) §7); the display-type links clear this by default.
|
|
284
|
-
- Hide-on-scroll stays on. It's *more* valuable on small screens where 4rem of chrome is 5% of the viewport. The `backdrop-blur` surface is fine on modern mobile; if the page is effect-heavy, prefer the hard surface swap (blur stacks with everything else on the compositor).
|
|
285
|
-
- The wordmark stays ≤ ~40% of viewport width; long names abbreviate to a mark.
|
|
286
|
-
|
|
287
|
-
---
|
|
288
|
-
|
|
289
|
-
## Header checklist
|
|
290
|
-
|
|
291
|
-
- [ ] Transparent over hero → surfaced after ~80px (border or sanctioned blur, never shadow)
|
|
292
|
-
- [ ] One scroll behavior (hide *or* shrink), quickTo-driven
|
|
293
|
-
- [ ] 3 to 5 links + one CTA max; overlay menu for everything else
|
|
294
|
-
- [ ] Menu: built on Radix Dialog (or equivalent), Lenis stop/start, masked link stagger in, reversed exit, ≤ 1s total
|
|
295
|
-
- [ ] Dialog semantics from the primitive: title, Esc, focus trap + return; trigger has `aria-expanded`; reduced-motion fade
|
|
296
|
-
- [ ] Mobile: same overlay, 48px targets, no dropdown variant
|