@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.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +4 -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 +4 -4
  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,390 +0,0 @@
1
- # SVG Motion: DrawSVG, MorphSVG, MotionPath
2
-
3
- SVG motion is how a site signs its name: a line that draws the story down the page, a mark that writes itself on first load, a logo that folds into an icon. Done well it costs almost nothing (a dash offset, a path string); done badly it is a squiggle that plays on a loop for no reason. Every recipe here is tied to scroll, a state change or a first visit, never to a timer.
4
-
5
- Prerequisites: the registry and `useGSAP` rules in [setup.md](setup.md), reduced motion via `gsap.matchMedia()` ([setup.md](setup.md) §4). API pointers: [gsap-api.md](gsap-api.md) §9.
6
-
7
- ## 1. Registration
8
-
9
- All three plugins ship in the public `gsap` package since 3.13. Register only the ones the build uses, in the one registry module:
10
-
11
- ```ts
12
- // lib/gsap.ts (additions)
13
- import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
14
- import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
15
- import { MotionPathPlugin } from "gsap/MotionPathPlugin";
16
-
17
- gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP, DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin);
18
- export { DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin };
19
- ```
20
-
21
- ## 2. Stroke setup and the gotchas
22
-
23
- DrawSVG animates `stroke-dashoffset` and `stroke-dasharray` on `path`, `line`, `polyline`, `polygon`, `rect`, `circle` and `ellipse`. `drawSVG: 0` is nothing drawn, `"100%"` the full stroke, `"40% 60%"` a segment, and `"50% 50%"` to `"0% 100%"` draws from the middle outward.
24
-
25
- ```css
26
- .draw { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
27
- .draw-fluid { vector-effect: non-scaling-stroke; } /* static strokes only: never on a path DrawSVG animates */
28
- ```
29
-
30
- - **Colour from tokens.** `stroke: currentColor` and a `text-ink` or `text-accent` class on the `<svg>`. Never a hex in the SVG file.
31
- - **Never combine `preserveAspectRatio="none"` with `vector-effect: non-scaling-stroke` on a drawn path.** The stroke is then dashed in screen space while DrawSVG measures in user space under a non-uniform scale, so the dash length is wrong and the line ends early or never finishes. A drawn path scales uniformly (default `preserveAspectRatio`), or is rebuilt in CSS pixels in a `viewBox` that matches its box and re-measured on resize (§3). A short, nearly flat swash may stretch without `vector-effect` (it measures correctly, the slight stroke distortion is invisible at that size, §5). `draw-fluid` is for static hairlines that are never drawn.
32
- - **A drawn motif used at several sizes takes its stroke width as a prop.** One 120-unit profile rendered at 72px and at 600px is either a hairline or a marker with a fixed `stroke-width`, and `vector-effect: non-scaling-stroke` is off the table on a drawn path (above). Pass the weight per use so the rendered stroke stays about 1 to 1.5px: `strokeWidth = 1.25 * (viewBoxWidth / renderedWidthPx)`, written as a prop (`<Motif weight={2.1} />` at 72px, `weight={0.25}` at 600px).
33
- - **Delete `pathLength` attributes.** Exported SVGs and CSS dash tutorials often set `pathLength="1"`, which rescales dash units; DrawSVG's measured lengths then stop matching and the line ends early or never finishes.
34
- - **No existing dashes.** DrawSVG owns `stroke-dasharray`. A dashed line has to be faked (a mask over a dashed copy), not drawn directly.
35
- - **Round caps show a dot at 0.** With `stroke-linecap: round`, a zero-length stroke still paints a dot. Start the element hidden and reveal it as drawing begins: `gsap.set(el, { autoAlpha: 1 })` right before the draw, or fade in over the first 5% of a scrub.
36
- - **Outlined text cannot be drawn.** A font converted to outlines is filled shapes with two contours per stroke; DrawSVG traces both outlines. Signatures and handwriting need a **single-stroke centreline** path. For filled lettering, draw a thick centreline inside a `<mask>` and let it reveal the fill (§4).
37
- - **Draw direction is path direction.** If it draws backwards, reverse the path in the editor or animate `drawSVG: "100% 100%"` to `"0% 100%"`.
38
-
39
- ## 3. `path-journey`: a scrub-drawn line through the sections
40
-
41
- One long path behind several sections, drawn as you scroll, connecting the chapters. It sits at plane -1 (behind content, above the background) and never crosses running text.
42
-
43
- ```tsx
44
- "use client";
45
- import { useLayoutEffect, useRef, useState } from "react";
46
- import { gsap, useGSAP } from "@/lib/gsap";
47
-
48
- // Control points as fractions of the wrapper box; the path is rebuilt in CSS pixels.
49
- const journey = (w: number, h: number) =>
50
- `M${0.8 * w} 0 C ${0.8 * w} ${0.18 * h}, ${0.2 * w} ${0.22 * h}, ${0.2 * w} ${0.4 * h} ` +
51
- `S ${0.8 * w} ${0.64 * h}, ${0.7 * w} ${0.8 * h} S ${0.3 * w} ${0.92 * h}, ${0.5 * w} ${h}`;
52
-
53
- export function PathJourney({ children }: { children: React.ReactNode }) {
54
- const root = useRef<HTMLDivElement>(null);
55
- const [box, setBox] = useState({ w: 0, h: 0 });
56
-
57
- useLayoutEffect(() => {
58
- const el = root.current!;
59
- const ro = new ResizeObserver(([e]) => {
60
- const { width, height } = e.contentRect;
61
- setBox((b) => (b.w === Math.round(width) && b.h === Math.round(height) ? b : { w: Math.round(width), h: Math.round(height) }));
62
- });
63
- ro.observe(el);
64
- return () => ro.disconnect();
65
- }, []);
66
-
67
- useGSAP(() => {
68
- if (!box.w) return;
69
- const mm = gsap.matchMedia();
70
- mm.add("(prefers-reduced-motion: no-preference)", () => {
71
- gsap.set("[data-journey]", { autoAlpha: 1 });
72
- gsap.fromTo("[data-journey]", { drawSVG: 0 }, {
73
- drawSVG: "100%",
74
- ease: "none",
75
- scrollTrigger: { trigger: root.current, start: "top 60%", end: "bottom 80%", scrub: 1 },
76
- });
77
- });
78
- }, { scope: root, dependencies: [box.w, box.h], revertOnUpdate: true });
79
-
80
- return (
81
- <div ref={root} className="relative">
82
- {box.w > 0 && (
83
- <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full text-accent"
84
- viewBox={`0 0 ${box.w} ${box.h}`}>
85
- <path data-journey className="draw" d={journey(box.w, box.h)} />
86
- </svg>
87
- )}
88
- <div className="relative">{children}</div>
89
- </div>
90
- );
91
- }
92
- ```
93
-
94
- - **The rule:** a full-height drawn path is never stretched. `preserveAspectRatio="none"` with `vector-effect: non-scaling-stroke` looks like the easy way to fit one path to any content length, but DrawSVG cannot measure a stroke drawn that way (§2). Instead the `viewBox` is the wrapper's own pixel box, so one user unit is one CSS pixel, the stroke stays 1.5px with no `vector-effect`, and the curve is rebuilt from fractions of the box.
95
- - **Resize re-measures.** The `ResizeObserver` updates the box only when the rounded size changes; `revertOnUpdate: true` kills the old tween and its ScrollTrigger and builds a fresh one against the new `d`, so DrawSVG always measures the path that is on screen. Check it at 390 and 1440.
96
- - **Scrub, never a threshold.** `ease: "none"` and `scrub: 1`: the line is where the reader is ([feel-layer.md](feel-layer.md)). `start: "top 60%"` keeps the drawing tip near the middle of the viewport, where the eye already is.
97
- - **Mobile is authored.** At 390px a wide S-curve crosses the text column. Write a second, straighter builder (a gentle edge-hugging line) and pick it from the box: `d={box.w < 768 ? edge(box.w, box.h) : journey(box.w, box.h)}`. The resize rebuild swaps it for free.
98
- - **Reduced motion:** no tween is created, so the full path is drawn statically. The path itself is the design; motion only stages it.
99
- - Want something travelling along the line? The pixel `viewBox` scales uniformly, so a MotionPath follower can ride this exact path (§9).
100
- - Pairs with `pin-horizontal-gallery` or `manifesto-fill` in a brand-story arc ([showcase.md](showcase.md)), never with a second scrub-drawn line.
101
-
102
- ## 4. Signature or logo draw
103
-
104
- A mark that writes itself: strokes in order, each at a speed proportional to its length, so long strokes do not race.
105
-
106
- ```tsx
107
- "use client";
108
- import { useRef } from "react";
109
- import { gsap, useGSAP } from "@/lib/gsap";
110
-
111
- export function SignatureDraw() {
112
- const root = useRef<SVGSVGElement>(null);
113
-
114
- useGSAP(() => {
115
- const mm = gsap.matchMedia();
116
- mm.add("(prefers-reduced-motion: no-preference)", () => {
117
- const strokes = gsap.utils.toArray<SVGPathElement>("[data-stroke]");
118
- gsap.set(strokes, { autoAlpha: 0 }); // no round-cap dots waiting on the page
119
- const tl = gsap.timeline({
120
- scrollTrigger: { trigger: root.current, start: "top 75%", once: true },
121
- });
122
- let at = 0;
123
- strokes.forEach((p) => {
124
- const d = gsap.utils.clamp(0.25, 1.2, p.getTotalLength() / 400); // seconds per stroke, by length
125
- tl.set(p, { autoAlpha: 1 }, at)
126
- .fromTo(p, { drawSVG: 0 }, { drawSVG: "100%", duration: d, ease: "power2.inOut" }, "<");
127
- at += d - 0.08; // the next stroke starts 0.08s before this one ends
128
- });
129
- });
130
- }, { scope: root });
131
-
132
- return (
133
- <svg ref={root} viewBox="0 0 320 120" aria-label="Signed, the studio" role="img" className="w-64 text-ink">
134
- <path data-stroke className="draw" d="M10 80 C 40 20, 70 20, 80 70 S 120 110, 140 60" />
135
- <path data-stroke className="draw" d="M150 60 C 170 30, 200 30, 210 70 S 260 100, 300 40" />
136
- </svg>
137
- );
138
- }
139
- ```
140
-
141
- - `power2.inOut` per stroke reads as a hand: it accelerates into the line and settles at the end. A linear draw reads as a plotter.
142
- - Every stroke is hidden up front and revealed at the exact moment its own draw starts, so no stroke shows a round-cap dot while it waits its turn.
143
- - The 0.08s overlap keeps the pen moving between strokes; a gap reads as a stutter.
144
- - **Filled wordmark?** Keep the fill and reveal it through a mask: `<mask id="m"><path data-stroke className="draw" stroke="white" strokeWidth={14} d="(centreline)" /></mask><g mask="url(#m)">(filled letters)</g>`. The thick stroke draws, the mask follows it, and the real letterforms appear behind the pen.
145
- - **When not to:** a mark that is geometric (circles, bars). Drawing a circle reads as a loading spinner; use a mask wipe or a scale-in instead.
146
-
147
- ## 5. Link underline draw
148
-
149
- The straight underline is pure CSS ([micro-interactions.md](micro-interactions.md) §3a); use that. DrawSVG earns its place only when the line is not straight: a hand-drawn swash under one word in a headline, or the main CTA.
150
-
151
- ```tsx
152
- "use client";
153
- import { useRef } from "react";
154
- import { gsap, useGSAP } from "@/lib/gsap";
155
-
156
- export function SwashLink({ href, children }: { href: string; children: React.ReactNode }) {
157
- const root = useRef<HTMLAnchorElement>(null);
158
- const tl = useRef<gsap.core.Timeline | null>(null);
159
-
160
- useGSAP(() => {
161
- const mm = gsap.matchMedia();
162
- mm.add("(hover: hover) and (prefers-reduced-motion: no-preference)", () => {
163
- tl.current = gsap.timeline({ paused: true })
164
- .set("[data-swash]", { autoAlpha: 1 })
165
- .fromTo("[data-swash]", { drawSVG: 0 }, { drawSVG: "100%", duration: 0.45, ease: "power3.out" });
166
- return () => { tl.current = null; };
167
- });
168
- }, { scope: root });
169
-
170
- return (
171
- <a ref={root} href={href} className="relative inline-block"
172
- onPointerEnter={() => tl.current?.play()} onPointerLeave={() => tl.current?.reverse()}
173
- onFocus={() => tl.current?.play()} onBlur={() => tl.current?.reverse()}>
174
- {children}
175
- <svg aria-hidden viewBox="0 0 200 12" preserveAspectRatio="none" className="absolute -bottom-2 left-0 h-3 w-full text-accent">
176
- <path data-swash className="draw invisible" d="M2 8 C 50 2, 120 12, 198 4" />
177
- </svg>
178
- </a>
179
- );
180
- }
181
- ```
182
-
183
- On touch and under reduced motion no timeline exists; show the swash statically on the active link if the design needs it, or leave it out. The focus handlers make the keyboard path identical to hover.
184
-
185
- ## 6. `draw-mark-loader`: the DrawMark preset, wired
186
-
187
- The loader shell, the session gate and the scroll lock live in [loaders.md](loaders.md); this is the DrawSVG body for its DrawMark preset. The loader invariant from [../SKILL.md](../SKILL.md) holds: the revisit gate and the live animation are separate, and the `seen` flag is written only after the exit finishes.
188
-
189
- ```css
190
- /* Before React runs, the SSR markup must not paint a finished mark for a frame. */
191
- .dm-mark { visibility: hidden; }
192
- ```
193
-
194
- ```tsx
195
- "use client";
196
- import { useRef, useState } from "react";
197
- import { gsap, useGSAP } from "@/lib/gsap";
198
- import { loaderSeen, markLoaderSeen } from "./useFirstVisit"; // loaders.md
199
-
200
- export function LoaderDrawMark({ id = "intro" }: { id?: string }) {
201
- const root = useRef<HTMLDivElement>(null);
202
- const [done, setDone] = useState(false);
203
-
204
- useGSAP(() => {
205
- if (loaderSeen(id)) { setDone(true); return; } // revisit: gone before paint
206
- const finish = () => { markLoaderSeen(id); setDone(true); };
207
- const mm = gsap.matchMedia();
208
- mm.add({ motion: "(prefers-reduced-motion: no-preference)", reduced: "(prefers-reduced-motion: reduce)" }, (ctx) => {
209
- const tl = gsap.timeline({ onComplete: finish });
210
- if (ctx.conditions!.reduced) {
211
- tl.set(".dm-mark", { visibility: "visible", autoAlpha: 0 })
212
- .to(".dm-mark", { autoAlpha: 1, duration: 0.2 })
213
- .to(root.current, { autoAlpha: 0, duration: 0.3 }, "+=0.3");
214
- return;
215
- }
216
- tl.set(".dm-mark", { visibility: "visible" })
217
- .fromTo(".dm-stroke", { drawSVG: 0 }, { drawSVG: "100%", duration: 1.1, ease: "power2.inOut", stagger: 0.12 })
218
- .from(".dm-word", { yPercent: 110, duration: 0.7, ease: "expo.out" }, "-=0.35")
219
- .to(root.current, { autoAlpha: 0, duration: 0.6, ease: "power2.inOut" }, "+=0.25");
220
- });
221
- }, { scope: root });
222
-
223
- if (done) return null;
224
- return (
225
- <div ref={root} aria-hidden className="fixed inset-0 z-[10001] grid place-items-center bg-surface-0 text-ink">
226
- <div className="dm-mark flex items-center gap-4">
227
- <svg viewBox="0 0 64 64" className="h-14 w-14">
228
- <path className="dm-stroke draw" d="M12 48 L32 12 L52 48" />
229
- <path className="dm-stroke draw" d="M20 36 H44" />
230
- </svg>
231
- <span className="overflow-clip"><span className="dm-word block font-display text-h3">Brand</span></span>
232
- </div>
233
- </div>
234
- );
235
- }
236
- ```
237
-
238
- - Total runtime about 2.3s on a first visit, zero on a revisit. Longer than 2.5s and the loader is in the way, not a welcome.
239
- - The page renders and paints under the overlay ([seo.md](seo.md) §6); the loader only covers it. The hero's `line-rise-hero` starts on the overlay's fade ([typography.md](typography.md) §5).
240
- - Use the real mark's paths, converted to single-stroke where the mark is drawn from lines ([brand.md](brand.md)).
241
-
242
- ## 7. `logo-morph`: MorphSVG
243
-
244
- MorphSVG rewrites a shape's geometry between two shapes. The target can be an element or a raw path string: `gsap.to(logo, { morphSVG: icon })`. A selector string also works, but it resolves lazily against the whole document, not your component (see the scoping note below).
245
-
246
- **Rules that decide whether a morph looks designed or melted:**
247
-
248
- - **Path, polyline, polygon.** MorphSVG morphs `<path>`, `<polyline>` and `<polygon>` in place (it rewrites `d` or `points`; the element stays the same node). `<circle>`, `<rect>` and `<ellipse>` need `MorphSVGPlugin.convertToPath(...)` first, and that call is the only one that **replaces** elements, which pulls nodes out from under React. Run it on an SVG React never re-renders, or better, convert once in the editor and commit paths.
249
- - **Scope plugin selectors yourself.** `useGSAP`'s `scope` scopes the tween's *target* selector, but strings inside plugin config (`morphSVG.shape`, `motionPath.path`, `motionPath.align`) are resolved later by the plugin against `document`. With two instances on a page they grab the first match. Query the element from the component root and pass it: `root.current!.querySelector("[data-icon]")`.
250
- - **One path to one path.** A five-letter wordmark morphing into one icon is five morphs, not one: morph the letter that becomes the icon and fade or scale the others away. Compound paths (several subpaths in one `d`) work best when both shapes have the same number of subpaths.
251
- - **`shapeIndex`** chooses which point on the start shape maps to the start of the end shape. The default (`"auto"`) is usually right; when a morph twists or folds through itself, try small integers (`shapeIndex: 3`) until it travels cleanly.
252
- - **`type: "rotational"`** interpolates points by angle and distance around a centre instead of in straight lines. Use it when linear morphing makes the shape collapse through its middle (blobs, organic marks, shapes that rotate as they change).
253
- - Duration 0.5 to 0.8s, `power3.inOut` or the quart in-out curve: a morph is an A to B with both ends visible.
254
-
255
- **Header mark that folds into an icon on scroll.** Wordmark at the top of the page, compact icon once the hero is past:
256
-
257
- ```tsx
258
- "use client";
259
- import { useRef } from "react";
260
- import { gsap, useGSAP, ScrollTrigger } from "@/lib/gsap";
261
-
262
- export function MorphingMark() {
263
- const root = useRef<SVGSVGElement>(null);
264
-
265
- useGSAP(() => {
266
- const mm = gsap.matchMedia();
267
- mm.add("(prefers-reduced-motion: no-preference)", () => {
268
- const icon = root.current!.querySelector<SVGPathElement>("[data-icon]")!; // element, not a selector string
269
- const tl = gsap.timeline({ paused: true, defaults: { duration: 0.6, ease: "power3.inOut" } })
270
- .to("[data-shape]", { morphSVG: { shape: icon, type: "rotational" } }, 0)
271
- .to("[data-rest]", { autoAlpha: 0, x: -8, stagger: 0.03 }, 0);
272
- ScrollTrigger.create({
273
- start: () => window.innerHeight * 0.4, // scroll position in px: 40% of the first viewport
274
- end: "max",
275
- onEnter: () => tl.play(),
276
- onLeaveBack: () => tl.reverse(),
277
- });
278
- });
279
- }, { scope: root });
280
-
281
- return (
282
- <svg ref={root} viewBox="0 0 160 40" role="img" aria-label="Brand" className="h-8 text-ink">
283
- <path data-shape fill="currentColor" d="(first letter of the wordmark)" />
284
- <g data-rest fill="currentColor">{/* remaining letters */}</g>
285
- <path data-icon d="(the icon)" className="hidden" />
286
- </svg>
287
- );
288
- }
289
- ```
290
-
291
- The `[data-icon]` path only supplies shape data, so it stays hidden. Under reduced motion swap the two with a cross-fade instead (Motion `AnimatePresence` or a CSS opacity transition), since the state change still has to read.
292
-
293
- **Play and pause.** The cleanest icon morph there is, if both shapes have matching subpaths. Draw play as two halves so it matches pause's two bars:
294
-
295
- ```tsx
296
- const PLAY = "M7 5 L12 8.2 L12 15.8 L7 19 Z M12 8.2 L18 12 L18 12 L12 15.8 Z";
297
- const PAUSE = "M7 5 H10.5 V19 H7 Z M13.5 5 H17 V19 H13.5 Z";
298
-
299
- // in a click handler made with contextSafe from useGSAP:
300
- gsap.to(iconPath, { morphSVG: playing ? PAUSE : PLAY, duration: 0.35, ease: "power3.inOut" });
301
- ```
302
-
303
- Two subpaths of four points each on both sides, so every point has a partner and the morph never tears. Reduced motion: set the `d` directly, no tween.
304
-
305
- **Blobs.** A blob that morphs between organic shapes is the classic tell when it loops on a timer ("nothing moves the user did not cause", [feel-layer.md](feel-layer.md) §5). Tie it to scroll (`scrub`) or to hover, use `type: "rotational"`, keep two or three shapes with similar point counts, and keep it behind content at low contrast.
306
-
307
- ## 8. Morph during a route transition
308
-
309
- The cover overlay from [route-transitions.md](route-transitions.md) is the perfect stage for a mark morph: the page is hidden, the eye has nothing else to watch, and the morph fills the swap. Add the mark inside the overlay and play the morph inside the cover's window:
310
-
311
- ```tsx
312
- // PageTransition.tsx, additions only. Inside the overlay wrapper, above the panel:
313
- <svg ref={mark} viewBox="0 0 64 64" className="absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 text-surface-0 opacity-0">
314
- <path data-from fill="currentColor" d="(mark)" />
315
- <path data-to className="hidden" d="(alternate shape)" />
316
- </svg>
317
-
318
- // In navigate(), after cover(...) starts, when motion is allowed:
319
- if (!reduce && mark.current) {
320
- const p = mark.current.querySelector<SVGPathElement>("[data-from]")!;
321
- const to = mark.current.querySelector<SVGPathElement>("[data-to]")!;
322
- gsap.timeline()
323
- .to(mark.current, { opacity: 1, duration: 0.2 }, 0.3)
324
- .to(p, { morphSVG: to, duration: 0.5, ease: "power3.inOut" }, 0.35)
325
- .to(mark.current, { opacity: 0, duration: 0.2 }, 0.85)
326
- .set(p, { morphSVG: p }); // reset to its own shape for the next navigation
327
- }
328
- ```
329
-
330
- - Keep the whole thing inside the cover and reveal: the budget is still 900ms per navigation ([route-transitions.md](route-transitions.md)). If the morph needs longer, the morph is too complex.
331
- - The final `set` with the path itself as the target restores its original shape; MorphSVG records the original `d` for exactly this.
332
- - One mark, one morph. A different shape per route turns navigation into a slideshow.
333
-
334
- ## 9. MotionPath: follow a path
335
-
336
- An element that travels along a curve: a dot running the journey line, a plane crossing a map, a cursor of light along a diagram.
337
-
338
- ```tsx
339
- // inside a component with `const root = useRef<HTMLDivElement>(null)` on the section, holding a
340
- // uniformly scaled <svg> with <path data-route className="draw" /> and a <div data-follower />
341
- useGSAP(() => {
342
- const mm = gsap.matchMedia();
343
- mm.add("(prefers-reduced-motion: no-preference)", () => {
344
- const route = root.current!.querySelector<SVGPathElement>("[data-route]")!; // element, not a string
345
- const tl = gsap.timeline({
346
- defaults: { ease: "none", duration: 1 },
347
- scrollTrigger: { trigger: root.current, start: "top 60%", end: "bottom 60%", scrub: 1 },
348
- });
349
- tl.set(route, { autoAlpha: 1 }, 0)
350
- .fromTo(route, { drawSVG: 0 }, { drawSVG: "100%" }, 0)
351
- .to("[data-follower]", {
352
- motionPath: {
353
- path: route,
354
- align: route, // follower sits on the path, in the path's coordinate space
355
- alignOrigin: [0.5, 0.5], // centre of the follower on the line
356
- autoRotate: true, // faces along the tangent; omit for a dot
357
- start: 0,
358
- end: 1,
359
- },
360
- }, 0);
361
- });
362
- }, { scope: root });
363
- ```
364
-
365
- - The drawn line and the follower share **one** timeline under one ScrollTrigger, both at position 0 with the same duration, so the tip of the line and the follower cannot drift apart.
366
- - `path` and `align` get the element queried from `root`, never a selector string: plugin strings resolve against the whole document (§7).
367
- - `align` needs the path's real geometry: the SVG must scale uniformly (no `preserveAspectRatio="none"`). The pixel `viewBox` from §3 qualifies; rebuild the timeline on resize the same way (`dependencies` plus `revertOnUpdate`).
368
- - `autoRotate` on something with a front (an arrow, a plane). A dot does not need it, and rotating a circle costs a matrix per frame for nothing.
369
- - Reduced motion: the follower sits at its end state (or is not rendered); the line is drawn statically.
370
-
371
- ## 10. Performance
372
-
373
- - **DrawSVG is cheap.** It is a dash offset; ten drawn paths on a page cost nothing measurable. The cost is paint area: a full-viewport path repaints its bounding box each frame, so keep strokes thin and avoid filters (`drop-shadow`, blur) on any animating SVG.
374
- - **MorphSVG runs on the main thread.** It rebuilds the `d` string every frame, proportional to point count. Simplify paths before morphing (hundreds of points, not thousands), and never morph more than a few shapes at once.
375
- - **Inline SVG only.** An `<img src="logo.svg">` cannot be drawn or morphed. Inline the few SVGs that animate; leave the rest as images.
376
- - **Move the `<svg>`, not the path,** when the whole graphic travels: transform the container (compositor) and keep path work for shape changes.
377
- - **One ScrollTrigger per graphic**, not per stroke: a timeline under one trigger, as in §4.
378
-
379
- ## Tells
380
-
381
- | Tell | Replace with |
382
- |---|---|
383
- | A squiggle or blob morphing on an infinite loop | Tied to scroll, hover or a state change |
384
- | Drawn line that finishes before the reader arrives (time-based at a threshold) | `scrub` with `ease: "none"` |
385
- | Outlined font text "drawing" its own outline | Single-stroke centreline, or a centreline mask over the fill |
386
- | A round-cap dot visible before the draw starts | Hidden until the draw begins |
387
- | A drawn path with `preserveAspectRatio="none"` and `non-scaling-stroke` that stops short | A pixel `viewBox` rebuilt on resize (§3) |
388
- | A circle drawing itself as a "loader" | The real mark, or a mask wipe |
389
- | Morph that folds through itself | `shapeIndex`, `type: "rotational"`, matched subpaths |
390
- | A different SVG flourish in every section | One line, one mark: SVG motion is a signature, not a texture |
@@ -1,160 +0,0 @@
1
- # Cinematic Transitions & Easing Philosophy
2
-
3
- Two halves: the easing reference (used by every other file) and the transition patterns (page curtains, section curtains, shared-element moves).
4
-
5
- ---
6
-
7
- ## Easing philosophy: why custom beziers feel "designed"
8
-
9
- Default `ease-in-out` (`cubic-bezier(0.4, 0, 0.2, 1)`) is symmetric and polite: it accelerates and decelerates by similar amounts. Nothing in the physical world moves like that on arrival. Things that *arrive* decelerate hard at the end (a hand placing a glass); things that *leave* accelerate away. Designed motion encodes intent in the curve:
10
-
11
- - **Entrances → strong out-eases.** Almost all the velocity happens in the first 20% of the duration, and the long deceleration tail is where the eye reads "settled, weighted, premium." This is why expo-out at 1s feels *faster* than ease-in-out at 0.5s: perceived speed is initial velocity, and perceived quality is the tail.
12
- - **Exits → in-eases.** The element commits and accelerates away. Slow-decelerating exits read as reluctance or lag.
13
- - **In-outs → only for things that move from A to B on screen** (curtains, position swaps, reordering) where both endpoints are visible.
14
- - **Never linear** except scrub-tied values, where scroll itself is the easing.
15
-
16
- ### The beziers that actually ship
17
-
18
- CSS-side (these go in `@theme`, see [vibe-presets.md](vibe-presets.md)) and their GSAP equivalents:
19
-
20
- | Name | cubic-bezier | GSAP | Use for | Character |
21
- |---|---|---|---|---|
22
- | **expo-out** | `(0.16, 1, 0.3, 1)` | `expo.out` | reveals, hero entrances, masks | the agency default; violent start, infinite-feeling settle |
23
- | **quart-out** | `(0.25, 1, 0.5, 1)` | `power3.out` | hovers, dropdowns, micro | expo's calmer sibling; snappy without drama |
24
- | **quint-in-out** | `(0.83, 0, 0.17, 1)` | `power4.inOut` | curtains, wipes, panel slides | holds, then *commits*; the cinematic door |
25
- | **quart-in-out** | `(0.76, 0, 0.24, 1)` | `power3.inOut` | position swaps, accordions | quint's softer sibling |
26
- | **back-out** | `(0.34, 1.56, 0.64, 1)` | `back.out(1.4)` | badges, toasts, playful pops | 5-6% overshoot; personality, so ration it |
27
- | **circ-out** | `(0, 0.55, 0.45, 1)` | `circ.out` | cursor effects, trailing UI | mechanical-smooth |
28
-
29
- Per-vibe personality = picking 2 to 3 of these plus a duration scale ([vibe-presets.md](vibe-presets.md)). A site that uses expo-out/quint-in-out at 0.8 to 1.4s feels heavy and cinematic; the same site on quart-out/back-out at 0.25 to 0.6s feels kinetic and product-y.
30
-
31
- For a true signature ease, GSAP `CustomEase.create("brand", "M0,0 C0.2,0 0.1,1 1,1")`. The table covers 99% of shipping needs.
32
-
33
- **Cheap tell:** `transition-all duration-300 ease-in-out` on everything, `duration-500` on big things. **Instead:** Asymmetric curves chosen per role, durations spanning 0.2s→1.4s by element weight.
34
-
35
- ---
36
-
37
- ## 1. Page transition curtain
38
-
39
- The route curtain is one system, and it lives in [route-transitions.md](route-transitions.md): the GSAP `PageTransitionProvider` and `TransitionLink` (cover, swap, reveal), with session-gated loaders in [loaders.md](loaders.md) and per-zone entrances in [page-entrances.md](page-entrances.md). Build every multi-page site from there. The ease is quint-in-out for the panel and expo-out for the content rising behind it, per the table above.
40
-
41
- **Cheap tell:** route changes hard-cut, or cross-fade via `opacity` on the whole page. **Instead:** A surface with a committed quint-in-out sweep, content rising in its wake.
42
-
43
- <sub>Footnote: a Motion-only `app/template.tsx` that plays a reveal-on-mount curtain (no cover phase, because `template.tsx` cannot animate the old page out) is a valid stopgap for a two-page site that will never grow a provider. It is not the canonical system; prefer [route-transitions.md](route-transitions.md) whenever a site has more than two routes or a loader.</sub>
44
-
45
- ---
46
-
47
- ## 2. Section curtain (in-page)
48
-
49
- **Term:** Surface handoff.
50
- **Mechanic:** A section's background sweeps up over the previous section as it enters, so the *surface itself* transitions rather than content scrolling past a boundary. Scrub-tied curtain = the page feels like layered sheets.
51
-
52
- ```tsx
53
- "use client";
54
-
55
- import { useRef } from "react";
56
- import { gsap, useGSAP } from "@/lib/gsap";
57
-
58
- export function CurtainSection({ children }: { children: React.ReactNode }) {
59
- const container = useRef<HTMLElement>(null);
60
-
61
- useGSAP(
62
- () => {
63
- gsap.fromTo(
64
- container.current,
65
- { clipPath: "inset(100% 0% 0% 0%)" },
66
- {
67
- clipPath: "inset(0% 0% 0% 0%)",
68
- ease: "none",
69
- scrollTrigger: {
70
- trigger: container.current,
71
- start: "top bottom",
72
- end: "top 25%",
73
- scrub: true, // tied to the hand: the user drags the curtain up
74
- },
75
- }
76
- );
77
- },
78
- { scope: container }
79
- );
80
-
81
- // -mt-[20vh] makes it sweep OVER the previous section's tail (see composition.md)
82
- return (
83
- <section ref={container} className="relative z-20 -mt-[20vh] bg-surface-2 px-6 py-32">
84
- {children}
85
- </section>
86
- );
87
- }
88
- ```
89
-
90
- Use at 1 to 2 major act-breaks per page, on a strong surface change (surface-0 → surface-2, or the accent). Every section curtaining = visual exhaustion.
91
-
92
- ---
93
-
94
- ## 3. Shared-element transition (`layoutId`)
95
-
96
- **Term:** Shared-element / match-cut transition.
97
- **Mechanic:** An element (project thumbnail) appears to *become* its counterpart in the next view (case-study hero). Motion interpolates position/scale between two elements sharing a `layoutId`. It is the strongest continuity illusion available in React, and pure Motion (GSAP has no equivalent; this is React-tree-aware layout animation).
98
-
99
- This is the one place Motion genuinely beats GSAP, and it **complements** the canonical page-flow system: use the cover wipe ([route-transitions.md](route-transitions.md)) for route changes, and `layoutId` for list→detail moves *within* a route.
100
-
101
- **When:** List→detail moves inside one route (modal/expanding card). Across App Router *pages*, `layoutId` does not survive the unmount/remount, so use the curtain instead, or keep the "detail" as an in-route overlay (parallel route/modal pattern) so both elements live in one tree.
102
-
103
- ```tsx
104
- "use client";
105
-
106
- import { useState } from "react";
107
- import { motion, AnimatePresence } from "motion/react";
108
-
109
- export function ProjectGrid({ projects }: { projects: { id: string; img: string; title: string }[] }) {
110
- const [active, setActive] = useState<string | null>(null);
111
- const QUINT_INOUT = [0.83, 0, 0.17, 1] as const;
112
-
113
- return (
114
- <>
115
- <div className="grid grid-cols-12 gap-4">
116
- {projects.map((p) => (
117
- <motion.button key={p.id} layoutId={`card-${p.id}`} onClick={() => setActive(p.id)}
118
- className="col-span-4 overflow-hidden">
119
- <motion.img layoutId={`img-${p.id}`} src={p.img} className="aspect-[4/3] w-full object-cover" />
120
- </motion.button>
121
- ))}
122
- </div>
123
-
124
- <AnimatePresence>
125
- {active && (
126
- <motion.div className="fixed inset-0 z-[90] bg-surface-0/90"
127
- initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
128
- onClick={() => setActive(null)}>
129
- <motion.div layoutId={`card-${active}`} transition={{ duration: 0.6, ease: QUINT_INOUT }}
130
- className="absolute inset-x-0 top-0 mx-auto max-w-5xl overflow-hidden">
131
- <motion.img layoutId={`img-${active}`} src={projects.find((p) => p.id === active)!.img}
132
- className="aspect-[16/9] w-full object-cover" />
133
- </motion.div>
134
- </motion.div>
135
- )}
136
- </AnimatePresence>
137
- </>
138
- );
139
- }
140
- ```
141
-
142
- Note the aspect-ratio change (4/3 → 16/9): Motion interpolates the crop too, which reads as a deliberate match-cut. Keep the moving element simple (an image, not a complex card), because layout animations of deep trees stutter.
143
-
144
- ---
145
-
146
- ## 4. Stagger-out (the forgotten half)
147
-
148
- Entrances get all the attention, but exits sell the system. When content leaves (accordion closing, filter change, modal dismiss), it should exit with the same system: `power2.in` (accelerating), ~60% of the entrance duration, reverse stagger (`stagger: { each: 0.04, from: "end" }`). Motion: `exit={{ y: -16, opacity: 0, transition: { duration: 0.35, ease: "easeIn" } }}` inside `AnimatePresence`. An interface whose exits are abrupt `display: none` while entrances are choreographed feels half-built. This asymmetry of care is a top-five LLM tell.
149
-
150
- ---
151
-
152
- ## Transition decision table
153
-
154
- | Situation | Tool | Pattern |
155
- |---|---|---|
156
- | Route change, narrative site | GSAP `PageTransitionProvider` | Route curtain ([route-transitions.md](route-transitions.md)) |
157
- | Act-break between sections | GSAP scrub | Section curtain (§2) |
158
- | List → detail, same route | Motion `layoutId` | Shared element (§3) |
159
- | Anything mounting/unmounting | Motion `AnimatePresence` | Enter + designed exit (§4) |
160
- | Scroll-driven anything | GSAP | other reference files |