@struq/skill 0.2.1 → 0.3.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.
- package/README.md +15 -5
- package/package.json +4 -4
- package/skills/struq/SKILL.md +304 -200
- package/skills/struq/cinematic/showpieces/README.md +18 -0
- package/skills/struq/cinematic/showpieces/toonstuk-fill-rows.tsx +74 -0
- package/skills/struq/cinematic/showpieces/toonstuk-hero-opener.tsx +128 -0
- package/skills/struq/cinematic/showpieces/toonstuk-image-to-fullbleed.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +81 -0
- package/skills/struq/cinematic/showpieces/toonstuk-panel-wipe.tsx +89 -0
- package/skills/struq/cinematic/showpieces/toonstuk-pixel-resolve.tsx +107 -0
- package/skills/struq/cinematic/showpieces/toonstuk-project-stack.tsx +86 -0
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +78 -0
- package/skills/struq/cinematic/showpieces/toonstuk-wordmark-finale.tsx +105 -0
- package/skills/struq/recipes/app/fonts.ts +22 -0
- package/skills/struq/recipes/app/globals.css +185 -0
- package/skills/struq/recipes/app/layout.tsx +28 -0
- package/skills/struq/recipes/components/struq/Cursor.tsx +117 -0
- package/skills/struq/recipes/components/struq/HeroCanvas.tsx +278 -0
- package/skills/struq/recipes/components/struq/HorizontalGallery.tsx +106 -0
- package/skills/struq/recipes/components/struq/MagneticButton.tsx +92 -0
- package/skills/struq/recipes/components/struq/MediaFrame.tsx +96 -0
- package/skills/struq/recipes/components/struq/PinnedZoom.tsx +79 -0
- package/skills/struq/recipes/components/struq/Reveal.tsx +64 -0
- package/skills/struq/recipes/components/struq/SmoothScroll.tsx +57 -0
- package/skills/struq/recipes/components/struq/SplitHeadline.tsx +68 -0
- package/skills/struq/recipes/components/struq/scenes/contours.ts +161 -0
- package/skills/struq/recipes/components/struq/scenes/dots.ts +110 -0
- package/skills/struq/recipes/components/struq/scenes/ripple.ts +99 -0
- package/skills/struq/recipes/components/struq/scenes/types.ts +114 -0
- package/skills/struq/recipes/lib/gsap.ts +21 -0
- package/skills/struq/references/anti-patterns.md +71 -6
- package/skills/struq/references/baseline-prompt.md +18 -14
- package/skills/struq/references/brief-router.md +7 -8
- package/skills/struq/references/composition.md +0 -1
- package/skills/struq/references/depth-parallax.md +1 -1
- package/skills/struq/references/direction.md +5 -5
- package/skills/struq/references/hero-scenes.md +107 -0
- package/skills/struq/references/iconography.md +1 -1
- package/skills/struq/references/imagery.md +10 -29
- package/skills/struq/references/micro-interactions.md +1 -1
- package/skills/struq/references/mobile-composition.md +1 -1
- package/skills/struq/references/niche-packets.md +338 -0
- package/skills/struq/references/page-architecture.md +2 -2
- package/skills/struq/references/pinning-scrub.md +3 -4
- package/skills/struq/references/scroll-reveals.md +1 -2
- package/skills/struq/references/sections.md +141 -0
- package/skills/struq/references/seo.md +2 -2
- package/skills/struq/references/showcase.md +2 -2
- package/skills/struq/references/svg-motion.md +390 -390
- package/skills/struq/references/typography.md +39 -39
- package/skills/struq/references/vibe-presets.md +5 -5
- package/skills/struq/references/webgl-scenes.md +593 -593
- package/skills/struq/scripts/canon.mjs +309 -0
- package/skills/struq/scripts/check.mjs +547 -0
- package/skills/struq/scripts/font-names.mjs +40 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +92 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/recipes.mjs +135 -0
- /package/{bin → cli}/struq.mjs +0 -0
|
@@ -1,390 +1,390 @@
|
|
|
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
|
+
# 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 |
|