@struq/skill 0.3.0 → 0.4.0

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.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /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