diff --git a/apps/docs/content/1.component/svg-draw.mdx b/apps/docs/content/1.component/svg-draw.mdx index fa4346b..87e6bd2 100644 --- a/apps/docs/content/1.component/svg-draw.mdx +++ b/apps/docs/content/1.component/svg-draw.mdx @@ -6,11 +6,10 @@ icon: pen-tool Wrap **any inline SVG**, an icon, a logo or a whole illustration, and it **draws itself in**. Every path, line, circle, rect and polygon **traces its -outline** one after another, longest first, then its **fill fades in** behind -the line. Outline icons draw in their own stroke and keep it; filled shapes are -traced in a thin line that fades away as they fill. Once a shape settles it is -**handed back exactly as written**. It plays when the SVG scrolls into view, or -on mount. Plain CSS transitions, no animation library. +outline** one after another, then its **fill fades in** behind the line. +Outline icons draw in their own stroke and keep it; filled shapes are traced in +a thin line that fades away as they fill. It plays when the SVG scrolls into +view, or on mount. GSAP DrawSVG and ScrollTrigger. ::component-preview{name="svg-draw" size="lg"} :: @@ -66,43 +65,38 @@ pasted from a design tool, or a whole illustration. Tune the pace with ## Props -| Prop | Type | Default | Description | -| ------------ | -------------------------------------- | ---------------- | --------------------------------------------------------------------------- | -| `duration` | `number` | `1.6` | Seconds each shape takes to draw. | -| `delay` | `number` | `0` | Seconds before the first shape starts. | -| `stagger` | `number` | `0.18` | Seconds between one shape starting and the next. | -| `order` | `'longest' \| 'reverse' \| 'document'` | `'longest'` | Which shapes go first. | -| `ease` | `SvgDrawEase \| string` | `'power1.out'` | A preset or any CSS easing. | -| `width` | `number` | `1.5` | Trace width in screen px for fill-only shapes. | -| `color` | `string` | `'currentColor'` | Trace color for fill-only shapes. | -| `ownColor` | `boolean` | `false` | Trace fill-only shapes in their own fill color instead. | -| `trigger` | `'view' \| 'mount'` | `'view'` | Play on scrolling into view, or straight away. | -| `startAt` | `number` | `0.85` | With `view`: how far down the viewport the SVG's top must rise, `0` to `1`. | -| `onComplete` | `() => void` | — | Called once every shape is drawn and settled. | - -The presets are `'power1.out'`, `'power2.out'`, `'power3.out'`, `'expo.out'`, -`'in-out'` and `'linear'`. It renders a `
` around your SVG and extends -native `
` attributes (`className`, `style`, …). +| Prop | Type | Default | Description | +| ------------ | ------------------- | ---------------- | ------------------------------------------------ | +| `duration` | `number` | `1.4` | Seconds each shape takes to draw. | +| `delay` | `number` | `0` | Seconds before the first shape starts. | +| `stagger` | `number` | `0.12` | Seconds between one shape starting and the next. | +| `ease` | `string` | `'power2.inOut'` | Any GSAP ease. | +| `width` | `number` | `1.5` | Trace width in screen px for fill-only shapes. | +| `trigger` | `'view' \| 'mount'` | `'view'` | Play on scrolling into view, or straight away. | +| `start` | `string` | `'top 85%'` | ScrollTrigger start, with `trigger="view"`. | +| `onComplete` | `() => void` | — | Called once every shape is drawn and filled. | + +The trace line takes the wrapper's text color, so set it with a `text-*` +class. It renders a `
` around your SVG and extends native `
` +attributes (`className`, `style`, …). ## Behavior - **Any shape, any SVG.** It finds every `path`, `line`, `polyline`, `polygon`, `circle`, `ellipse` and `rect`, however deeply nested in groups or across several SVGs. Shapes inside `defs`, `clipPath`, `mask`, - `pattern`, `marker`, `symbol` and gradients are skipped, because they are - never painted where they sit, and so are hidden shapes. + `pattern`, `marker` and `symbol` are skipped, because they are never painted + where they sit. - **Strokes stay strokes, fills stay fills.** A stroked shape draws in its own stroke color and width and keeps them. A fill-only shape draws in a thin - `color` line that fades out as its fill fades in. Colors are never swapped: - every fill ends in its own color, gradients included. -- **The same line on any scale.** `width` is in screen pixels, so the trace - looks the same on a 24-unit icon and on a 1000-unit logo. -- **Shapes that set `pathLength`** are measured in their own units, so the - line still runs exactly from start to end. -- **Handed back untouched.** Once a shape has settled, its own `style` - attribute is put back exactly as it was, so nothing lingers to fight your - CSS. Unmounting mid-draw, a prop change or React Strict Mode's double effect - restores the SVG too, so it is always measured as written. + line in the text color that fades out as its fill fades in. Every fill ends + in its own color and opacity, gradients included. +- **The same line on any scale.** The trace uses a non-scaling stroke, so + `width` looks the same on a 24-unit icon and on a 1000-unit logo. +- **Measured by DrawSVG.** Path lengths, `pathLength` attributes and line + caps are all handled by GSAP's DrawSVG plugin. +- **Cleans up after itself.** The timeline runs inside `useGSAP`, so + unmounting or changing a prop reverts every shape to how it was written. - **No flash.** Where scripts run, the SVG is hidden until its first frame is set up, before paint. Without JavaScript it simply shows. - **Only inline SVG.** An `` or a CSS background cannot be diff --git a/apps/docs/content/2.block/arc-carousel.mdx b/apps/docs/content/2.block/arc-carousel.mdx index f549b47..008c956 100644 --- a/apps/docs/content/2.block/arc-carousel.mdx +++ b/apps/docs/content/2.block/arc-carousel.mdx @@ -9,10 +9,9 @@ downward arc**, each one further from the centre sitting a little lower and **turned a little more**, three to a side. **Step** and the whole arc swings round: every card glides to its neighbour's slot on a slow, weighted curve, while the one arriving in the middle **eases up in size** and takes a **lime -outline**. A card that wraps past the edge **keeps travelling off-screen** -while it comes back in **from the far side**, so the loop never snaps. -**Swipe**, **drag**, use the **arrow keys**, or **click a side card** to bring -it to the centre. Plain CSS transitions, no animation library. +outline**. Cards fade out past the third slot and wrap round unseen, so the +loop never snaps. **Swipe**, **drag**, use the **arrow keys**, or **click a +side card** to bring it to the centre. GSAP with ScrollTrigger. ::component-preview{name="arc-carousel" size="xl"} :: @@ -50,8 +49,8 @@ export default function Page() { ::: :: -It ships with eight sample projects. Pass your own as `items`, and point the -button wherever the full list lives: +It ships with eight sample projects. Pass your own as `items`, and put your +own heading above it: ```tsx [app/page.tsx] import ArcCarousel from '@/components/tweenui/arc-carousel'; @@ -59,10 +58,7 @@ import ArcCarousel from '@/components/tweenui/arc-carousel'; export default function Page() { return (
\n );\n })}\n \n
\n
\n
\n\n
\n step(-1)}\n >\n \n \n {cta && (\n \n {cta.label}\n \n \n )}\n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n \n \n \n \n )}\n
\n\n {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n

\n {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n

\n\n
\n
\n \n );\n};\n\nexport default ArcCarousel;\n" + "content": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ArcCarouselItem {\n title: string;\n image: string;\n /** Chip on the top left of the card. */\n tag?: string;\n /** Line under the title, e.g. a client and year or a price. */\n meta?: string;\n href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n /** Eight or more keep the wrap seamless. */\n items?: ArcCarouselItem[];\n /** Accessible name for the carousel. */\n label?: string;\n /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n autoplay?: boolean;\n /** Time each card holds the centre while autoplaying, in ms. */\n interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n { x: 0, y: 0, r: 0 },\n { x: 359, y: 26, r: 8.77 },\n { x: 711, y: 113, r: 18.83 },\n { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst MOVE_DURATION = 0.8;\nconst MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1');\nconst SWIPE = 50;\n\n/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */\nconst slot = (offset: number) => {\n const t = Math.abs(offset);\n const sign = Math.sign(offset);\n const i = Math.min(Math.floor(t), SLOTS.length - 2);\n const a = SLOTS[i];\n const b = SLOTS[i + 1];\n const f = t - i;\n return {\n x: (a.x + (b.x - a.x) * f) * sign,\n y: a.y + (b.y - a.y) * f,\n rotation: (a.r + (b.r - a.r) * f) * sign,\n scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t),\n opacity: gsap.utils.clamp(0, 1, SLOTS.length - t),\n zIndex: 100 - Math.round(t * 10),\n };\n};\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n src.includes('images.unsplash.com')\n ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n {\n title: 'Nightfall',\n tag: 'Astronomy app',\n meta: 'Stellar Co. · 2026',\n image: photo('1519681393784-d120267933ba'),\n },\n {\n title: 'Chroma Identity',\n tag: 'Brand identity',\n meta: 'Chroma Studio · 2026',\n image: photo('1541701494587-cb58502866ab'),\n },\n {\n title: 'Alpine Lodge',\n tag: 'Hospitality',\n meta: 'Moraine Hotels · 2026',\n image: photo('1493246507139-91e8fad9978e'),\n },\n {\n title: 'Metro Pulse',\n tag: 'Data visualisation',\n meta: 'City of Lakes · 2025',\n image: photo('1477959858617-67f85cf4f1df'),\n },\n {\n title: 'Orbit Motion',\n tag: '3D & motion',\n meta: 'Orbit Labs · 2025',\n image: photo('1618005198919-d3d4b5a92ead'),\n },\n {\n title: 'Open Road',\n tag: 'Campaign film',\n meta: 'Westward · 2025',\n image: photo('1500530855697-b586d89ba3ee'),\n },\n {\n title: 'Still Water',\n tag: 'Wellness app',\n meta: 'Calm Harbor · 2024',\n image: photo('1534447677768-be436bb09401'),\n },\n {\n title: 'Lago Retreats',\n tag: 'Travel platform',\n meta: 'Lago Travel · 2024',\n image: photo('1501785888041-af3ef285b470'),\n },\n];\n\nconst STEP = cn(\n 'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white',\n 'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none'\n);\nconst FADE =\n 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n \n \n \n);\n\nconst PauseIcon = () => (\n \n \n \n \n);\n\nconst PlayIcon = () => (\n \n \n \n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n \n \n \n);\n\nconst ArcCarousel = ({\n items = DEFAULT_ITEMS,\n label = 'Featured projects',\n autoplay = true,\n interval = 5000,\n className,\n ...props\n}: ArcCarouselProps) => {\n const count = items.length;\n const [position, setPosition] = useState(0);\n const [playing, setPlaying] = useState(autoplay);\n const [held, setHeld] = useState(false);\n const rootRef = useRef(null);\n const ringRef = useRef(null);\n const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n const track = useRef({ pos: 0 });\n const clockRef = useRef(null);\n const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n const holds = useRef({ hover: false, focus: false, offscreen: false });\n\n const current = count ? gsap.utils.wrap(0, count, position) : 0;\n const active = items[current];\n const offsetOf = (index: number, pos: number) =>\n gsap.utils.wrap(-count / 2, count / 2, index - pos);\n\n const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n // `playing` is the viewer's choice; a hold is a pause they did not ask for\n const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n holds.current[key] = on;\n setHeld(Object.values(holds.current).some(Boolean));\n }, []);\n\n // Every card is placed from one number, so a step is a single tween and the wrap needs no copies\n useGSAP(\n () => {\n const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null);\n const render = () =>\n cards.forEach((card, i) => {\n const s = slot(offsetOf(i, track.current.pos));\n gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' });\n });\n\n gsap.to(track.current, {\n pos: position,\n duration: prefersReducedMotion() ? 0 : MOVE_DURATION,\n ease: MOVE_EASE,\n overwrite: true,\n onUpdate: render,\n });\n },\n { scope: rootRef, dependencies: [position, count] }\n );\n\n // The media query is client-only, so reduced motion applies after hydration\n useEffect(() => {\n setPlaying(autoplay && !prefersReducedMotion());\n }, [autoplay]);\n\n // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it\n useGSAP(\n () => {\n const ring = ringRef.current;\n if (!ring || !playing || count < 2) return;\n clockRef.current = gsap.fromTo(\n ring,\n // The attribute, not the CSS property, so the dash stays in `pathLength` units\n { attr: { 'stroke-dashoffset': 1 } },\n {\n attr: { 'stroke-dashoffset': 0 },\n duration: interval / 1000,\n ease: 'none',\n onComplete: () => step(1),\n }\n );\n },\n { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true }\n );\n\n useEffect(() => {\n if (held) clockRef.current?.pause();\n else clockRef.current?.resume();\n }, [held, position, playing]);\n\n useGSAP(\n () => {\n if (!autoplay) return;\n ScrollTrigger.create({\n trigger: rootRef.current,\n onToggle: (self) => hold('offscreen', !self.isActive),\n });\n },\n { scope: rootRef, dependencies: [autoplay, hold] }\n );\n\n // A swipe can end outside the list\n useEffect(() => {\n const onUp = (event: PointerEvent) => {\n const s = swipe.current;\n if (!s.tracking) return;\n s.tracking = false;\n const dx = event.clientX - s.x;\n if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) {\n s.swiped = true;\n step(dx < 0 ? 1 : -1);\n }\n };\n const onCancel = () => {\n swipe.current.tracking = false;\n };\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n return () => {\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n };\n }, [step]);\n\n // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n const onClickCapture = (event: MouseEvent) => {\n if (swipe.current.swiped) {\n swipe.current.swiped = false;\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n if (event.detail === 0) return;\n const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node));\n const offset = index < 0 ? 0 : Math.round(offsetOf(index, position));\n if (offset !== 0) {\n event.preventDefault();\n event.stopPropagation();\n step(Math.sign(offset));\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n event.preventDefault();\n step(event.key === 'ArrowRight' ? 1 : -1);\n };\n\n return (\n \n \n\n {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n event.pointerType !== 'touch' && hold('hover', true)}\n onPointerLeave={() => hold('hover', false)}\n >\n
\n {\n swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n }}\n onDragStart={(event) => event.preventDefault()}\n onClickCapture={onClickCapture}\n onKeyDown={onKeyDown}\n onFocus={(event) => {\n let visible = true;\n try {\n visible = event.target.matches(':focus-visible');\n } catch {\n // No :focus-visible support: treat every focus as visible\n }\n if (visible) hold('focus', true);\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n hold('focus', false);\n }\n }}\n >\n {items.map((item, i) => {\n const isActive = i === current;\n const Card = item.href ? 'a' : 'div';\n // Also set in markup, so the arc is in place before hydration\n const first = slot(offsetOf(i, 0));\n return (\n {\n cardRefs.current[i] = el;\n }}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}: ${item.title}`}\n aria-hidden={isActive ? undefined : true}\n data-active={isActive ? '' : undefined}\n className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n style={{\n transform: `translate(${first.x}px, ${first.y}px) rotate(${first.rotation}deg) scale(${first.scale})`,\n opacity: first.opacity,\n zIndex: first.zIndex,\n }}\n >\n \n \n \n \n\n \n {item.tag ? (\n \n {item.tag}\n \n ) : (\n \n )}\n \n {pad2(i + 1)}\n / {pad2(count)}\n \n \n\n \n \n \n {item.title}\n \n {item.meta && (\n {item.meta}\n )}\n \n {item.href && (\n \n \n \n )}\n \n\n \n \n
\n );\n })}\n
\n
\n \n\n
\n step(-1)}\n >\n \n \n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n \n \n \n \n )}\n
\n\n {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n

\n {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n

\n\n
\n
\n \n );\n};\n\nexport default ArcCarousel;\n" } ], - "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/arc-carousel\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping.\nColors come from the Tween UI theme tokens (`tween-ink-deep`, `tween-lime`, …), which the CLI adds to your `@theme`. Pass your own `items`; seven or more keep the arc full, since three cards show on each side of the centre." + "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/arc-carousel\nGSAP plugins (CustomEase, ScrollTrigger) are registered inside the component, so there is nothing to set up.\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping.\nPass your own `items`; eight or more keep the arc full and the wrap unseen. Colors are inline Tailwind classes, so there is nothing to add to your theme." } \ No newline at end of file diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index fd4f07d..e494ddf 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -563,7 +563,7 @@ "name": "svg-draw", "type": "registry:ui", "title": "SVG Draw", - "description": "Wrap any inline SVG and it draws itself in: every path, line, circle, rect and polygon traces its outline, one after another, then its fill fades in. Outline icons draw in their own stroke, filled logos are traced and then filled, and the SVG is handed back exactly as written once it settles. Plays when it scrolls into view or on mount; plain CSS transitions, no animation library.", + "description": "Wrap any inline SVG and it draws itself in: every path, line, circle, rect and polygon traces its outline, one after another, then its fill fades in. Outline icons draw in their own stroke, filled logos are traced in a thin line that fades as they fill. Plays when it scrolls into view or on mount. GSAP DrawSVG and ScrollTrigger.", "files": [ { "path": "components/tweenui/svg-draw.tsx", @@ -1527,7 +1527,7 @@ "name": "arc-carousel", "type": "registry:block", "title": "Arc Carousel", - "description": "A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline, and a card that wraps past the edge keeps travelling off-screen while it comes back in from the far side. Swipe, drag, the arrow keys or a click on a side card all step it.", + "description": "A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline; cards fade out past the third slot and wrap round unseen. Swipe, drag, the arrow keys or a click on a side card all step it, and it autoplays with a pause button. GSAP with ScrollTrigger.", "files": [ { "path": "components/tweenui/arc-carousel.tsx", diff --git a/apps/docs/public/r/svg-draw.json b/apps/docs/public/r/svg-draw.json index e2e26f2..5ab7b3f 100644 --- a/apps/docs/public/r/svg-draw.json +++ b/apps/docs/public/r/svg-draw.json @@ -3,8 +3,11 @@ "name": "svg-draw", "type": "registry:ui", "title": "SVG Draw", - "description": "Wrap any inline SVG and it draws itself in: every path, line, circle, rect and polygon traces its outline, one after another, then its fill fades in. Outline icons draw in their own stroke, filled logos are traced and then filled, and the SVG is handed back exactly as written once it settles. Plays when it scrolls into view or on mount; plain CSS transitions, no animation library.", - "dependencies": [], + "description": "Wrap any inline SVG and it draws itself in: every path, line, circle, rect and polygon traces its outline, one after another, then its fill fades in. Outline icons draw in their own stroke, filled logos are traced in a thin line that fades as they fill. Plays when it scrolls into view or on mount. GSAP DrawSVG and ScrollTrigger.", + "dependencies": [ + "gsap", + "@gsap/react" + ], "registryDependencies": [], "cssVars": { "theme": {} @@ -14,8 +17,8 @@ "path": "components/tweenui/svg-draw.tsx", "type": "registry:component", "target": "components/tweenui/svg-draw.tsx", - "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, type ComponentPropsWithoutRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport const SVG_DRAW_EASES = {\n 'power1.out': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n 'power2.out': 'cubic-bezier(0.22, 0.61, 0.36, 1)',\n 'power3.out': 'cubic-bezier(0.16, 1, 0.3, 1)',\n 'expo.out': 'cubic-bezier(0.19, 1, 0.22, 1)',\n 'in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',\n linear: 'linear',\n} as const;\n\nexport type SvgDrawEase = keyof typeof SVG_DRAW_EASES;\n\nconst FADE_MS = 400;\nconst SHAPES = new Set(['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect']);\n// Shapes inside these are never painted where they sit, so there is nothing to draw\nconst SKIP = new Set([\n 'defs',\n 'clippath',\n 'mask',\n 'pattern',\n 'marker',\n 'symbol',\n 'lineargradient',\n 'radialgradient',\n 'filter',\n 'foreignobject',\n]);\n\nconst shapesIn = (node: Element): SVGGeometryElement[] =>\n Array.from(node.children).flatMap((child) => {\n const tag = child.tagName.toLowerCase();\n if (SKIP.has(tag)) return [];\n return SHAPES.has(tag) ? [child as SVGGeometryElement] : shapesIn(child);\n });\n\nconst isClear = (paint: string) =>\n paint === 'none' || paint === 'transparent' || paint === 'rgba(0, 0, 0, 0)';\n\ninterface Shape {\n el: SVGGeometryElement;\n len: number;\n pad: number;\n stroked: boolean;\n trace: string;\n traceWidth: string;\n fillOpacity: string;\n inline: string | null;\n}\n\nconst measure = (el: SVGGeometryElement, width: number, color: string, ownColor: boolean) => {\n const style = getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden') return null;\n\n let real = 0;\n try {\n real = el.getTotalLength();\n } catch {\n return null;\n }\n // A `pathLength` attribute changes the units the dash is measured in\n const own = parseFloat(el.getAttribute('pathLength') ?? '');\n const len = own > 0 ? own : real;\n if (!(real > 0)) return null;\n\n // Unset paint computes to the SVG defaults: a black fill and no stroke\n const fill = style.fill || 'rgb(0, 0, 0)';\n const stroke = style.stroke || 'none';\n const strokeWidth = parseFloat(style.strokeWidth || '1');\n const stroked = !isClear(stroke) && strokeWidth > 0;\n if (!stroked && isClear(fill)) return null;\n\n // `width` is in screen px, the stroke in the shape's own units\n const ctm = el.getScreenCTM?.();\n const scale = ctm ? Math.sqrt(Math.abs(ctm.a * ctm.d - ctm.b * ctm.c)) || 1 : 1;\n const traceWidth = stroked ? strokeWidth : width / scale;\n\n return {\n el,\n len,\n // Run the dash past the end, so a line cap never shows as a dot before it starts\n pad: (traceWidth + 1) * (len / real),\n stroked,\n trace: stroked ? stroke : ownColor ? fill : color,\n traceWidth: String(traceWidth),\n fillOpacity: style.fillOpacity || '1',\n inline: el.getAttribute('style'),\n } satisfies Shape;\n};\n\nconst restore = ({ el, inline }: Shape) => {\n if (inline === null) el.removeAttribute('style');\n else el.setAttribute('style', inline);\n};\n\nexport interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> {\n /** Seconds each shape takes to draw. */\n duration?: number;\n /** Seconds before the first shape starts. */\n delay?: number;\n /** Seconds between one shape starting and the next. */\n stagger?: number;\n /** Trace width in screen px for fill-only shapes. Stroked shapes keep their own. */\n width?: number;\n ease?: SvgDrawEase | (string & {});\n order?: 'longest' | 'reverse' | 'document';\n /** Trace color for fill-only shapes. Stroked shapes keep their own. */\n color?: string;\n /** Trace fill-only shapes in their own fill color. */\n ownColor?: boolean;\n trigger?: 'view' | 'mount';\n /** With `trigger=\"view\"`, how far down the viewport the SVG's top must rise, 0 to 1. */\n startAt?: number;\n onComplete?: () => void;\n}\n\nconst SvgDraw = ({\n duration = 1.6,\n delay = 0,\n stagger = 0.18,\n width = 1.5,\n ease = 'power1.out',\n order = 'longest',\n color = 'currentColor',\n ownColor = false,\n trigger = 'view',\n startAt = 0.85,\n onComplete,\n className,\n children,\n ...props\n}: SvgDrawProps) => {\n const rootRef = useRef(null);\n const onCompleteRef = useRef(onComplete);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n }, [onComplete]);\n\n // Before paint, so the finished drawing never flashes up first\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n if (!root) return;\n const reveal = () => {\n root.style.visibility = 'visible';\n };\n if (prefersReducedMotion() || root.getClientRects().length === 0) return reveal();\n\n const shapes = shapesIn(root)\n .map((el) => measure(el, width, color, ownColor))\n .filter((shape): shape is Shape => shape !== null);\n if (!shapes.length) return reveal();\n if (order === 'longest') shapes.sort((a, b) => b.len - a.len);\n else if (order === 'reverse') shapes.reverse();\n\n shapes.forEach(({ el, len, pad, stroked, trace, traceWidth }) => {\n Object.assign(el.style, {\n transition: 'none',\n fillOpacity: '0',\n stroke: trace,\n strokeWidth: traceWidth,\n strokeDasharray: `${len} ${len + pad * 2}`,\n strokeDashoffset: String(len + pad),\n ...(stroked ? {} : { strokeLinecap: 'round', strokeLinejoin: 'round' }),\n });\n });\n reveal();\n\n const ms = Math.max(0.01, duration) * 1000;\n const start = Math.max(0, delay) * 1000;\n const gap = Math.max(0, stagger) * 1000;\n const curve = SVG_DRAW_EASES[ease as SvgDrawEase] ?? ease;\n const timers: number[] = [];\n\n const play = () => {\n void root.getBoundingClientRect();\n shapes.forEach((shape, i) => {\n const wait = start + i * gap;\n shape.el.style.transition = `stroke-dashoffset ${ms}ms ${curve} ${wait}ms`;\n shape.el.style.strokeDashoffset = '0';\n\n timers.push(\n window.setTimeout(() => {\n shape.el.style.transition = `fill-opacity ${FADE_MS}ms ease, stroke-opacity ${FADE_MS}ms ease`;\n shape.el.style.fillOpacity = shape.fillOpacity;\n if (!shape.stroked) shape.el.style.strokeOpacity = '0';\n }, wait + ms),\n window.setTimeout(() => restore(shape), wait + ms + FADE_MS)\n );\n });\n const end = start + (shapes.length - 1) * gap + ms + FADE_MS;\n timers.push(window.setTimeout(() => onCompleteRef.current?.(), end));\n };\n\n let observer: IntersectionObserver | undefined;\n if (trigger === 'view' && typeof IntersectionObserver !== 'undefined') {\n const margin = Math.round((1 - Math.min(1, Math.max(0, startAt))) * 100);\n observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry.isIntersecting) return;\n observer?.disconnect();\n play();\n },\n { rootMargin: `0px 0px -${margin}% 0px` }\n );\n observer.observe(root);\n } else {\n play();\n }\n\n // Hand the SVG back as written, so a re-run measures the real thing\n return () => {\n observer?.disconnect();\n timers.forEach((id) => window.clearTimeout(id));\n shapes.forEach(restore);\n };\n }, [duration, delay, stagger, width, ease, order, color, ownColor, trigger, startAt]);\n\n return (\n \n {children}\n
\n );\n};\n\nexport default SvgDraw;\n" + "content": "'use client';\n\nimport { useEffect, useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, DrawSVGPlugin, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect';\n// Shapes inside these are never painted where they sit\nconst UNPAINTED = 'defs, clipPath, mask, pattern, marker, symbol';\nconst NO_PAINT = new Set(['none', 'transparent', 'rgba(0, 0, 0, 0)']);\nconst FILL_FADE = 0.5;\n\nexport interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> {\n /** Seconds each shape takes to draw. */\n duration?: number;\n delay?: number;\n /** Seconds between one shape starting and the next. */\n stagger?: number;\n /** Any GSAP ease. */\n ease?: string;\n /** Width in screen px of the line that traces a fill-only shape. */\n width?: number;\n trigger?: 'view' | 'mount';\n /** ScrollTrigger start, with `trigger=\"view\"`. */\n start?: string;\n onComplete?: () => void;\n}\n\nconst SvgDraw = ({\n duration = 1.4,\n delay = 0,\n stagger = 0.12,\n ease = 'power2.inOut',\n width = 1.5,\n trigger = 'view',\n start = 'top 85%',\n onComplete,\n className,\n children,\n ...props\n}: SvgDrawProps) => {\n const rootRef = useRef(null);\n const onCompleteRef = useRef(onComplete);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n }, [onComplete]);\n\n useGSAP(\n () => {\n const root = rootRef.current;\n if (!root) return;\n gsap.set(root, { visibility: 'visible' });\n if (prefersReducedMotion()) return;\n\n const shapes = gsap.utils\n .toArray(root.querySelectorAll(SHAPES))\n .filter((shape) => !shape.closest(UNPAINTED));\n if (!shapes.length) return;\n\n const painted = (shape: Element, paint: 'fill' | 'stroke') =>\n !NO_PAINT.has(getComputedStyle(shape)[paint]);\n const filled = shapes.filter((shape) => painted(shape, 'fill'));\n // Fill-only shapes get a thin line to draw with, which fades once they fill\n const traced = filled.filter((shape) => !painted(shape, 'stroke'));\n gsap.set(traced, {\n stroke: 'currentColor',\n strokeWidth: width,\n vectorEffect: 'non-scaling-stroke',\n });\n\n gsap\n .timeline({\n delay,\n scrollTrigger: trigger === 'view' ? { trigger: root, start, once: true } : undefined,\n onComplete: () => onCompleteRef.current?.(),\n })\n .from(shapes, { drawSVG: 0, duration, ease, stagger })\n .from(filled, { fillOpacity: 0, duration: FILL_FADE, stagger }, duration * 0.6)\n .to(traced, { strokeOpacity: 0, duration: FILL_FADE, stagger }, '<');\n },\n {\n scope: rootRef,\n dependencies: [duration, delay, stagger, ease, width, trigger, start],\n revertOnUpdate: true,\n }\n );\n\n return (\n \n {children}\n
\n );\n};\n\nexport default SvgDraw;\n" } ], - "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/svg-draw\nWrap inline SVG markup, not an : only shapes in the page DOM can be traced. Give it a new key to play it again." + "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/svg-draw\nGSAP plugins (DrawSVGPlugin, ScrollTrigger) are registered inside the component, so there is nothing to set up.\nWrap inline SVG markup, not an : only shapes in the page DOM can be traced. Give it a new key to play it again." } \ No newline at end of file diff --git a/apps/docs/registry/__sources__.generated.ts b/apps/docs/registry/__sources__.generated.ts index b36efdd..e0a2a81 100644 --- a/apps/docs/registry/__sources__.generated.ts +++ b/apps/docs/registry/__sources__.generated.ts @@ -4,12 +4,7 @@ export interface Snippet { codeHtml: string; } -export const cssTokens: Record = { - "arc-carousel": { - "code": "@theme {\n --color-tween-teal: #045f64;\n --color-tween-teal-soft: #9fd4d6;\n --color-tween-lime: #c6f56f;\n --color-tween-ink: #071d1b;\n --color-tween-ink-deep: #03110f;\n --ease-tween: cubic-bezier(0.32, 0.72, 0, 1);\n}", - "codeHtml": "
@theme {\n  --color-tween-teal: #045f64;\n  --color-tween-teal-soft: #9fd4d6;\n  --color-tween-lime: #c6f56f;\n  --color-tween-ink: #071d1b;\n  --color-tween-ink-deep: #03110f;\n  --ease-tween: cubic-bezier(0.32, 0.72, 0, 1);\n}
" - } -}; +export const cssTokens: Record = {}; export const usage: Record = { "icon-trail-button": { @@ -503,8 +498,8 @@ export const manual: Record = { }, "svg-draw": { "react": { - "code": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, type ComponentPropsWithoutRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport const SVG_DRAW_EASES = {\n 'power1.out': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n 'power2.out': 'cubic-bezier(0.22, 0.61, 0.36, 1)',\n 'power3.out': 'cubic-bezier(0.16, 1, 0.3, 1)',\n 'expo.out': 'cubic-bezier(0.19, 1, 0.22, 1)',\n 'in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',\n linear: 'linear',\n} as const;\n\nexport type SvgDrawEase = keyof typeof SVG_DRAW_EASES;\n\nconst FADE_MS = 400;\nconst SHAPES = new Set(['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect']);\n// Shapes inside these are never painted where they sit, so there is nothing to draw\nconst SKIP = new Set([\n 'defs',\n 'clippath',\n 'mask',\n 'pattern',\n 'marker',\n 'symbol',\n 'lineargradient',\n 'radialgradient',\n 'filter',\n 'foreignobject',\n]);\n\nconst shapesIn = (node: Element): SVGGeometryElement[] =>\n Array.from(node.children).flatMap((child) => {\n const tag = child.tagName.toLowerCase();\n if (SKIP.has(tag)) return [];\n return SHAPES.has(tag) ? [child as SVGGeometryElement] : shapesIn(child);\n });\n\nconst isClear = (paint: string) =>\n paint === 'none' || paint === 'transparent' || paint === 'rgba(0, 0, 0, 0)';\n\ninterface Shape {\n el: SVGGeometryElement;\n len: number;\n pad: number;\n stroked: boolean;\n trace: string;\n traceWidth: string;\n fillOpacity: string;\n inline: string | null;\n}\n\nconst measure = (el: SVGGeometryElement, width: number, color: string, ownColor: boolean) => {\n const style = getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden') return null;\n\n let real = 0;\n try {\n real = el.getTotalLength();\n } catch {\n return null;\n }\n // A `pathLength` attribute changes the units the dash is measured in\n const own = parseFloat(el.getAttribute('pathLength') ?? '');\n const len = own > 0 ? own : real;\n if (!(real > 0)) return null;\n\n // Unset paint computes to the SVG defaults: a black fill and no stroke\n const fill = style.fill || 'rgb(0, 0, 0)';\n const stroke = style.stroke || 'none';\n const strokeWidth = parseFloat(style.strokeWidth || '1');\n const stroked = !isClear(stroke) && strokeWidth > 0;\n if (!stroked && isClear(fill)) return null;\n\n // `width` is in screen px, the stroke in the shape's own units\n const ctm = el.getScreenCTM?.();\n const scale = ctm ? Math.sqrt(Math.abs(ctm.a * ctm.d - ctm.b * ctm.c)) || 1 : 1;\n const traceWidth = stroked ? strokeWidth : width / scale;\n\n return {\n el,\n len,\n // Run the dash past the end, so a line cap never shows as a dot before it starts\n pad: (traceWidth + 1) * (len / real),\n stroked,\n trace: stroked ? stroke : ownColor ? fill : color,\n traceWidth: String(traceWidth),\n fillOpacity: style.fillOpacity || '1',\n inline: el.getAttribute('style'),\n } satisfies Shape;\n};\n\nconst restore = ({ el, inline }: Shape) => {\n if (inline === null) el.removeAttribute('style');\n else el.setAttribute('style', inline);\n};\n\nexport interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> {\n /** Seconds each shape takes to draw. */\n duration?: number;\n /** Seconds before the first shape starts. */\n delay?: number;\n /** Seconds between one shape starting and the next. */\n stagger?: number;\n /** Trace width in screen px for fill-only shapes. Stroked shapes keep their own. */\n width?: number;\n ease?: SvgDrawEase | (string & {});\n order?: 'longest' | 'reverse' | 'document';\n /** Trace color for fill-only shapes. Stroked shapes keep their own. */\n color?: string;\n /** Trace fill-only shapes in their own fill color. */\n ownColor?: boolean;\n trigger?: 'view' | 'mount';\n /** With `trigger=\"view\"`, how far down the viewport the SVG's top must rise, 0 to 1. */\n startAt?: number;\n onComplete?: () => void;\n}\n\nconst SvgDraw = ({\n duration = 1.6,\n delay = 0,\n stagger = 0.18,\n width = 1.5,\n ease = 'power1.out',\n order = 'longest',\n color = 'currentColor',\n ownColor = false,\n trigger = 'view',\n startAt = 0.85,\n onComplete,\n className,\n children,\n ...props\n}: SvgDrawProps) => {\n const rootRef = useRef(null);\n const onCompleteRef = useRef(onComplete);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n }, [onComplete]);\n\n // Before paint, so the finished drawing never flashes up first\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n if (!root) return;\n const reveal = () => {\n root.style.visibility = 'visible';\n };\n if (prefersReducedMotion() || root.getClientRects().length === 0) return reveal();\n\n const shapes = shapesIn(root)\n .map((el) => measure(el, width, color, ownColor))\n .filter((shape): shape is Shape => shape !== null);\n if (!shapes.length) return reveal();\n if (order === 'longest') shapes.sort((a, b) => b.len - a.len);\n else if (order === 'reverse') shapes.reverse();\n\n shapes.forEach(({ el, len, pad, stroked, trace, traceWidth }) => {\n Object.assign(el.style, {\n transition: 'none',\n fillOpacity: '0',\n stroke: trace,\n strokeWidth: traceWidth,\n strokeDasharray: `${len} ${len + pad * 2}`,\n strokeDashoffset: String(len + pad),\n ...(stroked ? {} : { strokeLinecap: 'round', strokeLinejoin: 'round' }),\n });\n });\n reveal();\n\n const ms = Math.max(0.01, duration) * 1000;\n const start = Math.max(0, delay) * 1000;\n const gap = Math.max(0, stagger) * 1000;\n const curve = SVG_DRAW_EASES[ease as SvgDrawEase] ?? ease;\n const timers: number[] = [];\n\n const play = () => {\n void root.getBoundingClientRect();\n shapes.forEach((shape, i) => {\n const wait = start + i * gap;\n shape.el.style.transition = `stroke-dashoffset ${ms}ms ${curve} ${wait}ms`;\n shape.el.style.strokeDashoffset = '0';\n\n timers.push(\n window.setTimeout(() => {\n shape.el.style.transition = `fill-opacity ${FADE_MS}ms ease, stroke-opacity ${FADE_MS}ms ease`;\n shape.el.style.fillOpacity = shape.fillOpacity;\n if (!shape.stroked) shape.el.style.strokeOpacity = '0';\n }, wait + ms),\n window.setTimeout(() => restore(shape), wait + ms + FADE_MS)\n );\n });\n const end = start + (shapes.length - 1) * gap + ms + FADE_MS;\n timers.push(window.setTimeout(() => onCompleteRef.current?.(), end));\n };\n\n let observer: IntersectionObserver | undefined;\n if (trigger === 'view' && typeof IntersectionObserver !== 'undefined') {\n const margin = Math.round((1 - Math.min(1, Math.max(0, startAt))) * 100);\n observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry.isIntersecting) return;\n observer?.disconnect();\n play();\n },\n { rootMargin: `0px 0px -${margin}% 0px` }\n );\n observer.observe(root);\n } else {\n play();\n }\n\n // Hand the SVG back as written, so a re-run measures the real thing\n return () => {\n observer?.disconnect();\n timers.forEach((id) => window.clearTimeout(id));\n shapes.forEach(restore);\n };\n }, [duration, delay, stagger, width, ease, order, color, ownColor, trigger, startAt]);\n\n return (\n \n {children}\n \n );\n};\n\nexport default SvgDraw;\n", - "codeHtml": "
'use client';\n\nimport { useEffect, useLayoutEffect, useRef, type ComponentPropsWithoutRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport const SVG_DRAW_EASES = {\n  'power1.out': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n  'power2.out': 'cubic-bezier(0.22, 0.61, 0.36, 1)',\n  'power3.out': 'cubic-bezier(0.16, 1, 0.3, 1)',\n  'expo.out': 'cubic-bezier(0.19, 1, 0.22, 1)',\n  'in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',\n  linear: 'linear',\n} as const;\n\nexport type SvgDrawEase = keyof typeof SVG_DRAW_EASES;\n\nconst FADE_MS = 400;\nconst SHAPES = new Set(['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect']);\n// Shapes inside these are never painted where they sit, so there is nothing to draw\nconst SKIP = new Set([\n  'defs',\n  'clippath',\n  'mask',\n  'pattern',\n  'marker',\n  'symbol',\n  'lineargradient',\n  'radialgradient',\n  'filter',\n  'foreignobject',\n]);\n\nconst shapesIn = (node: Element): SVGGeometryElement[] =>\n  Array.from(node.children).flatMap((child) => {\n    const tag = child.tagName.toLowerCase();\n    if (SKIP.has(tag)) return [];\n    return SHAPES.has(tag) ? [child as SVGGeometryElement] : shapesIn(child);\n  });\n\nconst isClear = (paint: string) =>\n  paint === 'none' || paint === 'transparent' || paint === 'rgba(0, 0, 0, 0)';\n\ninterface Shape {\n  el: SVGGeometryElement;\n  len: number;\n  pad: number;\n  stroked: boolean;\n  trace: string;\n  traceWidth: string;\n  fillOpacity: string;\n  inline: string | null;\n}\n\nconst measure = (el: SVGGeometryElement, width: number, color: string, ownColor: boolean) => {\n  const style = getComputedStyle(el);\n  if (style.display === 'none' || style.visibility === 'hidden') return null;\n\n  let real = 0;\n  try {\n    real = el.getTotalLength();\n  } catch {\n    return null;\n  }\n  // A `pathLength` attribute changes the units the dash is measured in\n  const own = parseFloat(el.getAttribute('pathLength') ?? '');\n  const len = own > 0 ? own : real;\n  if (!(real > 0)) return null;\n\n  // Unset paint computes to the SVG defaults: a black fill and no stroke\n  const fill = style.fill || 'rgb(0, 0, 0)';\n  const stroke = style.stroke || 'none';\n  const strokeWidth = parseFloat(style.strokeWidth || '1');\n  const stroked = !isClear(stroke) && strokeWidth > 0;\n  if (!stroked && isClear(fill)) return null;\n\n  // `width` is in screen px, the stroke in the shape's own units\n  const ctm = el.getScreenCTM?.();\n  const scale = ctm ? Math.sqrt(Math.abs(ctm.a * ctm.d - ctm.b * ctm.c)) || 1 : 1;\n  const traceWidth = stroked ? strokeWidth : width / scale;\n\n  return {\n    el,\n    len,\n    // Run the dash past the end, so a line cap never shows as a dot before it starts\n    pad: (traceWidth + 1) * (len / real),\n    stroked,\n    trace: stroked ? stroke : ownColor ? fill : color,\n    traceWidth: String(traceWidth),\n    fillOpacity: style.fillOpacity || '1',\n    inline: el.getAttribute('style'),\n  } satisfies Shape;\n};\n\nconst restore = ({ el, inline }: Shape) => {\n  if (inline === null) el.removeAttribute('style');\n  else el.setAttribute('style', inline);\n};\n\nexport interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> {\n  /** Seconds each shape takes to draw. */\n  duration?: number;\n  /** Seconds before the first shape starts. */\n  delay?: number;\n  /** Seconds between one shape starting and the next. */\n  stagger?: number;\n  /** Trace width in screen px for fill-only shapes. Stroked shapes keep their own. */\n  width?: number;\n  ease?: SvgDrawEase | (string & {});\n  order?: 'longest' | 'reverse' | 'document';\n  /** Trace color for fill-only shapes. Stroked shapes keep their own. */\n  color?: string;\n  /** Trace fill-only shapes in their own fill color. */\n  ownColor?: boolean;\n  trigger?: 'view' | 'mount';\n  /** With `trigger=\"view\"`, how far down the viewport the SVG's top must rise, 0 to 1. */\n  startAt?: number;\n  onComplete?: () => void;\n}\n\nconst SvgDraw = ({\n  duration = 1.6,\n  delay = 0,\n  stagger = 0.18,\n  width = 1.5,\n  ease = 'power1.out',\n  order = 'longest',\n  color = 'currentColor',\n  ownColor = false,\n  trigger = 'view',\n  startAt = 0.85,\n  onComplete,\n  className,\n  children,\n  ...props\n}: SvgDrawProps) => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const onCompleteRef = useRef(onComplete);\n\n  useEffect(() => {\n    onCompleteRef.current = onComplete;\n  }, [onComplete]);\n\n  // Before paint, so the finished drawing never flashes up first\n  useIsomorphicLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const reveal = () => {\n      root.style.visibility = 'visible';\n    };\n    if (prefersReducedMotion() || root.getClientRects().length === 0) return reveal();\n\n    const shapes = shapesIn(root)\n      .map((el) => measure(el, width, color, ownColor))\n      .filter((shape): shape is Shape => shape !== null);\n    if (!shapes.length) return reveal();\n    if (order === 'longest') shapes.sort((a, b) => b.len - a.len);\n    else if (order === 'reverse') shapes.reverse();\n\n    shapes.forEach(({ el, len, pad, stroked, trace, traceWidth }) => {\n      Object.assign(el.style, {\n        transition: 'none',\n        fillOpacity: '0',\n        stroke: trace,\n        strokeWidth: traceWidth,\n        strokeDasharray: `${len} ${len + pad * 2}`,\n        strokeDashoffset: String(len + pad),\n        ...(stroked ? {} : { strokeLinecap: 'round', strokeLinejoin: 'round' }),\n      });\n    });\n    reveal();\n\n    const ms = Math.max(0.01, duration) * 1000;\n    const start = Math.max(0, delay) * 1000;\n    const gap = Math.max(0, stagger) * 1000;\n    const curve = SVG_DRAW_EASES[ease as SvgDrawEase] ?? ease;\n    const timers: number[] = [];\n\n    const play = () => {\n      void root.getBoundingClientRect();\n      shapes.forEach((shape, i) => {\n        const wait = start + i * gap;\n        shape.el.style.transition = `stroke-dashoffset ${ms}ms ${curve} ${wait}ms`;\n        shape.el.style.strokeDashoffset = '0';\n\n        timers.push(\n          window.setTimeout(() => {\n            shape.el.style.transition = `fill-opacity ${FADE_MS}ms ease, stroke-opacity ${FADE_MS}ms ease`;\n            shape.el.style.fillOpacity = shape.fillOpacity;\n            if (!shape.stroked) shape.el.style.strokeOpacity = '0';\n          }, wait + ms),\n          window.setTimeout(() => restore(shape), wait + ms + FADE_MS)\n        );\n      });\n      const end = start + (shapes.length - 1) * gap + ms + FADE_MS;\n      timers.push(window.setTimeout(() => onCompleteRef.current?.(), end));\n    };\n\n    let observer: IntersectionObserver | undefined;\n    if (trigger === 'view' && typeof IntersectionObserver !== 'undefined') {\n      const margin = Math.round((1 - Math.min(1, Math.max(0, startAt))) * 100);\n      observer = new IntersectionObserver(\n        ([entry]) => {\n          if (!entry.isIntersecting) return;\n          observer?.disconnect();\n          play();\n        },\n        { rootMargin: `0px 0px -${margin}% 0px` }\n      );\n      observer.observe(root);\n    } else {\n      play();\n    }\n\n    // Hand the SVG back as written, so a re-run measures the real thing\n    return () => {\n      observer?.disconnect();\n      timers.forEach((id) => window.clearTimeout(id));\n      shapes.forEach(restore);\n    };\n  }, [duration, delay, stagger, width, ease, order, color, ownColor, trigger, startAt]);\n\n  return (\n    <div\n      ref={rootRef}\n      data-svg-draw=\"\"\n      className={cn('motion-reduce:visible! [@media(scripting:enabled)]:invisible', className)}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport default SvgDraw;\n
" + "code": "'use client';\n\nimport { useEffect, useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, DrawSVGPlugin, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect';\n// Shapes inside these are never painted where they sit\nconst UNPAINTED = 'defs, clipPath, mask, pattern, marker, symbol';\nconst NO_PAINT = new Set(['none', 'transparent', 'rgba(0, 0, 0, 0)']);\nconst FILL_FADE = 0.5;\n\nexport interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> {\n /** Seconds each shape takes to draw. */\n duration?: number;\n delay?: number;\n /** Seconds between one shape starting and the next. */\n stagger?: number;\n /** Any GSAP ease. */\n ease?: string;\n /** Width in screen px of the line that traces a fill-only shape. */\n width?: number;\n trigger?: 'view' | 'mount';\n /** ScrollTrigger start, with `trigger=\"view\"`. */\n start?: string;\n onComplete?: () => void;\n}\n\nconst SvgDraw = ({\n duration = 1.4,\n delay = 0,\n stagger = 0.12,\n ease = 'power2.inOut',\n width = 1.5,\n trigger = 'view',\n start = 'top 85%',\n onComplete,\n className,\n children,\n ...props\n}: SvgDrawProps) => {\n const rootRef = useRef(null);\n const onCompleteRef = useRef(onComplete);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n }, [onComplete]);\n\n useGSAP(\n () => {\n const root = rootRef.current;\n if (!root) return;\n gsap.set(root, { visibility: 'visible' });\n if (prefersReducedMotion()) return;\n\n const shapes = gsap.utils\n .toArray(root.querySelectorAll(SHAPES))\n .filter((shape) => !shape.closest(UNPAINTED));\n if (!shapes.length) return;\n\n const painted = (shape: Element, paint: 'fill' | 'stroke') =>\n !NO_PAINT.has(getComputedStyle(shape)[paint]);\n const filled = shapes.filter((shape) => painted(shape, 'fill'));\n // Fill-only shapes get a thin line to draw with, which fades once they fill\n const traced = filled.filter((shape) => !painted(shape, 'stroke'));\n gsap.set(traced, {\n stroke: 'currentColor',\n strokeWidth: width,\n vectorEffect: 'non-scaling-stroke',\n });\n\n gsap\n .timeline({\n delay,\n scrollTrigger: trigger === 'view' ? { trigger: root, start, once: true } : undefined,\n onComplete: () => onCompleteRef.current?.(),\n })\n .from(shapes, { drawSVG: 0, duration, ease, stagger })\n .from(filled, { fillOpacity: 0, duration: FILL_FADE, stagger }, duration * 0.6)\n .to(traced, { strokeOpacity: 0, duration: FILL_FADE, stagger }, '<');\n },\n {\n scope: rootRef,\n dependencies: [duration, delay, stagger, ease, width, trigger, start],\n revertOnUpdate: true,\n }\n );\n\n return (\n \n {children}\n \n );\n};\n\nexport default SvgDraw;\n", + "codeHtml": "
'use client';\n\nimport { useEffect, useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, DrawSVGPlugin, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect';\n// Shapes inside these are never painted where they sit\nconst UNPAINTED = 'defs, clipPath, mask, pattern, marker, symbol';\nconst NO_PAINT = new Set(['none', 'transparent', 'rgba(0, 0, 0, 0)']);\nconst FILL_FADE = 0.5;\n\nexport interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> {\n  /** Seconds each shape takes to draw. */\n  duration?: number;\n  delay?: number;\n  /** Seconds between one shape starting and the next. */\n  stagger?: number;\n  /** Any GSAP ease. */\n  ease?: string;\n  /** Width in screen px of the line that traces a fill-only shape. */\n  width?: number;\n  trigger?: 'view' | 'mount';\n  /** ScrollTrigger start, with `trigger=\"view\"`. */\n  start?: string;\n  onComplete?: () => void;\n}\n\nconst SvgDraw = ({\n  duration = 1.4,\n  delay = 0,\n  stagger = 0.12,\n  ease = 'power2.inOut',\n  width = 1.5,\n  trigger = 'view',\n  start = 'top 85%',\n  onComplete,\n  className,\n  children,\n  ...props\n}: SvgDrawProps) => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const onCompleteRef = useRef(onComplete);\n\n  useEffect(() => {\n    onCompleteRef.current = onComplete;\n  }, [onComplete]);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root) return;\n      gsap.set(root, { visibility: 'visible' });\n      if (prefersReducedMotion()) return;\n\n      const shapes = gsap.utils\n        .toArray<SVGGeometryElement>(root.querySelectorAll(SHAPES))\n        .filter((shape) => !shape.closest(UNPAINTED));\n      if (!shapes.length) return;\n\n      const painted = (shape: Element, paint: 'fill' | 'stroke') =>\n        !NO_PAINT.has(getComputedStyle(shape)[paint]);\n      const filled = shapes.filter((shape) => painted(shape, 'fill'));\n      // Fill-only shapes get a thin line to draw with, which fades once they fill\n      const traced = filled.filter((shape) => !painted(shape, 'stroke'));\n      gsap.set(traced, {\n        stroke: 'currentColor',\n        strokeWidth: width,\n        vectorEffect: 'non-scaling-stroke',\n      });\n\n      gsap\n        .timeline({\n          delay,\n          scrollTrigger: trigger === 'view' ? { trigger: root, start, once: true } : undefined,\n          onComplete: () => onCompleteRef.current?.(),\n        })\n        .from(shapes, { drawSVG: 0, duration, ease, stagger })\n        .from(filled, { fillOpacity: 0, duration: FILL_FADE, stagger }, duration * 0.6)\n        .to(traced, { strokeOpacity: 0, duration: FILL_FADE, stagger }, '<');\n    },\n    {\n      scope: rootRef,\n      dependencies: [duration, delay, stagger, ease, width, trigger, start],\n      revertOnUpdate: true,\n    }\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn('motion-reduce:visible! [@media(scripting:enabled)]:invisible', className)}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport default SvgDraw;\n
" } }, "pricing-plan-switch": { @@ -707,8 +702,8 @@ export const manual: Record = { }, "arc-carousel": { "react": { - "code": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n} from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport interface ArcCarouselItem {\n title: string;\n image: string;\n /** Chip on the top left of the card. */\n tag?: string;\n /** Line under the title, e.g. a client and year or a price. */\n meta?: string;\n href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n items?: ArcCarouselItem[];\n badge?: string;\n title?: string;\n description?: string;\n cta?: { label: string; href: string } | null;\n /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n autoplay?: boolean;\n /** Time each card holds the centre while autoplaying, in ms. */\n interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n { x: 0, y: 0, r: 0 },\n { x: 359, y: 26, r: 8.77 },\n { x: 711, y: 113, r: 18.83 },\n { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)';\nconst MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease';\nconst SWIPE = 50;\nconst GHOST_LIFE = 850;\n// Caps one frame of autoplay, so a backgrounded tab never skips ahead\nconst MAX_FRAME = 100;\n\nconst slot = (offset: number) => {\n const t = Math.abs(offset);\n const sign = Math.sign(offset);\n const last = SLOTS.length - 1;\n let p = SLOTS[Math.min(t, last)];\n if (t > last) {\n const prev = SLOTS[last - 1];\n const n = t - last;\n p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n };\n }\n return {\n transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`,\n opacity: t <= last ? '1' : '0',\n zIndex: String(100 - t),\n };\n};\n\n// Signed distance from the centre, the shortest way round\nconst offsetOf = (index: number, position: number, count: number) => {\n let r = (((index - position) % count) + count) % count;\n if (r > Math.floor(count / 2)) r -= count;\n return r;\n};\n\nconst wrap = (position: number, count: number) => ((position % count) + count) % count;\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n src.includes('images.unsplash.com')\n ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n {\n title: 'Nightfall',\n tag: 'Astronomy app',\n meta: 'Stellar Co. · 2026',\n image: photo('1519681393784-d120267933ba'),\n },\n {\n title: 'Chroma Identity',\n tag: 'Brand identity',\n meta: 'Chroma Studio · 2026',\n image: photo('1541701494587-cb58502866ab'),\n },\n {\n title: 'Alpine Lodge',\n tag: 'Hospitality',\n meta: 'Moraine Hotels · 2026',\n image: photo('1493246507139-91e8fad9978e'),\n },\n {\n title: 'Metro Pulse',\n tag: 'Data visualisation',\n meta: 'City of Lakes · 2025',\n image: photo('1477959858617-67f85cf4f1df'),\n },\n {\n title: 'Orbit Motion',\n tag: '3D & motion',\n meta: 'Orbit Labs · 2025',\n image: photo('1618005198919-d3d4b5a92ead'),\n },\n {\n title: 'Open Road',\n tag: 'Campaign film',\n meta: 'Westward · 2025',\n image: photo('1500530855697-b586d89ba3ee'),\n },\n {\n title: 'Still Water',\n tag: 'Wellness app',\n meta: 'Calm Harbor · 2024',\n image: photo('1534447677768-be436bb09401'),\n },\n {\n title: 'Lago Retreats',\n tag: 'Travel platform',\n meta: 'Lago Travel · 2024',\n image: photo('1501785888041-af3ef285b470'),\n },\n];\n\nconst STEP = cn(\n 'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white',\n 'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none'\n);\nconst FADE =\n 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n \n \n \n);\n\nconst PauseIcon = () => (\n \n \n \n \n);\n\nconst PlayIcon = () => (\n \n \n \n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n \n \n \n);\n\nconst ArcCarousel = ({\n items = DEFAULT_ITEMS,\n badge = 'Featured work',\n title = 'Projects we shipped this season.',\n description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.',\n cta = { label: 'View all projects', href: '#' },\n autoplay = true,\n interval = 5000,\n className,\n ...props\n}: ArcCarouselProps) => {\n const count = items.length;\n const [position, setPosition] = useState(0);\n const [playing, setPlaying] = useState(autoplay);\n const [held, setHeld] = useState(false);\n const rootRef = useRef(null);\n const ghostsRef = useRef(null);\n const ringRef = useRef(null);\n const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n const artRefs = useRef<(HTMLDivElement | null)[]>([]);\n const layout = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] });\n const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n const holds = useRef({ hover: false, focus: false, offscreen: false });\n const progress = useRef(0);\n const titleId = useId();\n const descriptionId = useId();\n\n // `playing` is the viewer's choice; a hold is a pause they did not ask for\n const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n holds.current[key] = on;\n setHeld(Object.values(holds.current).some(Boolean));\n }, []);\n\n const place = (item: HTMLElement, offset: number) => {\n const s = slot(offset);\n item.style.transform = s.transform;\n item.style.opacity = s.opacity;\n item.style.zIndex = s.zIndex;\n item.style.pointerEvents = s.opacity === '1' ? '' : 'none';\n };\n\n useIsomorphicLayoutEffect(() => {\n const ghosts = ghostsRef.current;\n if (!ghosts || !count) return;\n const reduced = prefersReducedMotion();\n const move = reduced ? 'opacity 0.3s ease' : MOVE;\n const art = reduced ? 'none' : ART;\n const st = layout.current;\n const animate = st.offsets.length > 0;\n const delta = animate ? position - st.last : 0;\n st.last = position;\n\n itemRefs.current.forEach((item, i) => {\n if (!item) return;\n const next = offsetOf(i, position, count);\n const prev = st.offsets[i];\n\n const scaleEl = artRefs.current[i];\n if (scaleEl) {\n const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)';\n scaleEl.style.transition = animate ? art : 'none';\n scaleEl.style.transform = scale;\n if (!animate) {\n void scaleEl.offsetWidth;\n scaleEl.style.transition = art;\n }\n }\n\n if (!animate || prev === undefined) {\n item.style.transition = 'none';\n place(item, next);\n } else if (prev - delta !== next) {\n // Wrapped round: a copy carries on off this edge while the card enters from the other\n const last = SLOTS.length - 1;\n const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1));\n const ghost = item.cloneNode(true) as HTMLElement;\n ghost.setAttribute('aria-hidden', 'true');\n ghost.inert = true;\n ghost.style.pointerEvents = 'none';\n ghost.style.transition = 'none';\n ghosts.appendChild(ghost);\n const out = slot(prev - delta);\n void ghost.offsetWidth;\n ghost.style.transition = move;\n ghost.style.transform = out.transform;\n ghost.style.opacity = '0';\n st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE));\n\n item.style.transition = 'none';\n place(item, entry);\n item.style.opacity = '0';\n void item.offsetWidth;\n item.style.transition = move;\n place(item, next);\n } else {\n item.style.transition = move;\n place(item, next);\n }\n st.offsets[i] = next;\n });\n }, [position, count]);\n\n useEffect(() => {\n const { ghosts } = layout.current;\n return () => ghosts.forEach((id) => window.clearTimeout(id));\n }, []);\n\n const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n // The media query is client-only, so reduced motion applies after hydration\n useEffect(() => {\n setPlaying(autoplay && !prefersReducedMotion());\n }, [autoplay]);\n\n useEffect(() => {\n progress.current = 0;\n ringRef.current?.style.setProperty('stroke-dashoffset', '1');\n }, [position, playing]);\n\n useEffect(() => {\n if (!playing || held || count < 2) return;\n let frame = 0;\n let last = performance.now();\n const tick = (now: number) => {\n progress.current += Math.min(now - last, MAX_FRAME) / interval;\n last = now;\n if (progress.current >= 1) {\n progress.current = 0;\n step(1);\n }\n ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current));\n frame = requestAnimationFrame(tick);\n };\n frame = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(frame);\n }, [playing, held, interval, count, step]);\n\n useEffect(() => {\n const root = rootRef.current;\n if (!autoplay || !root || typeof IntersectionObserver === 'undefined') return;\n const observer = new IntersectionObserver(([entry]) =>\n hold('offscreen', !entry.isIntersecting)\n );\n observer.observe(root);\n return () => observer.disconnect();\n }, [autoplay, hold]);\n\n // A swipe can end outside the list\n useEffect(() => {\n const onUp = (event: PointerEvent) => {\n const s = swipe.current;\n if (!s.tracking) return;\n s.tracking = false;\n const dx = event.clientX - s.x;\n const dy = event.clientY - s.y;\n if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) {\n s.swiped = true;\n step(dx < 0 ? 1 : -1);\n }\n };\n const onCancel = () => {\n swipe.current.tracking = false;\n };\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n return () => {\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n };\n }, [step]);\n\n // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n const onClickCapture = (event: MouseEvent) => {\n const s = swipe.current;\n if (s.swiped) {\n s.swiped = false;\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n if (event.detail === 0) return;\n const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node));\n if (index < 0) return;\n const offset = offsetOf(index, position, count);\n if (offset !== 0) {\n event.preventDefault();\n event.stopPropagation();\n step(Math.sign(offset));\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n event.preventDefault();\n step(event.key === 'ArrowRight' ? 1 : -1);\n };\n\n const current = count ? wrap(position, count) : 0;\n const active = items[current];\n\n return (\n \n \n\n
\n {badge && (\n \n \n {badge}\n \n )}\n \n {title}\n \n {description && (\n \n {description}\n

\n )}\n
\n\n {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n event.pointerType !== 'touch' && hold('hover', true)}\n onPointerLeave={() => hold('hover', false)}\n >\n
\n {\n swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n }}\n onDragStart={(event) => event.preventDefault()}\n onClickCapture={onClickCapture}\n onKeyDown={onKeyDown}\n onFocus={(event) => {\n let visible = true;\n try {\n visible = event.target.matches(':focus-visible');\n } catch {\n // No :focus-visible support: treat every focus as visible\n }\n if (visible) hold('focus', true);\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n hold('focus', false);\n }\n }}\n >\n {items.map((item, i) => {\n const offset = offsetOf(i, position, count);\n const isActive = offset === 0;\n const Card = item.href ? 'a' : 'div';\n // Also set in markup, so the arc is in place before hydration\n const first = slot(offsetOf(i, 0, count));\n return (\n {\n itemRefs.current[i] = el;\n }}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}: ${item.title}`}\n aria-hidden={isActive ? undefined : true}\n data-active={isActive ? '' : undefined}\n className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n style={{\n transform: first.transform,\n opacity: first.opacity,\n zIndex: first.zIndex,\n }}\n >\n {\n artRefs.current[i] = el;\n }}\n className=\"size-full origin-center\"\n >\n \n \n\n \n \n\n \n {item.tag ? (\n \n {item.tag}\n \n ) : (\n \n )}\n \n {pad2(i + 1)}\n / {pad2(count)}\n \n \n\n \n \n \n {item.title}\n \n {item.meta && (\n \n {item.meta}\n \n )}\n \n {item.href && (\n \n \n \n )}\n \n\n \n \n
\n \n );\n })}\n \n \n \n \n\n
\n step(-1)}\n >\n \n \n {cta && (\n \n {cta.label}\n \n \n )}\n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n \n \n \n \n )}\n
\n\n {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n

\n {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n

\n\n
\n
\n \n );\n};\n\nexport default ArcCarousel;\n", - "codeHtml": "
'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  type MouseEvent,\n} from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport interface ArcCarouselItem {\n  title: string;\n  image: string;\n  /** Chip on the top left of the card. */\n  tag?: string;\n  /** Line under the title, e.g. a client and year or a price. */\n  meta?: string;\n  href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n  items?: ArcCarouselItem[];\n  badge?: string;\n  title?: string;\n  description?: string;\n  cta?: { label: string; href: string } | null;\n  /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n  autoplay?: boolean;\n  /** Time each card holds the centre while autoplaying, in ms. */\n  interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n  { x: 0, y: 0, r: 0 },\n  { x: 359, y: 26, r: 8.77 },\n  { x: 711, y: 113, r: 18.83 },\n  { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)';\nconst MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease';\nconst SWIPE = 50;\nconst GHOST_LIFE = 850;\n// Caps one frame of autoplay, so a backgrounded tab never skips ahead\nconst MAX_FRAME = 100;\n\nconst slot = (offset: number) => {\n  const t = Math.abs(offset);\n  const sign = Math.sign(offset);\n  const last = SLOTS.length - 1;\n  let p = SLOTS[Math.min(t, last)];\n  if (t > last) {\n    const prev = SLOTS[last - 1];\n    const n = t - last;\n    p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n };\n  }\n  return {\n    transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`,\n    opacity: t <= last ? '1' : '0',\n    zIndex: String(100 - t),\n  };\n};\n\n// Signed distance from the centre, the shortest way round\nconst offsetOf = (index: number, position: number, count: number) => {\n  let r = (((index - position) % count) + count) % count;\n  if (r > Math.floor(count / 2)) r -= count;\n  return r;\n};\n\nconst wrap = (position: number, count: number) => ((position % count) + count) % count;\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n  `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n  src.includes('images.unsplash.com')\n    ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n    : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n  {\n    title: 'Nightfall',\n    tag: 'Astronomy app',\n    meta: 'Stellar Co. · 2026',\n    image: photo('1519681393784-d120267933ba'),\n  },\n  {\n    title: 'Chroma Identity',\n    tag: 'Brand identity',\n    meta: 'Chroma Studio · 2026',\n    image: photo('1541701494587-cb58502866ab'),\n  },\n  {\n    title: 'Alpine Lodge',\n    tag: 'Hospitality',\n    meta: 'Moraine Hotels · 2026',\n    image: photo('1493246507139-91e8fad9978e'),\n  },\n  {\n    title: 'Metro Pulse',\n    tag: 'Data visualisation',\n    meta: 'City of Lakes · 2025',\n    image: photo('1477959858617-67f85cf4f1df'),\n  },\n  {\n    title: 'Orbit Motion',\n    tag: '3D & motion',\n    meta: 'Orbit Labs · 2025',\n    image: photo('1618005198919-d3d4b5a92ead'),\n  },\n  {\n    title: 'Open Road',\n    tag: 'Campaign film',\n    meta: 'Westward · 2025',\n    image: photo('1500530855697-b586d89ba3ee'),\n  },\n  {\n    title: 'Still Water',\n    tag: 'Wellness app',\n    meta: 'Calm Harbor · 2024',\n    image: photo('1534447677768-be436bb09401'),\n  },\n  {\n    title: 'Lago Retreats',\n    tag: 'Travel platform',\n    meta: 'Lago Travel · 2024',\n    image: photo('1501785888041-af3ef285b470'),\n  },\n];\n\nconst STEP = cn(\n  'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white',\n  'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none'\n);\nconst FADE =\n  'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n  <svg\n    viewBox=\"0 0 9 16\"\n    aria-hidden=\"true\"\n    className={cn('h-4 w-[8.5px]', flip && 'rotate-180')}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M7.5 1.5 1.5 8l6 6.5\" />\n  </svg>\n);\n\nconst PauseIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <rect x=\"3.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n    <rect x=\"9.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n  </svg>\n);\n\nconst PlayIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <path d=\"M4.5 2.9v10.2a1 1 0 0 0 1.53.85l8.1-5.1a1 1 0 0 0 0-1.7l-8.1-5.1A1 1 0 0 0 4.5 2.9Z\" />\n  </svg>\n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n  <svg\n    viewBox=\"0 0 16 16\"\n    aria-hidden=\"true\"\n    className={className}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M4.5 11.5 11.5 4.5M5.5 4.5h6v6\" />\n  </svg>\n);\n\nconst ArcCarousel = ({\n  items = DEFAULT_ITEMS,\n  badge = 'Featured work',\n  title = 'Projects we shipped this season.',\n  description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.',\n  cta = { label: 'View all projects', href: '#' },\n  autoplay = true,\n  interval = 5000,\n  className,\n  ...props\n}: ArcCarouselProps) => {\n  const count = items.length;\n  const [position, setPosition] = useState(0);\n  const [playing, setPlaying] = useState(autoplay);\n  const [held, setHeld] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  const ghostsRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<SVGCircleElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const artRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const layout = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] });\n  const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n  const holds = useRef({ hover: false, focus: false, offscreen: false });\n  const progress = useRef(0);\n  const titleId = useId();\n  const descriptionId = useId();\n\n  // `playing` is the viewer's choice; a hold is a pause they did not ask for\n  const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n    holds.current[key] = on;\n    setHeld(Object.values(holds.current).some(Boolean));\n  }, []);\n\n  const place = (item: HTMLElement, offset: number) => {\n    const s = slot(offset);\n    item.style.transform = s.transform;\n    item.style.opacity = s.opacity;\n    item.style.zIndex = s.zIndex;\n    item.style.pointerEvents = s.opacity === '1' ? '' : 'none';\n  };\n\n  useIsomorphicLayoutEffect(() => {\n    const ghosts = ghostsRef.current;\n    if (!ghosts || !count) return;\n    const reduced = prefersReducedMotion();\n    const move = reduced ? 'opacity 0.3s ease' : MOVE;\n    const art = reduced ? 'none' : ART;\n    const st = layout.current;\n    const animate = st.offsets.length > 0;\n    const delta = animate ? position - st.last : 0;\n    st.last = position;\n\n    itemRefs.current.forEach((item, i) => {\n      if (!item) return;\n      const next = offsetOf(i, position, count);\n      const prev = st.offsets[i];\n\n      const scaleEl = artRefs.current[i];\n      if (scaleEl) {\n        const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)';\n        scaleEl.style.transition = animate ? art : 'none';\n        scaleEl.style.transform = scale;\n        if (!animate) {\n          void scaleEl.offsetWidth;\n          scaleEl.style.transition = art;\n        }\n      }\n\n      if (!animate || prev === undefined) {\n        item.style.transition = 'none';\n        place(item, next);\n      } else if (prev - delta !== next) {\n        // Wrapped round: a copy carries on off this edge while the card enters from the other\n        const last = SLOTS.length - 1;\n        const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1));\n        const ghost = item.cloneNode(true) as HTMLElement;\n        ghost.setAttribute('aria-hidden', 'true');\n        ghost.inert = true;\n        ghost.style.pointerEvents = 'none';\n        ghost.style.transition = 'none';\n        ghosts.appendChild(ghost);\n        const out = slot(prev - delta);\n        void ghost.offsetWidth;\n        ghost.style.transition = move;\n        ghost.style.transform = out.transform;\n        ghost.style.opacity = '0';\n        st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE));\n\n        item.style.transition = 'none';\n        place(item, entry);\n        item.style.opacity = '0';\n        void item.offsetWidth;\n        item.style.transition = move;\n        place(item, next);\n      } else {\n        item.style.transition = move;\n        place(item, next);\n      }\n      st.offsets[i] = next;\n    });\n  }, [position, count]);\n\n  useEffect(() => {\n    const { ghosts } = layout.current;\n    return () => ghosts.forEach((id) => window.clearTimeout(id));\n  }, []);\n\n  const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n  // The media query is client-only, so reduced motion applies after hydration\n  useEffect(() => {\n    setPlaying(autoplay && !prefersReducedMotion());\n  }, [autoplay]);\n\n  useEffect(() => {\n    progress.current = 0;\n    ringRef.current?.style.setProperty('stroke-dashoffset', '1');\n  }, [position, playing]);\n\n  useEffect(() => {\n    if (!playing || held || count < 2) return;\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      progress.current += Math.min(now - last, MAX_FRAME) / interval;\n      last = now;\n      if (progress.current >= 1) {\n        progress.current = 0;\n        step(1);\n      }\n      ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current));\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, held, interval, count, step]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!autoplay || !root || typeof IntersectionObserver === 'undefined') return;\n    const observer = new IntersectionObserver(([entry]) =>\n      hold('offscreen', !entry.isIntersecting)\n    );\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [autoplay, hold]);\n\n  // A swipe can end outside the list\n  useEffect(() => {\n    const onUp = (event: PointerEvent) => {\n      const s = swipe.current;\n      if (!s.tracking) return;\n      s.tracking = false;\n      const dx = event.clientX - s.x;\n      const dy = event.clientY - s.y;\n      if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) {\n        s.swiped = true;\n        step(dx < 0 ? 1 : -1);\n      }\n    };\n    const onCancel = () => {\n      swipe.current.tracking = false;\n    };\n    window.addEventListener('pointerup', onUp);\n    window.addEventListener('pointercancel', onCancel);\n    return () => {\n      window.removeEventListener('pointerup', onUp);\n      window.removeEventListener('pointercancel', onCancel);\n    };\n  }, [step]);\n\n  // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n  const onClickCapture = (event: MouseEvent<HTMLDivElement>) => {\n    const s = swipe.current;\n    if (s.swiped) {\n      s.swiped = false;\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (event.detail === 0) return;\n    const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node));\n    if (index < 0) return;\n    const offset = offsetOf(index, position, count);\n    if (offset !== 0) {\n      event.preventDefault();\n      event.stopPropagation();\n      step(Math.sign(offset));\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n    event.preventDefault();\n    step(event.key === 'ArrowRight' ? 1 : -1);\n  };\n\n  const current = count ? wrap(position, count) : 0;\n  const active = items[current];\n\n  return (\n    <section\n      ref={rootRef}\n      aria-labelledby={titleId}\n      aria-describedby={description ? descriptionId : undefined}\n      className={cn(\n        'bg-tween-ink-deep @container relative isolate flex w-full flex-col items-center gap-4 overflow-hidden pt-28 pb-24 text-white',\n        '@max-[64rem]:pt-24 @max-[64rem]:pb-18 @max-[48rem]:gap-8 @max-[48rem]:py-16',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-[62%] left-1/2 -z-10 size-[min(120cqw,70rem)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--color-tween-teal)_45%,transparent),color-mix(in_oklch,var(--color-tween-teal)_10%,transparent)_55%,transparent)]\"\n      />\n\n      <header className=\"relative z-3 flex w-full max-w-2xl flex-col items-center gap-5 px-6 text-center @max-[48rem]:gap-3\">\n        {badge && (\n          <span className=\"border-tween-lime/30 bg-tween-lime/10 text-tween-lime inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium tracking-[0.14em] uppercase\">\n            <span aria-hidden=\"true\" className=\"bg-tween-lime size-1.5 rounded-full\" />\n            {badge}\n          </span>\n        )}\n        <h2\n          id={titleId}\n          className=\"text-[clamp(2.25rem,5.5cqw,4rem)] leading-[1.02] font-semibold tracking-[-0.045em] text-balance\"\n        >\n          {title}\n        </h2>\n        {description && (\n          <p\n            id={descriptionId}\n            className=\"text-tween-teal-soft/75 max-w-lg text-[15px] leading-relaxed text-pretty @max-[48rem]:text-sm\"\n          >\n            {description}\n          </p>\n        )}\n      </header>\n\n      {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n      <div\n        className=\"relative isolate z-1 h-150 w-78 @max-[64rem]:h-134 @max-[48rem]:h-72.5\"\n        onPointerEnter={(event) => event.pointerType !== 'touch' && hold('hover', true)}\n        onPointerLeave={() => hold('hover', false)}\n      >\n        <div className=\"absolute inset-0 origin-[50%_0] @max-[64rem]:scale-80 @max-[48rem]:scale-62\">\n          <div\n            role=\"group\"\n            tabIndex={0}\n            aria-roledescription=\"carousel\"\n            aria-label={title}\n            className=\"focus-visible:ring-tween-lime focus-visible:ring-offset-tween-ink-deep absolute top-16 left-0 h-107.5 w-78 touch-pan-y rounded-[8px] outline-none select-none focus-visible:ring-2 focus-visible:ring-offset-8 @max-[48rem]:top-2\"\n            onPointerDown={(event) => {\n              swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n            }}\n            onDragStart={(event) => event.preventDefault()}\n            onClickCapture={onClickCapture}\n            onKeyDown={onKeyDown}\n            onFocus={(event) => {\n              let visible = true;\n              try {\n                visible = event.target.matches(':focus-visible');\n              } catch {\n                // No :focus-visible support: treat every focus as visible\n              }\n              if (visible) hold('focus', true);\n            }}\n            onBlur={(event) => {\n              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n                hold('focus', false);\n              }\n            }}\n          >\n            {items.map((item, i) => {\n              const offset = offsetOf(i, position, count);\n              const isActive = offset === 0;\n              const Card = item.href ? 'a' : 'div';\n              // Also set in markup, so the arc is in place before hydration\n              const first = slot(offsetOf(i, 0, count));\n              return (\n                <div\n                  key={`${item.title}-${i}`}\n                  ref={(el) => {\n                    itemRefs.current[i] = el;\n                  }}\n                  role=\"group\"\n                  aria-roledescription=\"slide\"\n                  aria-label={`${i + 1} of ${count}: ${item.title}`}\n                  aria-hidden={isActive ? undefined : true}\n                  data-active={isActive ? '' : undefined}\n                  className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n                  style={{\n                    transform: first.transform,\n                    opacity: first.opacity,\n                    zIndex: first.zIndex,\n                  }}\n                >\n                  <div\n                    ref={(el) => {\n                      artRefs.current[i] = el;\n                    }}\n                    className=\"size-full origin-center\"\n                  >\n                    <Card\n                      {...(item.href ? { href: item.href } : {})}\n                      tabIndex={item.href && isActive ? 0 : -1}\n                      draggable={false}\n                      className={cn(\n                        'bg-tween-ink relative block size-full overflow-hidden rounded-[8px]',\n                        'shadow-[0_30px_60px_-24px_rgb(0_0_0/0.75)]',\n                        'focus-visible:outline-tween-lime focus-visible:outline-2 focus-visible:outline-offset-4'\n                      )}\n                    >\n                      <img\n                        src={item.image}\n                        srcSet={srcSetOf(item.image)}\n                        alt=\"\"\n                        width={312}\n                        height={430}\n                        draggable={false}\n                        decoding=\"async\"\n                        loading={Math.abs(offsetOf(i, 0, count)) <= 2 ? 'eager' : 'lazy'}\n                        className=\"absolute inset-0 size-full object-cover\"\n                      />\n\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"bg-tween-ink-deep/40 ease-tween absolute inset-0 transition-opacity duration-500 group-data-active/card:opacity-0 motion-reduce:transition-none\"\n                      />\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"from-tween-ink-deep via-tween-ink-deep/60 absolute inset-x-0 bottom-0 h-3/5 bg-linear-to-t to-transparent\"\n                      />\n\n                      <span className=\"absolute inset-x-4 top-4 flex items-center justify-between gap-3\">\n                        {item.tag ? (\n                          <span className=\"bg-tween-ink-deep/70 truncate rounded-full px-3 py-1.5 text-[11px] leading-none font-medium tracking-[0.12em] text-white uppercase ring-1 ring-white/10\">\n                            {item.tag}\n                          </span>\n                        ) : (\n                          <span />\n                        )}\n                        <span className=\"shrink-0 text-xs font-medium text-white/70 tabular-nums\">\n                          {pad2(i + 1)}\n                          <span className=\"text-white/35\"> / {pad2(count)}</span>\n                        </span>\n                      </span>\n\n                      <span className=\"absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-5\">\n                        <span className=\"grid min-w-0 gap-1.5\">\n                          <span className=\"truncate text-[22px] leading-tight font-semibold tracking-[-0.025em] text-white\">\n                            {item.title}\n                          </span>\n                          {item.meta && (\n                            <span className=\"text-tween-teal-soft/80 truncate text-sm\">\n                              {item.meta}\n                            </span>\n                          )}\n                        </span>\n                        {item.href && (\n                          <span\n                            aria-hidden=\"true\"\n                            className={cn(\n                              'grid size-11 shrink-0 place-items-center rounded-full bg-white/10 text-white ring-1 ring-white/20',\n                              'ease-tween transition-[background-color,color,box-shadow] duration-500 motion-reduce:transition-none',\n                              'group-data-active/card:bg-tween-lime group-data-active/card:text-tween-ink-deep group-data-active/card:ring-transparent'\n                            )}\n                          >\n                            <ArrowUpRight className=\"ease-tween size-4 transition-transform duration-300 group-hover/card:rotate-45 motion-reduce:transition-none\" />\n                          </span>\n                        )}\n                      </span>\n\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"ease-tween group-data-active/card:ring-tween-lime/80 pointer-events-none absolute inset-0 rounded-[8px] ring-1 ring-white/10 transition-shadow duration-500 ring-inset group-data-active/card:ring-2 motion-reduce:transition-none\"\n                      />\n                    </Card>\n                  </div>\n                </div>\n              );\n            })}\n            <div\n              ref={ghostsRef}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0\"\n            />\n          </div>\n        </div>\n      </div>\n\n      <div className=\"relative z-3 flex items-center gap-2\">\n        <button\n          className={STEP}\n          type=\"button\"\n          aria-label=\"Previous project\"\n          onClick={() => step(-1)}\n        >\n          <Caret />\n        </button>\n        {cta && (\n          <a\n            href={cta.href}\n            className={cn(\n              'bg-tween-lime text-tween-ink-deep inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-semibold',\n              'ease-tween transition-[filter,transform] duration-300 hover:brightness-110 active:scale-[0.97]',\n              'focus-visible:outline-tween-lime focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:transition-none'\n            )}\n          >\n            {cta.label}\n            <ArrowUpRight className=\"size-3.5\" />\n          </a>\n        )}\n        <button className={STEP} type=\"button\" aria-label=\"Next project\" onClick={() => step(1)}>\n          <Caret flip />\n        </button>\n        {autoplay && (\n          <button\n            className={cn(STEP, 'relative ml-2')}\n            type=\"button\"\n            aria-label={playing ? 'Pause autoplay' : 'Start autoplay'}\n            onClick={() => setPlaying((on) => !on)}\n          >\n            {playing ? <PauseIcon /> : <PlayIcon />}\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 44 44\"\n              className={cn(\n                'pointer-events-none absolute -inset-px -rotate-90 transition-opacity duration-300',\n                playing ? 'opacity-100' : 'opacity-0'\n              )}\n            >\n              <circle\n                ref={ringRef}\n                cx=\"22\"\n                cy=\"22\"\n                r=\"21\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                pathLength={1}\n                strokeDasharray=\"1\"\n                strokeDashoffset=\"1\"\n                className=\"text-tween-lime\"\n              />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n      <p aria-live={playing && !held ? 'off' : 'polite'} className=\"sr-only\">\n        {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n      </p>\n\n      <div aria-hidden=\"true\" className={cn(FADE, 'left-0 bg-linear-to-r')} />\n      <div aria-hidden=\"true\" className={cn(FADE, 'right-0 bg-linear-to-l')} />\n    </section>\n  );\n};\n\nexport default ArcCarousel;\n
" + "code": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ArcCarouselItem {\n title: string;\n image: string;\n /** Chip on the top left of the card. */\n tag?: string;\n /** Line under the title, e.g. a client and year or a price. */\n meta?: string;\n href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n /** Eight or more keep the wrap seamless. */\n items?: ArcCarouselItem[];\n /** Accessible name for the carousel. */\n label?: string;\n /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n autoplay?: boolean;\n /** Time each card holds the centre while autoplaying, in ms. */\n interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n { x: 0, y: 0, r: 0 },\n { x: 359, y: 26, r: 8.77 },\n { x: 711, y: 113, r: 18.83 },\n { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst MOVE_DURATION = 0.8;\nconst MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1');\nconst SWIPE = 50;\n\n/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */\nconst slot = (offset: number) => {\n const t = Math.abs(offset);\n const sign = Math.sign(offset);\n const i = Math.min(Math.floor(t), SLOTS.length - 2);\n const a = SLOTS[i];\n const b = SLOTS[i + 1];\n const f = t - i;\n return {\n x: (a.x + (b.x - a.x) * f) * sign,\n y: a.y + (b.y - a.y) * f,\n rotation: (a.r + (b.r - a.r) * f) * sign,\n scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t),\n opacity: gsap.utils.clamp(0, 1, SLOTS.length - t),\n zIndex: 100 - Math.round(t * 10),\n };\n};\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n src.includes('images.unsplash.com')\n ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n {\n title: 'Nightfall',\n tag: 'Astronomy app',\n meta: 'Stellar Co. · 2026',\n image: photo('1519681393784-d120267933ba'),\n },\n {\n title: 'Chroma Identity',\n tag: 'Brand identity',\n meta: 'Chroma Studio · 2026',\n image: photo('1541701494587-cb58502866ab'),\n },\n {\n title: 'Alpine Lodge',\n tag: 'Hospitality',\n meta: 'Moraine Hotels · 2026',\n image: photo('1493246507139-91e8fad9978e'),\n },\n {\n title: 'Metro Pulse',\n tag: 'Data visualisation',\n meta: 'City of Lakes · 2025',\n image: photo('1477959858617-67f85cf4f1df'),\n },\n {\n title: 'Orbit Motion',\n tag: '3D & motion',\n meta: 'Orbit Labs · 2025',\n image: photo('1618005198919-d3d4b5a92ead'),\n },\n {\n title: 'Open Road',\n tag: 'Campaign film',\n meta: 'Westward · 2025',\n image: photo('1500530855697-b586d89ba3ee'),\n },\n {\n title: 'Still Water',\n tag: 'Wellness app',\n meta: 'Calm Harbor · 2024',\n image: photo('1534447677768-be436bb09401'),\n },\n {\n title: 'Lago Retreats',\n tag: 'Travel platform',\n meta: 'Lago Travel · 2024',\n image: photo('1501785888041-af3ef285b470'),\n },\n];\n\nconst STEP = cn(\n 'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white',\n 'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none'\n);\nconst FADE =\n 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n \n \n \n);\n\nconst PauseIcon = () => (\n \n \n \n \n);\n\nconst PlayIcon = () => (\n \n \n \n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n \n \n \n);\n\nconst ArcCarousel = ({\n items = DEFAULT_ITEMS,\n label = 'Featured projects',\n autoplay = true,\n interval = 5000,\n className,\n ...props\n}: ArcCarouselProps) => {\n const count = items.length;\n const [position, setPosition] = useState(0);\n const [playing, setPlaying] = useState(autoplay);\n const [held, setHeld] = useState(false);\n const rootRef = useRef(null);\n const ringRef = useRef(null);\n const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n const track = useRef({ pos: 0 });\n const clockRef = useRef(null);\n const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n const holds = useRef({ hover: false, focus: false, offscreen: false });\n\n const current = count ? gsap.utils.wrap(0, count, position) : 0;\n const active = items[current];\n const offsetOf = (index: number, pos: number) =>\n gsap.utils.wrap(-count / 2, count / 2, index - pos);\n\n const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n // `playing` is the viewer's choice; a hold is a pause they did not ask for\n const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n holds.current[key] = on;\n setHeld(Object.values(holds.current).some(Boolean));\n }, []);\n\n // Every card is placed from one number, so a step is a single tween and the wrap needs no copies\n useGSAP(\n () => {\n const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null);\n const render = () =>\n cards.forEach((card, i) => {\n const s = slot(offsetOf(i, track.current.pos));\n gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' });\n });\n\n gsap.to(track.current, {\n pos: position,\n duration: prefersReducedMotion() ? 0 : MOVE_DURATION,\n ease: MOVE_EASE,\n overwrite: true,\n onUpdate: render,\n });\n },\n { scope: rootRef, dependencies: [position, count] }\n );\n\n // The media query is client-only, so reduced motion applies after hydration\n useEffect(() => {\n setPlaying(autoplay && !prefersReducedMotion());\n }, [autoplay]);\n\n // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it\n useGSAP(\n () => {\n const ring = ringRef.current;\n if (!ring || !playing || count < 2) return;\n clockRef.current = gsap.fromTo(\n ring,\n // The attribute, not the CSS property, so the dash stays in `pathLength` units\n { attr: { 'stroke-dashoffset': 1 } },\n {\n attr: { 'stroke-dashoffset': 0 },\n duration: interval / 1000,\n ease: 'none',\n onComplete: () => step(1),\n }\n );\n },\n { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true }\n );\n\n useEffect(() => {\n if (held) clockRef.current?.pause();\n else clockRef.current?.resume();\n }, [held, position, playing]);\n\n useGSAP(\n () => {\n if (!autoplay) return;\n ScrollTrigger.create({\n trigger: rootRef.current,\n onToggle: (self) => hold('offscreen', !self.isActive),\n });\n },\n { scope: rootRef, dependencies: [autoplay, hold] }\n );\n\n // A swipe can end outside the list\n useEffect(() => {\n const onUp = (event: PointerEvent) => {\n const s = swipe.current;\n if (!s.tracking) return;\n s.tracking = false;\n const dx = event.clientX - s.x;\n if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) {\n s.swiped = true;\n step(dx < 0 ? 1 : -1);\n }\n };\n const onCancel = () => {\n swipe.current.tracking = false;\n };\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n return () => {\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n };\n }, [step]);\n\n // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n const onClickCapture = (event: MouseEvent) => {\n if (swipe.current.swiped) {\n swipe.current.swiped = false;\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n if (event.detail === 0) return;\n const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node));\n const offset = index < 0 ? 0 : Math.round(offsetOf(index, position));\n if (offset !== 0) {\n event.preventDefault();\n event.stopPropagation();\n step(Math.sign(offset));\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n event.preventDefault();\n step(event.key === 'ArrowRight' ? 1 : -1);\n };\n\n return (\n \n \n\n {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n event.pointerType !== 'touch' && hold('hover', true)}\n onPointerLeave={() => hold('hover', false)}\n >\n
\n {\n swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n }}\n onDragStart={(event) => event.preventDefault()}\n onClickCapture={onClickCapture}\n onKeyDown={onKeyDown}\n onFocus={(event) => {\n let visible = true;\n try {\n visible = event.target.matches(':focus-visible');\n } catch {\n // No :focus-visible support: treat every focus as visible\n }\n if (visible) hold('focus', true);\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n hold('focus', false);\n }\n }}\n >\n {items.map((item, i) => {\n const isActive = i === current;\n const Card = item.href ? 'a' : 'div';\n // Also set in markup, so the arc is in place before hydration\n const first = slot(offsetOf(i, 0));\n return (\n {\n cardRefs.current[i] = el;\n }}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}: ${item.title}`}\n aria-hidden={isActive ? undefined : true}\n data-active={isActive ? '' : undefined}\n className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n style={{\n transform: `translate(${first.x}px, ${first.y}px) rotate(${first.rotation}deg) scale(${first.scale})`,\n opacity: first.opacity,\n zIndex: first.zIndex,\n }}\n >\n \n \n \n \n\n \n {item.tag ? (\n \n {item.tag}\n \n ) : (\n \n )}\n \n {pad2(i + 1)}\n / {pad2(count)}\n \n \n\n \n \n \n {item.title}\n \n {item.meta && (\n {item.meta}\n )}\n \n {item.href && (\n \n \n \n )}\n \n\n \n \n
\n );\n })}\n
\n
\n \n\n
\n step(-1)}\n >\n \n \n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n \n \n \n \n )}\n
\n\n {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n

\n {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n

\n\n
\n
\n \n );\n};\n\nexport default ArcCarousel;\n", + "codeHtml": "
'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  type MouseEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ArcCarouselItem {\n  title: string;\n  image: string;\n  /** Chip on the top left of the card. */\n  tag?: string;\n  /** Line under the title, e.g. a client and year or a price. */\n  meta?: string;\n  href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n  /** Eight or more keep the wrap seamless. */\n  items?: ArcCarouselItem[];\n  /** Accessible name for the carousel. */\n  label?: string;\n  /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n  autoplay?: boolean;\n  /** Time each card holds the centre while autoplaying, in ms. */\n  interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n  { x: 0, y: 0, r: 0 },\n  { x: 359, y: 26, r: 8.77 },\n  { x: 711, y: 113, r: 18.83 },\n  { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst MOVE_DURATION = 0.8;\nconst MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1');\nconst SWIPE = 50;\n\n/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */\nconst slot = (offset: number) => {\n  const t = Math.abs(offset);\n  const sign = Math.sign(offset);\n  const i = Math.min(Math.floor(t), SLOTS.length - 2);\n  const a = SLOTS[i];\n  const b = SLOTS[i + 1];\n  const f = t - i;\n  return {\n    x: (a.x + (b.x - a.x) * f) * sign,\n    y: a.y + (b.y - a.y) * f,\n    rotation: (a.r + (b.r - a.r) * f) * sign,\n    scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t),\n    opacity: gsap.utils.clamp(0, 1, SLOTS.length - t),\n    zIndex: 100 - Math.round(t * 10),\n  };\n};\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n  `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n  src.includes('images.unsplash.com')\n    ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n    : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n  {\n    title: 'Nightfall',\n    tag: 'Astronomy app',\n    meta: 'Stellar Co. · 2026',\n    image: photo('1519681393784-d120267933ba'),\n  },\n  {\n    title: 'Chroma Identity',\n    tag: 'Brand identity',\n    meta: 'Chroma Studio · 2026',\n    image: photo('1541701494587-cb58502866ab'),\n  },\n  {\n    title: 'Alpine Lodge',\n    tag: 'Hospitality',\n    meta: 'Moraine Hotels · 2026',\n    image: photo('1493246507139-91e8fad9978e'),\n  },\n  {\n    title: 'Metro Pulse',\n    tag: 'Data visualisation',\n    meta: 'City of Lakes · 2025',\n    image: photo('1477959858617-67f85cf4f1df'),\n  },\n  {\n    title: 'Orbit Motion',\n    tag: '3D & motion',\n    meta: 'Orbit Labs · 2025',\n    image: photo('1618005198919-d3d4b5a92ead'),\n  },\n  {\n    title: 'Open Road',\n    tag: 'Campaign film',\n    meta: 'Westward · 2025',\n    image: photo('1500530855697-b586d89ba3ee'),\n  },\n  {\n    title: 'Still Water',\n    tag: 'Wellness app',\n    meta: 'Calm Harbor · 2024',\n    image: photo('1534447677768-be436bb09401'),\n  },\n  {\n    title: 'Lago Retreats',\n    tag: 'Travel platform',\n    meta: 'Lago Travel · 2024',\n    image: photo('1501785888041-af3ef285b470'),\n  },\n];\n\nconst STEP = cn(\n  'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white',\n  'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none'\n);\nconst FADE =\n  'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n  <svg\n    viewBox=\"0 0 9 16\"\n    aria-hidden=\"true\"\n    className={cn('h-4 w-[8.5px]', flip && 'rotate-180')}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M7.5 1.5 1.5 8l6 6.5\" />\n  </svg>\n);\n\nconst PauseIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <rect x=\"3.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n    <rect x=\"9.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n  </svg>\n);\n\nconst PlayIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <path d=\"M4.5 2.9v10.2a1 1 0 0 0 1.53.85l8.1-5.1a1 1 0 0 0 0-1.7l-8.1-5.1A1 1 0 0 0 4.5 2.9Z\" />\n  </svg>\n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n  <svg\n    viewBox=\"0 0 16 16\"\n    aria-hidden=\"true\"\n    className={className}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M4.5 11.5 11.5 4.5M5.5 4.5h6v6\" />\n  </svg>\n);\n\nconst ArcCarousel = ({\n  items = DEFAULT_ITEMS,\n  label = 'Featured projects',\n  autoplay = true,\n  interval = 5000,\n  className,\n  ...props\n}: ArcCarouselProps) => {\n  const count = items.length;\n  const [position, setPosition] = useState(0);\n  const [playing, setPlaying] = useState(autoplay);\n  const [held, setHeld] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  const ringRef = useRef<SVGCircleElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const track = useRef({ pos: 0 });\n  const clockRef = useRef<gsap.core.Tween | null>(null);\n  const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n  const holds = useRef({ hover: false, focus: false, offscreen: false });\n\n  const current = count ? gsap.utils.wrap(0, count, position) : 0;\n  const active = items[current];\n  const offsetOf = (index: number, pos: number) =>\n    gsap.utils.wrap(-count / 2, count / 2, index - pos);\n\n  const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n  // `playing` is the viewer's choice; a hold is a pause they did not ask for\n  const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n    holds.current[key] = on;\n    setHeld(Object.values(holds.current).some(Boolean));\n  }, []);\n\n  // Every card is placed from one number, so a step is a single tween and the wrap needs no copies\n  useGSAP(\n    () => {\n      const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null);\n      const render = () =>\n        cards.forEach((card, i) => {\n          const s = slot(offsetOf(i, track.current.pos));\n          gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' });\n        });\n\n      gsap.to(track.current, {\n        pos: position,\n        duration: prefersReducedMotion() ? 0 : MOVE_DURATION,\n        ease: MOVE_EASE,\n        overwrite: true,\n        onUpdate: render,\n      });\n    },\n    { scope: rootRef, dependencies: [position, count] }\n  );\n\n  // The media query is client-only, so reduced motion applies after hydration\n  useEffect(() => {\n    setPlaying(autoplay && !prefersReducedMotion());\n  }, [autoplay]);\n\n  // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it\n  useGSAP(\n    () => {\n      const ring = ringRef.current;\n      if (!ring || !playing || count < 2) return;\n      clockRef.current = gsap.fromTo(\n        ring,\n        // The attribute, not the CSS property, so the dash stays in `pathLength` units\n        { attr: { 'stroke-dashoffset': 1 } },\n        {\n          attr: { 'stroke-dashoffset': 0 },\n          duration: interval / 1000,\n          ease: 'none',\n          onComplete: () => step(1),\n        }\n      );\n    },\n    { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true }\n  );\n\n  useEffect(() => {\n    if (held) clockRef.current?.pause();\n    else clockRef.current?.resume();\n  }, [held, position, playing]);\n\n  useGSAP(\n    () => {\n      if (!autoplay) return;\n      ScrollTrigger.create({\n        trigger: rootRef.current,\n        onToggle: (self) => hold('offscreen', !self.isActive),\n      });\n    },\n    { scope: rootRef, dependencies: [autoplay, hold] }\n  );\n\n  // A swipe can end outside the list\n  useEffect(() => {\n    const onUp = (event: PointerEvent) => {\n      const s = swipe.current;\n      if (!s.tracking) return;\n      s.tracking = false;\n      const dx = event.clientX - s.x;\n      if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) {\n        s.swiped = true;\n        step(dx < 0 ? 1 : -1);\n      }\n    };\n    const onCancel = () => {\n      swipe.current.tracking = false;\n    };\n    window.addEventListener('pointerup', onUp);\n    window.addEventListener('pointercancel', onCancel);\n    return () => {\n      window.removeEventListener('pointerup', onUp);\n      window.removeEventListener('pointercancel', onCancel);\n    };\n  }, [step]);\n\n  // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n  const onClickCapture = (event: MouseEvent<HTMLDivElement>) => {\n    if (swipe.current.swiped) {\n      swipe.current.swiped = false;\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (event.detail === 0) return;\n    const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node));\n    const offset = index < 0 ? 0 : Math.round(offsetOf(index, position));\n    if (offset !== 0) {\n      event.preventDefault();\n      event.stopPropagation();\n      step(Math.sign(offset));\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n    event.preventDefault();\n    step(event.key === 'ArrowRight' ? 1 : -1);\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      aria-label={label}\n      className={cn(\n        '@container relative isolate flex w-full flex-col items-center gap-4 overflow-hidden bg-[#03110f] py-16 text-white',\n        '@max-[48rem]:gap-8 @max-[48rem]:py-12',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-[62%] left-1/2 -z-10 size-[min(120cqw,70rem)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,#045f64_45%,transparent),color-mix(in_oklch,#045f64_10%,transparent)_55%,transparent)]\"\n      />\n\n      {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n      <div\n        className=\"relative isolate z-1 h-150 w-78 @max-[64rem]:h-134 @max-[48rem]:h-72.5\"\n        onPointerEnter={(event) => event.pointerType !== 'touch' && hold('hover', true)}\n        onPointerLeave={() => hold('hover', false)}\n      >\n        <div className=\"absolute inset-0 origin-[50%_0] @max-[64rem]:scale-80 @max-[48rem]:scale-62\">\n          <div\n            role=\"group\"\n            tabIndex={0}\n            aria-roledescription=\"carousel\"\n            aria-label={label}\n            className=\"absolute top-16 left-0 h-107.5 w-78 touch-pan-y rounded-[8px] outline-none select-none focus-visible:ring-2 focus-visible:ring-[#c6f56f] focus-visible:ring-offset-8 focus-visible:ring-offset-[#03110f] @max-[48rem]:top-2\"\n            onPointerDown={(event) => {\n              swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n            }}\n            onDragStart={(event) => event.preventDefault()}\n            onClickCapture={onClickCapture}\n            onKeyDown={onKeyDown}\n            onFocus={(event) => {\n              let visible = true;\n              try {\n                visible = event.target.matches(':focus-visible');\n              } catch {\n                // No :focus-visible support: treat every focus as visible\n              }\n              if (visible) hold('focus', true);\n            }}\n            onBlur={(event) => {\n              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n                hold('focus', false);\n              }\n            }}\n          >\n            {items.map((item, i) => {\n              const isActive = i === current;\n              const Card = item.href ? 'a' : 'div';\n              // Also set in markup, so the arc is in place before hydration\n              const first = slot(offsetOf(i, 0));\n              return (\n                <div\n                  key={`${item.title}-${i}`}\n                  ref={(el) => {\n                    cardRefs.current[i] = el;\n                  }}\n                  role=\"group\"\n                  aria-roledescription=\"slide\"\n                  aria-label={`${i + 1} of ${count}: ${item.title}`}\n                  aria-hidden={isActive ? undefined : true}\n                  data-active={isActive ? '' : undefined}\n                  className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n                  style={{\n                    transform: `translate(${first.x}px, ${first.y}px) rotate(${first.rotation}deg) scale(${first.scale})`,\n                    opacity: first.opacity,\n                    zIndex: first.zIndex,\n                  }}\n                >\n                  <Card\n                    {...(item.href ? { href: item.href } : {})}\n                    tabIndex={item.href && isActive ? 0 : -1}\n                    draggable={false}\n                    className={cn(\n                      'relative block size-full overflow-hidden rounded-[8px] bg-[#071d1b]',\n                      'shadow-[0_30px_60px_-24px_rgb(0_0_0/0.75)]',\n                      'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]'\n                    )}\n                  >\n                    <img\n                      src={item.image}\n                      srcSet={srcSetOf(item.image)}\n                      alt=\"\"\n                      width={312}\n                      height={430}\n                      draggable={false}\n                      decoding=\"async\"\n                      loading={Math.abs(offsetOf(i, 0)) <= 2 ? 'eager' : 'lazy'}\n                      className=\"absolute inset-0 size-full object-cover\"\n                    />\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-0 bg-[#03110f]/40 transition-opacity duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-active/card:opacity-0 motion-reduce:transition-none\"\n                    />\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-x-0 bottom-0 h-3/5 bg-linear-to-t from-[#03110f] via-[#03110f]/60 to-transparent\"\n                    />\n\n                    <span className=\"absolute inset-x-4 top-4 flex items-center justify-between gap-3\">\n                      {item.tag ? (\n                        <span className=\"truncate rounded-full bg-[#03110f]/70 px-3 py-1.5 text-[11px] leading-none font-medium tracking-[0.12em] text-white uppercase ring-1 ring-white/10\">\n                          {item.tag}\n                        </span>\n                      ) : (\n                        <span />\n                      )}\n                      <span className=\"shrink-0 text-xs font-medium text-white/70 tabular-nums\">\n                        {pad2(i + 1)}\n                        <span className=\"text-white/35\"> / {pad2(count)}</span>\n                      </span>\n                    </span>\n\n                    <span className=\"absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-5\">\n                      <span className=\"grid min-w-0 gap-1.5\">\n                        <span className=\"truncate text-[22px] leading-tight font-semibold tracking-[-0.025em] text-white\">\n                          {item.title}\n                        </span>\n                        {item.meta && (\n                          <span className=\"truncate text-sm text-[#9fd4d6]/80\">{item.meta}</span>\n                        )}\n                      </span>\n                      {item.href && (\n                        <span\n                          aria-hidden=\"true\"\n                          className={cn(\n                            'grid size-11 shrink-0 place-items-center rounded-full bg-white/10 text-white ring-1 ring-white/20',\n                            'transition-[background-color,color,box-shadow] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none',\n                            'group-data-active/card:bg-[#c6f56f] group-data-active/card:text-[#03110f] group-data-active/card:ring-transparent'\n                          )}\n                        >\n                          <ArrowUpRight className=\"size-4 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] group-hover/card:rotate-45 motion-reduce:transition-none\" />\n                        </span>\n                      )}\n                    </span>\n\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"pointer-events-none absolute inset-0 rounded-[8px] ring-1 ring-white/10 transition-shadow duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] ring-inset group-data-active/card:ring-2 group-data-active/card:ring-[#c6f56f]/80 motion-reduce:transition-none\"\n                    />\n                  </Card>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"relative z-3 flex items-center gap-2\">\n        <button\n          className={STEP}\n          type=\"button\"\n          aria-label=\"Previous project\"\n          onClick={() => step(-1)}\n        >\n          <Caret />\n        </button>\n        <button className={STEP} type=\"button\" aria-label=\"Next project\" onClick={() => step(1)}>\n          <Caret flip />\n        </button>\n        {autoplay && (\n          <button\n            className={cn(STEP, 'relative ml-2')}\n            type=\"button\"\n            aria-label={playing ? 'Pause autoplay' : 'Start autoplay'}\n            onClick={() => setPlaying((on) => !on)}\n          >\n            {playing ? <PauseIcon /> : <PlayIcon />}\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 44 44\"\n              className={cn(\n                'pointer-events-none absolute -inset-px -rotate-90 transition-opacity duration-300',\n                playing ? 'opacity-100' : 'opacity-0'\n              )}\n            >\n              <circle\n                ref={ringRef}\n                cx=\"22\"\n                cy=\"22\"\n                r=\"21\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                pathLength={1}\n                strokeDasharray=\"1\"\n                strokeDashoffset=\"1\"\n                className=\"text-[#c6f56f]\"\n              />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n      <p aria-live={playing && !held ? 'off' : 'polite'} className=\"sr-only\">\n        {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n      </p>\n\n      <div aria-hidden=\"true\" className={cn(FADE, 'left-0 bg-linear-to-r')} />\n      <div aria-hidden=\"true\" className={cn(FADE, 'right-0 bg-linear-to-l')} />\n    </section>\n  );\n};\n\nexport default ArcCarousel;\n
" } } }; diff --git a/apps/docs/registry/registry-blocks.ts b/apps/docs/registry/registry-blocks.ts index 75d5f47..4617713 100644 --- a/apps/docs/registry/registry-blocks.ts +++ b/apps/docs/registry/registry-blocks.ts @@ -1118,18 +1118,11 @@ export default function Example() { addedAt: '2026-10-14', title: 'Arc Carousel', description: - 'A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline, and a card that wraps past the edge keeps travelling off-screen while it comes back in from the far side. Swipe, drag, the arrow keys or a click on a side card all step it.', + 'A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline; cards fade out past the third slot and wrap round unseen. Swipe, drag, the arrow keys or a click on a side card all step it, and it autoplays with a pause button. GSAP with ScrollTrigger.', media: {}, - dependencies: [], + dependencies: ['gsap', '@gsap/react'], registryDependencies: [], - cssVars: { - '--color-tween-teal': '#045f64', - '--color-tween-teal-soft': '#9fd4d6', - '--color-tween-lime': '#c6f56f', - '--color-tween-ink': '#071d1b', - '--color-tween-ink-deep': '#03110f', - '--ease-tween': 'cubic-bezier(0.32, 0.72, 0, 1)', - }, + cssVars: {}, files: [ { path: 'registry/tweenui/arc-carousel.tsx', @@ -1151,6 +1144,6 @@ export default function Example() { return ; }`, }, - docs: 'Colors come from the Tween UI theme tokens (`tween-ink-deep`, `tween-lime`, …), which the CLI adds to your `@theme`. Pass your own `items`; seven or more keep the arc full, since three cards show on each side of the centre.', + docs: 'Pass your own `items`; eight or more keep the arc full and the wrap unseen. Colors are inline Tailwind classes, so there is nothing to add to your theme.', }, ]; diff --git a/apps/docs/registry/registry-ui.ts b/apps/docs/registry/registry-ui.ts index aed5482..c0e4581 100644 --- a/apps/docs/registry/registry-ui.ts +++ b/apps/docs/registry/registry-ui.ts @@ -877,9 +877,9 @@ export default function Example() { addedAt: '2026-10-14', title: 'SVG Draw', description: - 'Wrap any inline SVG and it draws itself in: every path, line, circle, rect and polygon traces its outline, one after another, then its fill fades in. Outline icons draw in their own stroke, filled logos are traced and then filled, and the SVG is handed back exactly as written once it settles. Plays when it scrolls into view or on mount; plain CSS transitions, no animation library.', + 'Wrap any inline SVG and it draws itself in: every path, line, circle, rect and polygon traces its outline, one after another, then its fill fades in. Outline icons draw in their own stroke, filled logos are traced in a thin line that fades as they fill. Plays when it scrolls into view or on mount. GSAP DrawSVG and ScrollTrigger.', media: {}, - dependencies: [], + dependencies: ['gsap', '@gsap/react'], registryDependencies: [], cssVars: {}, files: [ diff --git a/apps/docs/registry/tweenui/arc-carousel.tsx b/apps/docs/registry/tweenui/arc-carousel.tsx index 2af1a2a..8428520 100644 --- a/apps/docs/registry/tweenui/arc-carousel.tsx +++ b/apps/docs/registry/tweenui/arc-carousel.tsx @@ -3,21 +3,23 @@ import { useCallback, useEffect, - useId, - useLayoutEffect, useRef, useState, type ComponentPropsWithoutRef, type KeyboardEvent, type MouseEvent, } from 'react'; +import { useGSAP } from '@gsap/react'; +import gsap from 'gsap'; +import { CustomEase } from 'gsap/CustomEase'; +import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { cn } from '@/lib/utils'; +gsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger); + const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; -const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; - export interface ArcCarouselItem { title: string; image: string; @@ -29,11 +31,10 @@ export interface ArcCarouselItem { } export interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> { + /** Eight or more keep the wrap seamless. */ items?: ArcCarouselItem[]; - badge?: string; - title?: string; - description?: string; - cta?: { label: string; href: string } | null; + /** Accessible name for the carousel. */ + label?: string; /** Step on its own; holds on hover, keyboard focus and while off-screen. */ autoplay?: boolean; /** Time each card holds the centre while autoplaying, in ms. */ @@ -48,38 +49,28 @@ const SLOTS = [ { x: 1033, y: 243, r: 25.05 }, ]; const ACTIVE_SCALE = 1.05; -const ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)'; -const MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease'; +const MOVE_DURATION = 0.8; +const MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1'); const SWIPE = 50; -const GHOST_LIFE = 850; -// Caps one frame of autoplay, so a backgrounded tab never skips ahead -const MAX_FRAME = 100; +/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */ const slot = (offset: number) => { const t = Math.abs(offset); const sign = Math.sign(offset); - const last = SLOTS.length - 1; - let p = SLOTS[Math.min(t, last)]; - if (t > last) { - const prev = SLOTS[last - 1]; - const n = t - last; - p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n }; - } + const i = Math.min(Math.floor(t), SLOTS.length - 2); + const a = SLOTS[i]; + const b = SLOTS[i + 1]; + const f = t - i; return { - transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`, - opacity: t <= last ? '1' : '0', - zIndex: String(100 - t), + x: (a.x + (b.x - a.x) * f) * sign, + y: a.y + (b.y - a.y) * f, + rotation: (a.r + (b.r - a.r) * f) * sign, + scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t), + opacity: gsap.utils.clamp(0, 1, SLOTS.length - t), + zIndex: 100 - Math.round(t * 10), }; }; -// Signed distance from the centre, the shortest way round -const offsetOf = (index: number, position: number, count: number) => { - let r = (((index - position) % count) + count) % count; - if (r > Math.floor(count / 2)) r -= count; - return r; -}; - -const wrap = (position: number, count: number) => ((position % count) + count) % count; const pad2 = (n: number) => String(n).padStart(2, '0'); const photo = (id: string, width = 640) => @@ -141,12 +132,12 @@ const DEFAULT_ITEMS: ArcCarouselItem[] = [ ]; const STEP = cn( - 'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white', - 'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep', - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none' + 'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white', + 'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none' ); const FADE = - 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent'; + 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent'; const Caret = ({ flip }: { flip?: boolean }) => ( ( const ArcCarousel = ({ items = DEFAULT_ITEMS, - badge = 'Featured work', - title = 'Projects we shipped this season.', - description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.', - cta = { label: 'View all projects', href: '#' }, + label = 'Featured projects', autoplay = true, interval = 5000, className, @@ -207,16 +195,19 @@ const ArcCarousel = ({ const [playing, setPlaying] = useState(autoplay); const [held, setHeld] = useState(false); const rootRef = useRef(null); - const ghostsRef = useRef(null); const ringRef = useRef(null); - const itemRefs = useRef<(HTMLDivElement | null)[]>([]); - const artRefs = useRef<(HTMLDivElement | null)[]>([]); - const layout = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] }); + const cardRefs = useRef<(HTMLDivElement | null)[]>([]); + const track = useRef({ pos: 0 }); + const clockRef = useRef(null); const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 }); const holds = useRef({ hover: false, focus: false, offscreen: false }); - const progress = useRef(0); - const titleId = useId(); - const descriptionId = useId(); + + const current = count ? gsap.utils.wrap(0, count, position) : 0; + const active = items[current]; + const offsetOf = (index: number, pos: number) => + gsap.utils.wrap(-count / 2, count / 2, index - pos); + + const step = useCallback((direction: number) => setPosition((p) => p + direction), []); // `playing` is the viewer's choice; a hold is a pause they did not ask for const hold = useCallback((key: keyof typeof holds.current, on: boolean) => { @@ -224,119 +215,67 @@ const ArcCarousel = ({ setHeld(Object.values(holds.current).some(Boolean)); }, []); - const place = (item: HTMLElement, offset: number) => { - const s = slot(offset); - item.style.transform = s.transform; - item.style.opacity = s.opacity; - item.style.zIndex = s.zIndex; - item.style.pointerEvents = s.opacity === '1' ? '' : 'none'; - }; - - useIsomorphicLayoutEffect(() => { - const ghosts = ghostsRef.current; - if (!ghosts || !count) return; - const reduced = prefersReducedMotion(); - const move = reduced ? 'opacity 0.3s ease' : MOVE; - const art = reduced ? 'none' : ART; - const st = layout.current; - const animate = st.offsets.length > 0; - const delta = animate ? position - st.last : 0; - st.last = position; - - itemRefs.current.forEach((item, i) => { - if (!item) return; - const next = offsetOf(i, position, count); - const prev = st.offsets[i]; - - const scaleEl = artRefs.current[i]; - if (scaleEl) { - const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)'; - scaleEl.style.transition = animate ? art : 'none'; - scaleEl.style.transform = scale; - if (!animate) { - void scaleEl.offsetWidth; - scaleEl.style.transition = art; - } - } - - if (!animate || prev === undefined) { - item.style.transition = 'none'; - place(item, next); - } else if (prev - delta !== next) { - // Wrapped round: a copy carries on off this edge while the card enters from the other - const last = SLOTS.length - 1; - const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1)); - const ghost = item.cloneNode(true) as HTMLElement; - ghost.setAttribute('aria-hidden', 'true'); - ghost.inert = true; - ghost.style.pointerEvents = 'none'; - ghost.style.transition = 'none'; - ghosts.appendChild(ghost); - const out = slot(prev - delta); - void ghost.offsetWidth; - ghost.style.transition = move; - ghost.style.transform = out.transform; - ghost.style.opacity = '0'; - st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE)); - - item.style.transition = 'none'; - place(item, entry); - item.style.opacity = '0'; - void item.offsetWidth; - item.style.transition = move; - place(item, next); - } else { - item.style.transition = move; - place(item, next); - } - st.offsets[i] = next; - }); - }, [position, count]); - - useEffect(() => { - const { ghosts } = layout.current; - return () => ghosts.forEach((id) => window.clearTimeout(id)); - }, []); - - const step = useCallback((direction: number) => setPosition((p) => p + direction), []); + // Every card is placed from one number, so a step is a single tween and the wrap needs no copies + useGSAP( + () => { + const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null); + const render = () => + cards.forEach((card, i) => { + const s = slot(offsetOf(i, track.current.pos)); + gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' }); + }); + + gsap.to(track.current, { + pos: position, + duration: prefersReducedMotion() ? 0 : MOVE_DURATION, + ease: MOVE_EASE, + overwrite: true, + onUpdate: render, + }); + }, + { scope: rootRef, dependencies: [position, count] } + ); // The media query is client-only, so reduced motion applies after hydration useEffect(() => { setPlaying(autoplay && !prefersReducedMotion()); }, [autoplay]); - useEffect(() => { - progress.current = 0; - ringRef.current?.style.setProperty('stroke-dashoffset', '1'); - }, [position, playing]); - - useEffect(() => { - if (!playing || held || count < 2) return; - let frame = 0; - let last = performance.now(); - const tick = (now: number) => { - progress.current += Math.min(now - last, MAX_FRAME) / interval; - last = now; - if (progress.current >= 1) { - progress.current = 0; - step(1); - } - ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current)); - frame = requestAnimationFrame(tick); - }; - frame = requestAnimationFrame(tick); - return () => cancelAnimationFrame(frame); - }, [playing, held, interval, count, step]); + // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it + useGSAP( + () => { + const ring = ringRef.current; + if (!ring || !playing || count < 2) return; + clockRef.current = gsap.fromTo( + ring, + // The attribute, not the CSS property, so the dash stays in `pathLength` units + { attr: { 'stroke-dashoffset': 1 } }, + { + attr: { 'stroke-dashoffset': 0 }, + duration: interval / 1000, + ease: 'none', + onComplete: () => step(1), + } + ); + }, + { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true } + ); useEffect(() => { - const root = rootRef.current; - if (!autoplay || !root || typeof IntersectionObserver === 'undefined') return; - const observer = new IntersectionObserver(([entry]) => - hold('offscreen', !entry.isIntersecting) - ); - observer.observe(root); - return () => observer.disconnect(); - }, [autoplay, hold]); + if (held) clockRef.current?.pause(); + else clockRef.current?.resume(); + }, [held, position, playing]); + + useGSAP( + () => { + if (!autoplay) return; + ScrollTrigger.create({ + trigger: rootRef.current, + onToggle: (self) => hold('offscreen', !self.isActive), + }); + }, + { scope: rootRef, dependencies: [autoplay, hold] } + ); // A swipe can end outside the list useEffect(() => { @@ -345,8 +284,7 @@ const ArcCarousel = ({ if (!s.tracking) return; s.tracking = false; const dx = event.clientX - s.x; - const dy = event.clientY - s.y; - if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) { + if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) { s.swiped = true; step(dx < 0 ? 1 : -1); } @@ -364,17 +302,15 @@ const ArcCarousel = ({ // After a swipe, or on a side card, a click steps the arc instead of following the card's link const onClickCapture = (event: MouseEvent) => { - const s = swipe.current; - if (s.swiped) { - s.swiped = false; + if (swipe.current.swiped) { + swipe.current.swiped = false; event.preventDefault(); event.stopPropagation(); return; } if (event.detail === 0) return; - const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node)); - if (index < 0) return; - const offset = offsetOf(index, position, count); + const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node)); + const offset = index < 0 ? 0 : Math.round(offsetOf(index, position)); if (offset !== 0) { event.preventDefault(); event.stopPropagation(); @@ -388,49 +324,22 @@ const ArcCarousel = ({ step(event.key === 'ArrowRight' ? 1 : -1); }; - const current = count ? wrap(position, count) : 0; - const active = items[current]; - return (
@@ -592,19 +485,6 @@ const ArcCarousel = ({ > - {cta && ( - - {cta.label} - - - )} @@ -635,7 +515,7 @@ const ArcCarousel = ({ pathLength={1} strokeDasharray="1" strokeDashoffset="1" - className="text-tween-lime" + className="text-[#c6f56f]" /> diff --git a/apps/docs/registry/tweenui/svg-draw.tsx b/apps/docs/registry/tweenui/svg-draw.tsx index ae324db..f716225 100644 --- a/apps/docs/registry/tweenui/svg-draw.tsx +++ b/apps/docs/registry/tweenui/svg-draw.tsx @@ -1,138 +1,47 @@ 'use client'; -import { useEffect, useLayoutEffect, useRef, type ComponentPropsWithoutRef } from 'react'; +import { useEffect, useRef, type ComponentPropsWithoutRef } from 'react'; +import { useGSAP } from '@gsap/react'; +import gsap from 'gsap'; +import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin'; +import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { cn } from '@/lib/utils'; +gsap.registerPlugin(useGSAP, DrawSVGPlugin, ScrollTrigger); + const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; -const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; - -export const SVG_DRAW_EASES = { - 'power1.out': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', - 'power2.out': 'cubic-bezier(0.22, 0.61, 0.36, 1)', - 'power3.out': 'cubic-bezier(0.16, 1, 0.3, 1)', - 'expo.out': 'cubic-bezier(0.19, 1, 0.22, 1)', - 'in-out': 'cubic-bezier(0.65, 0, 0.35, 1)', - linear: 'linear', -} as const; - -export type SvgDrawEase = keyof typeof SVG_DRAW_EASES; - -const FADE_MS = 400; -const SHAPES = new Set(['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect']); -// Shapes inside these are never painted where they sit, so there is nothing to draw -const SKIP = new Set([ - 'defs', - 'clippath', - 'mask', - 'pattern', - 'marker', - 'symbol', - 'lineargradient', - 'radialgradient', - 'filter', - 'foreignobject', -]); - -const shapesIn = (node: Element): SVGGeometryElement[] => - Array.from(node.children).flatMap((child) => { - const tag = child.tagName.toLowerCase(); - if (SKIP.has(tag)) return []; - return SHAPES.has(tag) ? [child as SVGGeometryElement] : shapesIn(child); - }); - -const isClear = (paint: string) => - paint === 'none' || paint === 'transparent' || paint === 'rgba(0, 0, 0, 0)'; - -interface Shape { - el: SVGGeometryElement; - len: number; - pad: number; - stroked: boolean; - trace: string; - traceWidth: string; - fillOpacity: string; - inline: string | null; -} - -const measure = (el: SVGGeometryElement, width: number, color: string, ownColor: boolean) => { - const style = getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return null; - - let real = 0; - try { - real = el.getTotalLength(); - } catch { - return null; - } - // A `pathLength` attribute changes the units the dash is measured in - const own = parseFloat(el.getAttribute('pathLength') ?? ''); - const len = own > 0 ? own : real; - if (!(real > 0)) return null; - - // Unset paint computes to the SVG defaults: a black fill and no stroke - const fill = style.fill || 'rgb(0, 0, 0)'; - const stroke = style.stroke || 'none'; - const strokeWidth = parseFloat(style.strokeWidth || '1'); - const stroked = !isClear(stroke) && strokeWidth > 0; - if (!stroked && isClear(fill)) return null; - - // `width` is in screen px, the stroke in the shape's own units - const ctm = el.getScreenCTM?.(); - const scale = ctm ? Math.sqrt(Math.abs(ctm.a * ctm.d - ctm.b * ctm.c)) || 1 : 1; - const traceWidth = stroked ? strokeWidth : width / scale; - - return { - el, - len, - // Run the dash past the end, so a line cap never shows as a dot before it starts - pad: (traceWidth + 1) * (len / real), - stroked, - trace: stroked ? stroke : ownColor ? fill : color, - traceWidth: String(traceWidth), - fillOpacity: style.fillOpacity || '1', - inline: el.getAttribute('style'), - } satisfies Shape; -}; - -const restore = ({ el, inline }: Shape) => { - if (inline === null) el.removeAttribute('style'); - else el.setAttribute('style', inline); -}; +const SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect'; +// Shapes inside these are never painted where they sit +const UNPAINTED = 'defs, clipPath, mask, pattern, marker, symbol'; +const NO_PAINT = new Set(['none', 'transparent', 'rgba(0, 0, 0, 0)']); +const FILL_FADE = 0.5; export interface SvgDrawProps extends ComponentPropsWithoutRef<'div'> { /** Seconds each shape takes to draw. */ duration?: number; - /** Seconds before the first shape starts. */ delay?: number; /** Seconds between one shape starting and the next. */ stagger?: number; - /** Trace width in screen px for fill-only shapes. Stroked shapes keep their own. */ + /** Any GSAP ease. */ + ease?: string; + /** Width in screen px of the line that traces a fill-only shape. */ width?: number; - ease?: SvgDrawEase | (string & {}); - order?: 'longest' | 'reverse' | 'document'; - /** Trace color for fill-only shapes. Stroked shapes keep their own. */ - color?: string; - /** Trace fill-only shapes in their own fill color. */ - ownColor?: boolean; trigger?: 'view' | 'mount'; - /** With `trigger="view"`, how far down the viewport the SVG's top must rise, 0 to 1. */ - startAt?: number; + /** ScrollTrigger start, with `trigger="view"`. */ + start?: string; onComplete?: () => void; } const SvgDraw = ({ - duration = 1.6, + duration = 1.4, delay = 0, - stagger = 0.18, + stagger = 0.12, + ease = 'power2.inOut', width = 1.5, - ease = 'power1.out', - order = 'longest', - color = 'currentColor', - ownColor = false, trigger = 'view', - startAt = 0.85, + start = 'top 85%', onComplete, className, children, @@ -145,89 +54,49 @@ const SvgDraw = ({ onCompleteRef.current = onComplete; }, [onComplete]); - // Before paint, so the finished drawing never flashes up first - useIsomorphicLayoutEffect(() => { - const root = rootRef.current; - if (!root) return; - const reveal = () => { - root.style.visibility = 'visible'; - }; - if (prefersReducedMotion() || root.getClientRects().length === 0) return reveal(); - - const shapes = shapesIn(root) - .map((el) => measure(el, width, color, ownColor)) - .filter((shape): shape is Shape => shape !== null); - if (!shapes.length) return reveal(); - if (order === 'longest') shapes.sort((a, b) => b.len - a.len); - else if (order === 'reverse') shapes.reverse(); - - shapes.forEach(({ el, len, pad, stroked, trace, traceWidth }) => { - Object.assign(el.style, { - transition: 'none', - fillOpacity: '0', - stroke: trace, - strokeWidth: traceWidth, - strokeDasharray: `${len} ${len + pad * 2}`, - strokeDashoffset: String(len + pad), - ...(stroked ? {} : { strokeLinecap: 'round', strokeLinejoin: 'round' }), + useGSAP( + () => { + const root = rootRef.current; + if (!root) return; + gsap.set(root, { visibility: 'visible' }); + if (prefersReducedMotion()) return; + + const shapes = gsap.utils + .toArray(root.querySelectorAll(SHAPES)) + .filter((shape) => !shape.closest(UNPAINTED)); + if (!shapes.length) return; + + const painted = (shape: Element, paint: 'fill' | 'stroke') => + !NO_PAINT.has(getComputedStyle(shape)[paint]); + const filled = shapes.filter((shape) => painted(shape, 'fill')); + // Fill-only shapes get a thin line to draw with, which fades once they fill + const traced = filled.filter((shape) => !painted(shape, 'stroke')); + gsap.set(traced, { + stroke: 'currentColor', + strokeWidth: width, + vectorEffect: 'non-scaling-stroke', }); - }); - reveal(); - const ms = Math.max(0.01, duration) * 1000; - const start = Math.max(0, delay) * 1000; - const gap = Math.max(0, stagger) * 1000; - const curve = SVG_DRAW_EASES[ease as SvgDrawEase] ?? ease; - const timers: number[] = []; - - const play = () => { - void root.getBoundingClientRect(); - shapes.forEach((shape, i) => { - const wait = start + i * gap; - shape.el.style.transition = `stroke-dashoffset ${ms}ms ${curve} ${wait}ms`; - shape.el.style.strokeDashoffset = '0'; - - timers.push( - window.setTimeout(() => { - shape.el.style.transition = `fill-opacity ${FADE_MS}ms ease, stroke-opacity ${FADE_MS}ms ease`; - shape.el.style.fillOpacity = shape.fillOpacity; - if (!shape.stroked) shape.el.style.strokeOpacity = '0'; - }, wait + ms), - window.setTimeout(() => restore(shape), wait + ms + FADE_MS) - ); - }); - const end = start + (shapes.length - 1) * gap + ms + FADE_MS; - timers.push(window.setTimeout(() => onCompleteRef.current?.(), end)); - }; - - let observer: IntersectionObserver | undefined; - if (trigger === 'view' && typeof IntersectionObserver !== 'undefined') { - const margin = Math.round((1 - Math.min(1, Math.max(0, startAt))) * 100); - observer = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting) return; - observer?.disconnect(); - play(); - }, - { rootMargin: `0px 0px -${margin}% 0px` } - ); - observer.observe(root); - } else { - play(); + gsap + .timeline({ + delay, + scrollTrigger: trigger === 'view' ? { trigger: root, start, once: true } : undefined, + onComplete: () => onCompleteRef.current?.(), + }) + .from(shapes, { drawSVG: 0, duration, ease, stagger }) + .from(filled, { fillOpacity: 0, duration: FILL_FADE, stagger }, duration * 0.6) + .to(traced, { strokeOpacity: 0, duration: FILL_FADE, stagger }, '<'); + }, + { + scope: rootRef, + dependencies: [duration, delay, stagger, ease, width, trigger, start], + revertOnUpdate: true, } - - // Hand the SVG back as written, so a re-run measures the real thing - return () => { - observer?.disconnect(); - timers.forEach((id) => window.clearTimeout(id)); - shapes.forEach(restore); - }; - }, [duration, delay, stagger, width, ease, order, color, ownColor, trigger, startAt]); + ); return (
diff --git a/apps/docs/tests/arc-carousel.test.tsx b/apps/docs/tests/arc-carousel.test.tsx index 241c502..1d4bfbb 100644 --- a/apps/docs/tests/arc-carousel.test.tsx +++ b/apps/docs/tests/arc-carousel.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; +import gsap from 'gsap'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ArcCarousel, { type ArcCarouselItem } from '@/registry/tweenui/arc-carousel'; @@ -30,10 +31,10 @@ describe('Arc Carousel', () => { beforeEach(() => setReducedMotion(true)); afterEach(() => vi.restoreAllMocks()); - it('names the carousel after its title', () => { - render(); + it('names the carousel after its label', () => { + render(); - expect(screen.getByRole('heading', { name: 'Selected work' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Selected work' })).toBeInTheDocument(); const carousel = screen.getByRole('group', { name: 'Selected work' }); expect(carousel).toHaveAttribute('aria-roledescription', 'carousel'); }); @@ -60,7 +61,7 @@ describe('Arc Carousel', () => { }); it('steps with the arrow keys', () => { - render(); + render(); const carousel = screen.getByRole('group', { name: 'Work' }); fireEvent.keyDown(carousel, { key: 'ArrowRight' }); @@ -84,42 +85,42 @@ describe('Arc Carousel', () => { expect(announced()).toBe('2 of 8: Ben'); }); - it('places the cards on the arc before any interaction', () => { + it('places the cards on the arc in the markup, before GSAP takes over', () => { + // With motion on, GSAP's first frame is async, so this reads the server-rendered layout + setReducedMotion(false); const { container } = render(); const slides = container.querySelectorAll('[aria-roledescription="slide"]'); - expect(slides[0].style.transform).toBe('translate(0px, 0px) rotate(0deg)'); - expect(slides[1].style.transform).toBe('translate(359px, 26px) rotate(8.77deg)'); - expect(slides[7].style.transform).toBe('translate(-359px, 26px) rotate(-8.77deg)'); + expect(slides[0].style.transform).toBe('translate(0px, 0px) rotate(0deg) scale(1.05)'); + expect(slides[1].style.transform).toBe('translate(359px, 26px) rotate(8.77deg) scale(1)'); + expect(slides[7].style.transform).toBe('translate(-359px, 26px) rotate(-8.77deg) scale(1)'); // Four out is past the last slot, so it waits off-screen expect(slides[4].style.opacity).toBe('0'); }); - it('describes the section with its description, or drops it', () => { - const { rerender } = render(); - expect(screen.getByRole('region')).toHaveAccessibleDescription('Recent launches.'); - - rerender(); - expect(screen.queryByText('Recent launches.')).not.toBeInTheDocument(); - }); - - it('drops the badge and the button when asked', () => { - render(); + it('has no heading or call to action of its own', () => { + render(); - expect(screen.queryByText('Featured work')).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'View all projects' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /view all/i })).not.toBeInTheDocument(); }); describe('autoplay', () => { + // GSAP keeps its own clock: unhook it from the ticker and step it by hand + let now = 0; beforeEach(() => { setReducedMotion(false); - vi.useFakeTimers(); + gsap.ticker.remove(gsap.updateRoot); + now = gsap.ticker.time; + }); + afterEach(() => { + gsap.ticker.add(gsap.updateRoot); }); - afterEach(() => vi.useRealTimers()); const wait = (ms: number) => act(() => { - vi.advanceTimersByTime(ms); + now += ms / 1000; + gsap.updateRoot(now); }); it('is on by default', () => { @@ -147,6 +148,17 @@ describe('Arc Carousel', () => { expect(announced()).toBe('3 of 8: Cleo'); }); + it('fills the progress ring over the interval', () => { + const { container } = render(); + const ring = container.querySelector('button[aria-label="Pause autoplay"] circle')!; + const offset = () => Number(ring.getAttribute('stroke-dashoffset')); + + wait(10); + expect(offset()).toBeGreaterThan(0.95); + wait(1000); + expect(offset()).toBeCloseTo(0.5, 1); + }); + it('keeps the announcement quiet while it plays itself', () => { render(); const live = () => document.querySelector('[aria-live]')!.getAttribute('aria-live'); @@ -169,7 +181,7 @@ describe('Arc Carousel', () => { }); it('holds while the mouse rests on the cards', () => { - render(); + render(); const stage = screen.getByRole('group', { name: 'Work' }).parentElement!.parentElement!; fireEvent.pointerEnter(stage, { pointerType: 'mouse' }); diff --git a/apps/docs/tests/svg-draw.test.tsx b/apps/docs/tests/svg-draw.test.tsx index 3952160..cb9d0ef 100644 --- a/apps/docs/tests/svg-draw.test.tsx +++ b/apps/docs/tests/svg-draw.test.tsx @@ -1,4 +1,4 @@ -import { act, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SvgDraw from '@/registry/tweenui/svg-draw'; @@ -15,26 +15,8 @@ const setReducedMotion = (reduce: boolean) => { })) as unknown as typeof window.matchMedia; }; -// jsdom has no SVG geometry or layout: every shape is as long as its `data-len`, and on screen -const proto = window.SVGElement.prototype as unknown as { getTotalLength?: () => number }; -const LEN = (el: Element) => Number(el.getAttribute('data-len') ?? 100); - -/** An IntersectionObserver that only fires when the test says so. */ -let enterView: () => void = () => {}; -class ObserverStub { - constructor(private callback: IntersectionObserverCallback) { - enterView = () => - this.callback( - [{ isIntersecting: true } as IntersectionObserverEntry], - this as unknown as IntersectionObserver - ); - } - observe() {} - disconnect() {} -} - -const shape = (container: HTMLElement, id: string) => - container.querySelector(`#${id}`)!; +// jsdom has no SVG geometry; DrawSVG only needs a length and a box +const svgProto = window.SVGElement.prototype as unknown as Record; const Drawing = () => ( @@ -43,34 +25,27 @@ const Drawing = () => ( - - - - + + ); +const shape = (container: HTMLElement, id: string) => + container.querySelector(`#${id}`)!; + describe('SVG Draw', () => { beforeEach(() => { setReducedMotion(false); - proto.getTotalLength = function (this: Element) { - return LEN(this); - }; - vi.spyOn(Element.prototype, 'getClientRects').mockReturnValue([{}] as unknown as DOMRectList); + svgProto.getTotalLength = () => 100; + svgProto.getBBox = () => ({ x: 0, y: 0, width: 100, height: 100 }); }); afterEach(() => { - delete proto.getTotalLength; + delete svgProto.getTotalLength; + delete svgProto.getBBox; vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.useRealTimers(); }); - it('leaves the SVG as written under reduced motion', () => { + it('shows the SVG as written under reduced motion', () => { setReducedMotion(true); const { container } = render( @@ -78,59 +53,35 @@ describe('SVG Draw', () => { ); + expect((container.firstChild as HTMLElement).style.visibility).toBe('visible'); expect(shape(container, 'outline').style.strokeDasharray).toBe(''); - expect(container.querySelector('[data-svg-draw]')!.style.visibility).toBe( - 'visible' - ); }); - it('holds every shape undrawn until it scrolls into view', () => { - vi.stubGlobal('IntersectionObserver', ObserverStub); + it('starts every shape undrawn', () => { const { container } = render( - + ); - const outline = shape(container, 'outline'); - expect(Number(outline.style.strokeDashoffset)).toBeGreaterThan(300); - expect(outline.style.fillOpacity).toBe('0'); - expect(container.querySelector('[data-svg-draw]')!.style.visibility).toBe( - 'visible' - ); - - act(() => enterView()); - expect(outline.style.strokeDashoffset).toBe('0'); + expect(shape(container, 'outline').style.strokeDasharray).not.toBe(''); + expect(shape(container, 'dot').style.strokeDasharray).not.toBe(''); }); - it('traces a stroked shape in its own stroke and a fill-only one in `color`', () => { - vi.stubGlobal('IntersectionObserver', ObserverStub); + it('traces a fill-only shape in the text color and leaves strokes alone', () => { const { container } = render( - + ); + expect(shape(container, 'dot').style.stroke).toBe('currentColor'); expect(shape(container, 'outline').style.stroke).toBe('rgb(255, 0, 0)'); - expect(shape(container, 'outline').style.strokeWidth).toBe('2'); - expect(shape(container, 'dot').style.stroke).toBe('rgb(0, 0, 255)'); - }); - - it('traces fill-only shapes in their own fill with ownColor', () => { - vi.stubGlobal('IntersectionObserver', ObserverStub); - const { container } = render( - - - - ); - - expect(shape(container, 'dot').style.stroke).toBe('rgb(0, 128, 0)'); }); it('skips shapes that are never painted where they sit', () => { - vi.stubGlobal('IntersectionObserver', ObserverStub); const { container } = render( - + ); @@ -138,51 +89,16 @@ describe('SVG Draw', () => { expect(shape(container, 'clipped').getAttribute('style')).toBeNull(); }); - it('draws the longest shape first, the rest one stagger apart', () => { - vi.stubGlobal('IntersectionObserver', undefined); - const { container } = render( - - - - ); - - expect(shape(container, 'outline').style.transition).toMatch(/ 0ms$/); - expect(shape(container, 'dot').style.transition).toMatch(/ 250ms$/); - }); - - it('hands every shape back as written once it settles', () => { - vi.useFakeTimers(); - vi.stubGlobal('IntersectionObserver', undefined); - const onComplete = vi.fn(); - const { container } = render( - - - - ); - const outline = shape(container, 'outline'); - expect(outline.style.strokeDasharray).not.toBe(''); - - act(() => { - vi.advanceTimersByTime(1000 + 200 + 400 + 10); - }); - - expect(onComplete).toHaveBeenCalledTimes(1); - // Its own style is back, and nothing of the drawing is left on it - expect(outline.style.stroke).toBe('rgb(255, 0, 0)'); - expect(outline.style.strokeDasharray).toBe(''); - expect(shape(container, 'dot').style.fillOpacity).toBe(''); - }); - - it('restores the SVG when it unmounts mid-draw', () => { - vi.stubGlobal('IntersectionObserver', ObserverStub); + it('reverts every shape when it unmounts', () => { const { container, unmount } = render( - + ); - const outline = shape(container, 'outline'); + const dot = shape(container, 'dot'); unmount(); - expect(outline.style.strokeDasharray).toBe(''); + expect(dot.style.strokeDasharray).toBe(''); + expect(dot.style.stroke).toBe('none'); }); });