diff --git a/apps/docs/components/nav/animated-icon.tsx b/apps/docs/components/nav/animated-icon.tsx index 47a3bb8..5df18a9 100644 --- a/apps/docs/components/nav/animated-icon.tsx +++ b/apps/docs/components/nav/animated-icon.tsx @@ -103,6 +103,7 @@ const PLAY: Record = { 'list-ordered': wave, 'list-collapse': wave, layers: wave, + blinds: wave, 'move-right': nudge, 'unfold-horizontal': nudge, 'circle-arrow-right': nudge, diff --git a/apps/docs/content/2.block/shutter-slider.mdx b/apps/docs/content/2.block/shutter-slider.mdx new file mode 100644 index 0000000..d0b5e81 --- /dev/null +++ b/apps/docs/content/2.block/shutter-slider.mdx @@ -0,0 +1,118 @@ +--- +title: Shutter Slider +description: An autoplaying feature slider whose images open through alternating slats. +icon: blinds +--- + +A feature slider for landing and product pages. Each new image **opens through +vertical slats** — every other slat from the top, the rest from the bottom, in +a wave from the side you're heading to — while the old picture sinks back and +dims beneath it. The **headline letters roll in like drum faces**, the copy +**rises line by line** through masks and the caption **pulls into focus** from +its centre. Every move is **mirrored when you go back**. The counters roll like +an odometer and the progress line doubles as the autoplay clock. GSAP. + +::component-preview{name="shutter-slider" size="xl"} +:: + +## Installation + +::tabs +:::tabs-item{label="CLI" icon="i-lucide-terminal"} + +::install-tabs{item="shutter-slider"} +:: + +::: +:::tabs-item{label="Manual" icon="i-lucide-clipboard"} + +::component-source{name="shutter-slider"} +:: + +::: +:: + +## Usage + +::tabs +:::tabs-item{label="React" icon="i-lucide-atom"} + +```tsx [app/page.tsx] +import ShutterSlider from '@/components/tweenui/shutter-slider'; + +export default function Page() { + return ; +} +``` + +::: +:: + +Pass your own slides. Images of any shape are cropped to the same 2:1 frame: + +```tsx [app/page.tsx] +import ShutterSlider from '@/components/tweenui/shutter-slider'; + +export default function Page() { + return ( + + ); +} +``` + +The sample photos are from [Unsplash](https://unsplash.com/license), free to use +under the Unsplash License. The background (`#eef1f4` / `#15130f` in dark mode) +and the terracotta accent live in the `className` strings on the `section` and +the pagination row, so you can restyle them in place. + +## Props + +| Prop | Type | Default | Description | +| ------------ | ---------------- | -------------- | ------------------------------------------------- | +| `slides` | `ShutterSlide[]` | 3-slide sample | Slides, in order. | +| `autoplay` | `number` | `6` | Seconds each slide holds. `0` turns autoplay off. | +| `aria-label` | `string` | `'Highlights'` | Accessible name for the carousel. | + +Each slide is `{ heading, image, imageAlt?, description, caption? }`. Extends +native `
` attributes (`className`, `style`, …). + +## Behavior + +- **Stable height** — all slides share one grid cell, so the section is always + as tall as its tallest slide and nothing below it jumps between slides. +- **Autoplay** pauses while the pointer is over the slider, while it is off + screen and while the tab is hidden. +- **Swipe** left or right on touch screens; **← / →** step while focus is inside. +- **Resizing** re-splits the copy lines, so each line mask always matches the + real wrapping. +- Text is split once **web fonts are ready**, so lines never break in a + fallback font. + +## Accessibility + +::note +The slider is a `section` with `aria-roledescription="carousel"`, and each slide +is a group labelled "1 of 3" and so on. Inactive slides are `visibility: hidden`, +so screen readers only reach the current one. The prev and next controls are +real buttons with labels; the rolling counters and the shutter slats are +`aria-hidden`. Under `prefers-reduced-motion: reduce` there is no autoplay and +no motion — the controls swap slides instantly. +:: diff --git a/apps/docs/public/media/blocks/shutter-slider-poster.webp b/apps/docs/public/media/blocks/shutter-slider-poster.webp new file mode 100644 index 0000000..79546a6 Binary files /dev/null and b/apps/docs/public/media/blocks/shutter-slider-poster.webp differ diff --git a/apps/docs/public/media/blocks/shutter-slider-preview.mp4 b/apps/docs/public/media/blocks/shutter-slider-preview.mp4 new file mode 100644 index 0000000..01f3138 Binary files /dev/null and b/apps/docs/public/media/blocks/shutter-slider-preview.mp4 differ diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index 71c4f56..50d8e5b 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -458,6 +458,18 @@ "type": "registry:component" } ] + }, + { + "name": "shutter-slider", + "type": "registry:block", + "title": "Shutter Slider", + "description": "An autoplaying feature slider. Each image opens through vertical slats that alternate from the top and bottom, the headline letters roll in like drum faces, copy lines rise through masks and the caption pulls into focus — all mirrored when you travel back.", + "files": [ + { + "path": "components/tweenui/shutter-slider.tsx", + "type": "registry:component" + } + ] } ] } \ No newline at end of file diff --git a/apps/docs/public/r/shutter-slider.json b/apps/docs/public/r/shutter-slider.json new file mode 100644 index 0000000..c1fcff7 --- /dev/null +++ b/apps/docs/public/r/shutter-slider.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "shutter-slider", + "type": "registry:block", + "title": "Shutter Slider", + "description": "An autoplaying feature slider. Each image opens through vertical slats that alternate from the top and bottom, the headline letters roll in like drum faces, copy lines rise through masks and the caption pulls into focus — all mirrored when you travel back.", + "dependencies": [ + "gsap", + "@gsap/react" + ], + "registryDependencies": [], + "cssVars": { + "theme": {} + }, + "files": [ + { + "path": "components/tweenui/shutter-slider.tsx", + "type": "registry:component", + "target": "components/tweenui/shutter-slider.tsx", + "content": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { SplitText } from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, SplitText);\nif (!CustomEase.get('shutter-out')) CustomEase.create('shutter-out', '0.16,1,0.3,1');\nif (!CustomEase.get('shutter-in')) CustomEase.create('shutter-in', '0.6,0,0.9,0.35');\nif (!CustomEase.get('shutter-in-out')) CustomEase.create('shutter-in-out', '0.72,0,0.18,1');\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** Hover pauses autoplay — only where there is a real hover. */\nconst canHover = () =>\n typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Vertical slats the incoming image opens through. */\nconst SLATS = 6;\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\nexport interface ShutterSlide {\n /** Large serif title above the image. */\n heading: string;\n /** Image URL. Any aspect works — it is cropped to a 2:1 frame. */\n image: string;\n /** Alt text for the image. Empty marks it decorative. */\n imageAlt?: string;\n /** Paragraph under the pagination. */\n description: string;\n /** Short uppercase line at the bottom. */\n caption?: string;\n}\n\nexport interface ShutterSliderProps extends ComponentPropsWithoutRef<'section'> {\n /** Slides, in order. Defaults to a three-slide sample. */\n slides?: ShutterSlide[];\n /** Seconds each slide holds before advancing. `0` turns autoplay off. */\n autoplay?: number;\n}\n\nconst unsplash = (id: string) =>\n `https://images.unsplash.com/${id}?w=1600&h=800&fit=crop&auto=format&q=80`;\n\nconst DEFAULT_SLIDES: ShutterSlide[] = [\n {\n heading: 'Light first',\n image: unsplash('photo-1512917774080-9991f1c4c750'),\n imageAlt: 'A white modern house with floor-to-ceiling glass, a palm and a pool in the sun',\n description:\n 'Every room is planned around the path of the sun, so mornings start bright and evenings stay warm without reaching for a switch.',\n caption: 'Designed around daylight',\n },\n {\n heading: 'Room to breathe',\n image: unsplash('photo-1600607687939-ce8a6c25118c'),\n imageAlt: 'An open-plan living room flowing into a kitchen under a high white ceiling',\n description:\n 'Open plans, tall ceilings and quiet corners give every home the space to gather, to work and to slow down.',\n caption: 'Space that adapts',\n },\n {\n heading: 'Built to last',\n image: unsplash('photo-1600585154340-be6161a56a0c'),\n imageAlt: 'A timber-clad house lit warmly from inside at dusk, under a large tree',\n description:\n 'Stone, timber and lime plaster, chosen to age gracefully and hold their character for decades.',\n caption: 'Honest materials',\n },\n];\n\ninterface SlideParts {\n slide: HTMLElement;\n heading: HTMLElement;\n copy: HTMLElement;\n caption: HTMLElement | null;\n base: HTMLElement;\n shutter: HTMLElement;\n slats: HTMLElement[];\n slatImgs: HTMLElement[];\n chars: Element[];\n lines: Element[];\n capChars: Element[];\n lineSplit: SplitText | null;\n}\n\nconst LINE_SPLIT = { type: 'lines', linesClass: 'ss-line', mask: 'lines' } as const;\nconst CHAR_SPLIT = { type: 'words,chars', wordsClass: 'ss-word', charsClass: 'ss-char' } as const;\n\n/** Resting pose: everything in place, base image showing, shutter parked. */\nfunction settle(p: SlideParts) {\n gsap.set(p.chars, { rotationX: 0, opacity: 1 });\n gsap.set(p.lines, { yPercent: 0, rotation: 0 });\n gsap.set(p.capChars, { opacity: 1, yPercent: 0 });\n if (p.caption) gsap.set(p.caption, { filter: 'none' });\n gsap.set(p.base, { autoAlpha: 1, scale: 1, filter: 'none' });\n gsap.set(p.shutter, { autoAlpha: 0 });\n}\n\n/** \"About to enter\" pose, mirrored for `dir` (1 = next, -1 = previous). */\nfunction prime(p: SlideParts, dir: number) {\n // Letters turn around an axis behind them, like faces on a drum.\n const depth = parseFloat(getComputedStyle(p.heading).fontSize || '16') * -0.4;\n gsap.set(p.chars, { rotationX: -95 * dir, opacity: 0, transformOrigin: `50% 50% ${depth}px` });\n gsap.set(p.lines, { yPercent: 105 * dir, rotation: 2.5 * dir, transformOrigin: '0% 100%' });\n gsap.set(p.capChars, { opacity: 0, yPercent: 40 * dir });\n if (p.caption) gsap.set(p.caption, { filter: 'blur(6px)' });\n gsap.set(p.base, { autoAlpha: 0, scale: 1, filter: 'brightness(1)' });\n gsap.set(p.shutter, { autoAlpha: 1 });\n // Alternate slats open from opposite ends — swapped when travelling back.\n gsap.set(p.slats, {\n clipPath: (i: number) =>\n (i % 2 === 0) === dir > 0 ? 'inset(100% 0% 0% 0%)' : 'inset(0% 0% 100% 0%)',\n });\n gsap.set(p.slatImgs, { scale: 1.25, yPercent: (i: number) => (i % 2 ? -6 : 6) * dir });\n}\n\nfunction enter(p: SlideParts, dir: number) {\n const from = dir > 0 ? 'start' : 'end';\n return (\n gsap\n .timeline()\n // Slats open in a wave from the side we're travelling toward.\n .to(\n p.slats,\n {\n clipPath: 'inset(0% 0% 0% 0%)',\n duration: 1.1,\n ease: 'shutter-in-out',\n stagger: { each: 0.07, from },\n },\n 0\n )\n .to(\n p.slatImgs,\n {\n scale: 1,\n yPercent: 0,\n duration: 1.5,\n ease: 'shutter-out',\n stagger: { each: 0.07, from },\n },\n 0\n )\n // Hand over to the single base image so no slat seams remain at rest.\n .set(p.base, { autoAlpha: 1 })\n .set(p.shutter, { autoAlpha: 0 })\n .to(\n p.chars,\n {\n rotationX: 0,\n opacity: 1,\n duration: 1,\n ease: 'shutter-out',\n stagger: { each: 0.03, from },\n },\n 0.3\n )\n .to(\n p.lines,\n { yPercent: 0, rotation: 0, duration: 1, ease: 'shutter-out', stagger: 0.08 },\n 0.45\n )\n // Caption pulls focus from the centre outward.\n .to(\n p.capChars,\n {\n opacity: 1,\n yPercent: 0,\n duration: 0.8,\n ease: 'shutter-out',\n stagger: { each: 0.012, from: 'center' },\n },\n 0.65\n )\n .to(p.caption, { filter: 'blur(0px)', duration: 0.9, ease: 'shutter-out' }, 0.65)\n );\n}\n\nfunction exit(p: SlideParts, dir: number) {\n const from = dir > 0 ? 'start' : 'end';\n return (\n gsap\n .timeline()\n .to(\n p.chars,\n {\n rotationX: 95 * dir,\n opacity: 0,\n duration: 0.55,\n ease: 'shutter-in',\n stagger: { each: 0.02, from },\n },\n 0\n )\n .to(\n p.lines,\n {\n yPercent: -105 * dir,\n rotation: -1.5 * dir,\n duration: 0.5,\n ease: 'shutter-in',\n stagger: 0.05,\n },\n 0\n )\n .to(\n p.capChars,\n { opacity: 0, duration: 0.3, ease: 'none', stagger: { each: 0.008, from: 'edges' } },\n 0\n )\n // The old picture sinks back and dims under the incoming slats.\n .to(\n p.base,\n { scale: 1.12, filter: 'brightness(0.55)', duration: 1.3, ease: 'shutter-in-out' },\n 0\n )\n );\n}\n\nfunction Chevron({ back }: { back?: boolean }) {\n return (\n \n \n \n );\n}\n\nexport default function ShutterSlider({\n slides = DEFAULT_SLIDES,\n autoplay = 6,\n className,\n 'aria-label': ariaLabel = 'Highlights',\n ...props\n}: ShutterSliderProps) {\n const rootRef = useRef(null);\n const total = slides.length;\n\n useGSAP(\n (_context, contextSafe) => {\n const root = rootRef.current;\n if (!root || !total) return;\n\n const reduced = prefersReducedMotion();\n const disposers: (() => void)[] = [];\n let cancelled = false;\n\n const init = contextSafe!(() => {\n if (cancelled) return;\n\n const q = (sel: string, scope: ParentNode = root) =>\n scope.querySelector(sel)!;\n const currentEl = q('[data-shutter-count=\"current\"]');\n const nextEl = q('[data-shutter-count=\"next\"]');\n const fill = q('[data-shutter-progress]');\n\n const parts: SlideParts[] = gsap.utils\n .toArray('[data-shutter-slide]', root)\n .map((slide) => {\n const heading = q('[data-shutter-heading]', slide);\n const copy = q('[data-shutter-copy]', slide);\n const caption = slide.querySelector('[data-shutter-caption]');\n // Nothing animates under reduced motion, so the text is left unsplit.\n const lineSplit = reduced ? null : SplitText.create(copy, LINE_SPLIT);\n return {\n slide,\n heading,\n copy,\n caption,\n base: q('[data-shutter-base]', slide),\n shutter: q('[data-shutter-slats]', slide),\n slats: gsap.utils.toArray('[data-shutter-slat]', slide),\n slatImgs: gsap.utils.toArray('[data-shutter-slat] > img', slide),\n chars: reduced ? [] : SplitText.create(heading, CHAR_SPLIT).chars,\n lines: lineSplit?.lines ?? [],\n capChars: caption && !reduced ? SplitText.create(caption, CHAR_SPLIT).chars : [],\n lineSplit,\n };\n });\n\n let index = 0;\n let busy = false;\n let revealed = false;\n let inView = typeof IntersectionObserver === 'undefined';\n let hovering = false;\n let clock: gsap.core.Tween | null = null;\n\n // All slides share one grid cell, so switching is only visibility —\n // the section keeps the tallest slide's height and never jumps.\n const show = (p: SlideParts, on: boolean) =>\n gsap.set(p.slide, { visibility: on ? 'visible' : 'hidden', zIndex: on ? 1 : 'auto' });\n parts.forEach((p, i) => show(p, i === 0));\n\n // Odometer: the old number rolls out as the new one rolls in.\n const roll = (el: HTMLElement, value: number, dir: number, instant?: boolean) => {\n const old = el.lastElementChild as HTMLElement | null;\n const next = document.createElement('span');\n next.textContent = pad(value);\n el.appendChild(next);\n if (instant || !old || reduced) return old?.remove();\n gsap.fromTo(\n next,\n { yPercent: 100 * dir },\n { yPercent: 0, duration: 0.7, ease: 'shutter-out' }\n );\n gsap.to(old, {\n yPercent: -100 * dir,\n duration: 0.7,\n ease: 'shutter-out',\n onComplete: () => old.remove(),\n });\n };\n const counters = (dir: number, instant?: boolean) => {\n roll(currentEl, index + 1, dir, instant);\n roll(nextEl, ((index + 1) % total) + 1, dir, instant);\n };\n currentEl.textContent = '';\n nextEl.textContent = '';\n counters(1, true);\n\n const go = (dir: number) => {\n if (busy || total < 2) return;\n busy = true;\n const out = parts[index];\n index = (index + dir + total) % total;\n const inn = parts[index];\n counters(dir);\n root.setAttribute('data-index', String(index));\n\n show(inn, true);\n gsap.set(out.slide, { zIndex: 0 });\n\n if (reduced) {\n settle(inn);\n show(out, false);\n busy = false;\n return;\n }\n\n prime(inn, dir);\n gsap\n .timeline({\n onComplete: () => {\n show(out, false);\n gsap.set(inn.slide, { zIndex: 1 });\n busy = false;\n },\n })\n .add(exit(out, dir), 0)\n .add(enter(inn, dir), 0.15);\n };\n\n // Autoplay: the progress line is the clock.\n const stop = () => {\n clock?.kill();\n clock = null;\n gsap.set(fill, { scaleX: 0 });\n };\n const start = () => {\n stop();\n if (reduced || autoplay <= 0 || total < 2 || !inView || document.hidden) return;\n clock = gsap.fromTo(\n fill,\n { scaleX: 0 },\n {\n scaleX: 1,\n duration: autoplay,\n ease: 'none',\n paused: hovering,\n onComplete: () => {\n go(1);\n start();\n },\n }\n );\n };\n const step = (dir: number) => {\n if (busy) return;\n go(dir);\n start();\n };\n\n const listen = (\n target: HTMLElement,\n type: K,\n fn: (event: HTMLElementEventMap[K]) => void\n ) => {\n const handler = contextSafe!(fn as (event: Event) => void);\n target.addEventListener(type, handler as EventListener);\n disposers.push(() => target.removeEventListener(type, handler as EventListener));\n };\n\n listen(q('[data-shutter-prev]'), 'click', () => step(-1));\n listen(q('[data-shutter-next]'), 'click', () => step(1));\n listen(root, 'keydown', (event) => {\n if (event.key === 'ArrowLeft') step(-1);\n if (event.key === 'ArrowRight') step(1);\n });\n\n // Horizontal swipe on touch and pen.\n let sx = 0;\n let sy = 0;\n listen(root, 'pointerdown', (event) => {\n sx = event.clientX;\n sy = event.clientY;\n });\n listen(root, 'pointerup', (event) => {\n if (event.pointerType === 'mouse') return;\n const dx = event.clientX - sx;\n const dy = event.clientY - sy;\n if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);\n });\n\n if (canHover()) {\n listen(root, 'pointerenter', () => {\n hovering = true;\n clock?.pause();\n });\n listen(root, 'pointerleave', () => {\n hovering = false;\n clock?.resume();\n });\n }\n\n const onVisibility = contextSafe!(() => (document.hidden ? stop() : start()));\n document.addEventListener('visibilitychange', onVisibility);\n disposers.push(() => document.removeEventListener('visibilitychange', onVisibility));\n\n // Copy lines depend on the width they were split at: re-split after a\n // resize so each line mask matches the real wrapping again.\n let splitWidth = root.offsetWidth;\n let resizeTimer: ReturnType | undefined;\n const resplit = contextSafe!(() => {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(\n contextSafe!(() => {\n if (reduced || root.offsetWidth === splitWidth) return;\n if (busy) return resplit();\n splitWidth = root.offsetWidth;\n parts.forEach((p, i) => {\n p.lineSplit?.revert();\n p.lineSplit = SplitText.create(p.copy, LINE_SPLIT);\n p.lines = p.lineSplit.lines;\n const shown = i === index && revealed;\n gsap.set(\n p.lines,\n shown\n ? { yPercent: 0 }\n : { yPercent: 105, rotation: 2.5, transformOrigin: '0% 100%' }\n );\n });\n }),\n 150\n );\n });\n const resizeObserver = new ResizeObserver(resplit);\n resizeObserver.observe(root);\n disposers.push(() => {\n resizeObserver.disconnect();\n clearTimeout(resizeTimer);\n });\n\n // First slide plays in the first time the slider is on screen.\n const first = parts[0];\n const reveal = () => {\n if (revealed) return;\n revealed = true;\n if (reduced) settle(first);\n else enter(first, 1).delay(0.2);\n };\n\n if (reduced) settle(first);\n else prime(first, 1);\n parts.slice(1).forEach((p) => (reduced ? settle(p) : prime(p, 1)));\n root.setAttribute('data-ready', '');\n\n if (typeof IntersectionObserver === 'undefined') {\n reveal();\n start();\n } else {\n const io = new IntersectionObserver(\n contextSafe!(([entry]: IntersectionObserverEntry[]) => {\n inView = entry.isIntersecting;\n if (!inView) return stop();\n reveal();\n start();\n }),\n { threshold: 0.25 }\n );\n io.observe(root);\n disposers.push(() => io.disconnect());\n }\n\n disposers.push(() => clock?.kill());\n });\n\n // Split only once web fonts are in, so line breaks match the final text;\n // give up after a beat so a slow font can't hold the slider hidden.\n const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n if (fonts) Promise.race([fonts, new Promise((r) => setTimeout(r, 1500))]).then(init);\n else init();\n\n return () => {\n cancelled = true;\n disposers.forEach((dispose) => dispose());\n root.removeAttribute('data-ready');\n };\n },\n { scope: rootRef, dependencies: [slides, autoplay] }\n );\n\n return (\n \n
\n
\n {/* Mirrors a slide's middle row so the pagination lands just under the image. */}\n
\n
\n
\n
\n \n \n \n span]:[grid-area:1/1]\"\n >\n {pad(1)}\n \n \n
\n \n
\n \n span]:[grid-area:1/1]\"\n >\n {pad(Math.min(2, total))}\n \n \n \n
\n
\n\n {/* Hidden until GSAP has posed the first slide, so nothing flashes in place. */}\n
\n {slides.map((slide, i) => (\n 0 && 'invisible'\n )}\n >\n
\n \n {slide.heading}\n \n
\n\n
\n {/* clip-path as well as overflow: WebKit can let composited\n (scaled, will-change) children escape overflow clipping. */}\n
\n \n \n {Array.from({ length: SLATS }, (_, s) => (\n \n {/* A full-frame copy shifted into place, so the slats read as one picture. */}\n \n
\n ))}\n
\n
\n
\n\n {/* pt-16 = pagination (h-12) + the gap under it. */}\n
\n \n {slide.description}\n

\n {slide.caption && (\n \n {slide.caption}\n

\n )}\n
\n
\n ))}\n
\n
\n \n
\n );\n}\n" + } + ] +} \ No newline at end of file diff --git a/apps/docs/registry/__index__.generated.tsx b/apps/docs/registry/__index__.generated.tsx index 23c4c65..aa41051 100644 --- a/apps/docs/registry/__index__.generated.tsx +++ b/apps/docs/registry/__index__.generated.tsx @@ -48,6 +48,7 @@ export const demos: Record = { 'pricing-usage-slider:default': dynamic(() => import('./demos/pricing-usage-slider')), 'grid-cascade:default': dynamic(() => import('./demos/grid-cascade')), 'cube-roll-item:default': dynamic(() => import('./demos/cube-roll-item')), + 'shutter-slider:default': dynamic(() => import('./demos/shutter-slider')), }; export function getDemo(component: string, variant: string): ComponentType | undefined { diff --git a/apps/docs/registry/__media__.generated.ts b/apps/docs/registry/__media__.generated.ts index 6114669..a29e2a0 100644 --- a/apps/docs/registry/__media__.generated.ts +++ b/apps/docs/registry/__media__.generated.ts @@ -45,4 +45,5 @@ export const discoveredMedia: Record = { 'pricing-usage-slider': {"image":"/media/blocks/pricing-usage-slider-poster.webp","video":"/media/blocks/pricing-usage-slider-preview.mp4"}, 'grid-cascade': {"image":"/media/blocks/grid-cascade-poster.webp","video":"/media/blocks/grid-cascade-preview.mp4"}, 'cube-roll-item': {"image":"/media/blocks/cube-roll-item-poster.webp","video":"/media/blocks/cube-roll-item-preview.mp4"}, + 'shutter-slider': {"image":"/media/blocks/shutter-slider-poster.webp","video":"/media/blocks/shutter-slider-preview.mp4"}, }; diff --git a/apps/docs/registry/__sources__.generated.ts b/apps/docs/registry/__sources__.generated.ts index f70299e..96f9fce 100644 --- a/apps/docs/registry/__sources__.generated.ts +++ b/apps/docs/registry/__sources__.generated.ts @@ -234,6 +234,12 @@ export const usage: Record = { "code": "import CubeRollItem from '@/components/tweenui/cube-roll-item';\n\nexport default function Example() {\n return ;\n}", "codeHtml": "
import CubeRollItem from '@/components/tweenui/cube-roll-item';\n\nexport default function Example() {\n  return <CubeRollItem />;\n}
" } + }, + "shutter-slider": { + "react": { + "code": "import ShutterSlider from '@/components/tweenui/shutter-slider';\n\nexport default function Example() {\n return ;\n}", + "codeHtml": "
import ShutterSlider from '@/components/tweenui/shutter-slider';\n\nexport default function Example() {\n  return <ShutterSlider />;\n}
" + } } }; @@ -465,5 +471,11 @@ export const manual: Record = { "code": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef, type FocusEvent, type MouseEvent } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** The roll follows the pointer — skip it on touch, where there is no hover. */\nconst canHover = () =>\n typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Keyboard focus rolls the row; a mouse click that happens to focus it does not. */\nconst isFocusVisible = (el: HTMLElement) => {\n try {\n return el.matches(':focus-visible');\n } catch {\n return true;\n }\n};\n\n/** Settles a touch past the face, like a drum clicking into its detent. */\nconst ROLL_EASE = 'back.out(1.6)';\n\n/**\n * Each row is a four-sided drum turning on its X axis. Front and back carry the\n * resting face, top and bottom the filled one — so every quarter turn lands on\n * the opposite state, and the drum can keep rolling the way the pointer moves.\n * `--depth` matches the row height, which makes the drum a true square prism.\n */\nconst FACE_TRANSFORM = {\n front: '[transform:translateZ(calc(var(--depth)/2))]',\n back: '[transform:rotateX(180deg)_translateZ(calc(var(--depth)/2))]',\n top: '[transform:rotateX(90deg)_translateZ(calc(var(--depth)/2))]',\n bottom: '[transform:rotateX(-90deg)_translateZ(calc(var(--depth)/2))]',\n} as const;\n\ntype Side = keyof typeof FACE_TRANSFORM;\n\nconst SIDES: Side[] = ['front', 'back', 'top', 'bottom'];\n\nexport interface CubeRollItemEntry {\n /** Large title in the middle of the row. */\n title: string;\n /** Short line on the right. */\n label?: string;\n /** Link target for the row. */\n href: string;\n}\n\nexport interface CubeRollItemProps extends ComponentPropsWithoutRef<'nav'> {\n /** Rows in the list, numbered in order. Defaults to a four-row sample. */\n items?: CubeRollItemEntry[];\n}\n\nconst DEFAULT_ITEMS: CubeRollItemEntry[] = [\n { title: 'Components', label: 'Buttons, text & logos', href: '#components' },\n { title: 'Blocks', label: 'Ready-made sections', href: '#blocks' },\n { title: 'Installation', label: 'One command, no config', href: '#installation' },\n { title: 'Changelog', label: 'What shipped lately', href: '#changelog' },\n];\n\nfunction Face({ side, item, number }: { side: Side; item: CubeRollItemEntry; number: string }) {\n const filled = side === 'top' || side === 'bottom';\n\n return (\n \n {/* Equal side columns keep the title on the row's true center line. */}\n
\n \n {number}\n {filled && (\n \n \n \n )}\n \n {/* Leading above 1 keeps descenders (g, y, p) inside the overflow box. */}\n \n {item.title}\n \n {item.label && (\n \n {item.label}\n \n )}\n
\n \n );\n}\n\nexport default function CubeRollItem({\n items = DEFAULT_ITEMS,\n className,\n 'aria-label': ariaLabel = 'Sections',\n ...props\n}: CubeRollItemProps) {\n const rootRef = useRef(null);\n /** Accumulated drum angle per row, in quarter turns of ±90°. */\n const anglesRef = useRef([]);\n const activeRef = useRef([]);\n\n const { contextSafe } = useGSAP(\n (_context, contextSafe) => {\n anglesRef.current = items.map(() => 0);\n activeRef.current = items.map(() => false);\n\n if (prefersReducedMotion()) return;\n\n // Park every drum half a turn over with its back face blanked, so each row\n // starts as an empty slab and nothing shows in the fallback font.\n const backContent = '[data-roll-face=\"back\"] > *';\n gsap.set('[data-roll-rule]', { scaleX: 0 });\n gsap.set('[data-roll-drum]', { rotationX: 180 });\n gsap.set(backContent, { opacity: 0 });\n\n let cancelled = false;\n\n // Rules draw in while the drums flip over one after another, flashing\n // their filled face on the way past.\n const play = contextSafe!(() => {\n if (cancelled) return;\n gsap\n .timeline({ onComplete: () => gsap.set(backContent, { opacity: 1 }) })\n .to(\n '[data-roll-rule]',\n { scaleX: 1, duration: 0.9, ease: 'expo.inOut', stagger: 0.15 },\n 0\n )\n .to(\n '[data-roll-drum]',\n { rotationX: 0, duration: 0.9, ease: 'power4.inOut', stagger: 0.15 },\n 0.15\n );\n });\n\n // Wait for web fonts so the text never swaps mid-reveal; give up after a\n // beat so a slow or failed font can't hold the block hidden.\n const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n Promise.race([fonts, new Promise((resolve) => setTimeout(resolve, 1500))]).then(play);\n\n return () => {\n cancelled = true;\n };\n },\n { scope: rootRef, dependencies: [items] }\n );\n\n /** Turn a row a quarter — downward when `down`, upward otherwise. */\n const turn = contextSafe((link: HTMLElement, index: number, active: boolean, down: boolean) => {\n if (activeRef.current[index] === active) return;\n activeRef.current[index] = active;\n link.closest('li')?.setAttribute('data-active', String(active));\n\n const drum = link.querySelector('[data-roll-drum]');\n if (!drum) return;\n // Hovered before the intro finished: the back face may still be blank.\n gsap.set(link.querySelectorAll('[data-roll-face=\"back\"] > *'), { opacity: 1 });\n\n if (prefersReducedMotion()) {\n anglesRef.current[index] = active ? -90 : 0;\n gsap.set(drum, { rotationX: anglesRef.current[index], opacity: 1 });\n return;\n }\n\n anglesRef.current[index] = (anglesRef.current[index] ?? 0) + (down ? -90 : 90);\n // `auto` takes rotationX from this drum's intro flip only, so hovering\n // mid-reveal leaves the other rows' flips running.\n gsap.to(drum, {\n rotationX: anglesRef.current[index],\n duration: active ? 0.6 : 0.7,\n ease: ROLL_EASE,\n overwrite: 'auto',\n });\n });\n\n /** Did the pointer cross the top half of the row? */\n const fromTop = (event: MouseEvent) => {\n const rect = event.currentTarget.getBoundingClientRect();\n return event.clientY < rect.top + rect.height / 2;\n };\n\n return (\n \n {/* No gap between rows, so the filled face covers the whole row edge to edge. */}\n
    \n {items.map((item, index) => {\n const number = String(index + 1).padStart(3, '0');\n\n return (\n \n \n \n canHover() && turn(event.currentTarget, index, true, fromTop(event))\n }\n onMouseLeave={(event) =>\n canHover() && turn(event.currentTarget, index, false, !fromTop(event))\n }\n onFocus={(event: FocusEvent) =>\n isFocusVisible(event.currentTarget) &&\n turn(event.currentTarget, index, true, true)\n }\n onBlur={(event: FocusEvent) =>\n turn(event.currentTarget, index, false, true)\n }\n >\n
    \n {/* Under reduced motion the drum only snaps, so skip the layer promotion. */}\n \n {SIDES.map((side) => (\n \n ))}\n
    \n \n \n \n );\n })}\n
\n {/* Closing rule under the last row. */}\n \n \n );\n}\n", "codeHtml": "
'use client';\n\nimport { useRef, type ComponentPropsWithoutRef, type FocusEvent, type MouseEvent } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** The roll follows the pointer — skip it on touch, where there is no hover. */\nconst canHover = () =>\n  typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Keyboard focus rolls the row; a mouse click that happens to focus it does not. */\nconst isFocusVisible = (el: HTMLElement) => {\n  try {\n    return el.matches(':focus-visible');\n  } catch {\n    return true;\n  }\n};\n\n/** Settles a touch past the face, like a drum clicking into its detent. */\nconst ROLL_EASE = 'back.out(1.6)';\n\n/**\n * Each row is a four-sided drum turning on its X axis. Front and back carry the\n * resting face, top and bottom the filled one — so every quarter turn lands on\n * the opposite state, and the drum can keep rolling the way the pointer moves.\n * `--depth` matches the row height, which makes the drum a true square prism.\n */\nconst FACE_TRANSFORM = {\n  front: '[transform:translateZ(calc(var(--depth)/2))]',\n  back: '[transform:rotateX(180deg)_translateZ(calc(var(--depth)/2))]',\n  top: '[transform:rotateX(90deg)_translateZ(calc(var(--depth)/2))]',\n  bottom: '[transform:rotateX(-90deg)_translateZ(calc(var(--depth)/2))]',\n} as const;\n\ntype Side = keyof typeof FACE_TRANSFORM;\n\nconst SIDES: Side[] = ['front', 'back', 'top', 'bottom'];\n\nexport interface CubeRollItemEntry {\n  /** Large title in the middle of the row. */\n  title: string;\n  /** Short line on the right. */\n  label?: string;\n  /** Link target for the row. */\n  href: string;\n}\n\nexport interface CubeRollItemProps extends ComponentPropsWithoutRef<'nav'> {\n  /** Rows in the list, numbered in order. Defaults to a four-row sample. */\n  items?: CubeRollItemEntry[];\n}\n\nconst DEFAULT_ITEMS: CubeRollItemEntry[] = [\n  { title: 'Components', label: 'Buttons, text & logos', href: '#components' },\n  { title: 'Blocks', label: 'Ready-made sections', href: '#blocks' },\n  { title: 'Installation', label: 'One command, no config', href: '#installation' },\n  { title: 'Changelog', label: 'What shipped lately', href: '#changelog' },\n];\n\nfunction Face({ side, item, number }: { side: Side; item: CubeRollItemEntry; number: string }) {\n  const filled = side === 'top' || side === 'bottom';\n\n  return (\n    <div\n      data-roll-face={side}\n      aria-hidden={side === 'front' ? undefined : true}\n      className={cn(\n        'absolute inset-0 backface-hidden',\n        FACE_TRANSFORM[side],\n        filled\n          ? 'bg-[#045f64] text-white dark:bg-[#c6f56f] dark:text-[#071d1b]'\n          : 'bg-white text-[#12161F] dark:bg-[#0d1117] dark:text-white'\n      )}\n    >\n      {/* Equal side columns keep the title on the row's true center line. */}\n      <div className=\"flex h-full flex-wrap items-start px-3 pt-3.5 md:grid md:grid-cols-[1fr_2fr_1fr] md:items-center md:gap-6 md:px-4 md:pt-0\">\n        <span\n          className={cn(\n            'order-1 flex items-center gap-1.5 text-xs tabular-nums',\n            filled ? 'text-[#c6f56f] dark:text-[#045f64]' : 'text-[#045f64] dark:text-[#9fd4d6]'\n          )}\n        >\n          {number}\n          {filled && (\n            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M1 6h9.5M6.5 2 10.5 6l-4 4\" stroke=\"currentColor\" strokeWidth=\"1.25\" />\n            </svg>\n          )}\n        </span>\n        {/* Leading above 1 keeps descenders (g, y, p) inside the overflow box. */}\n        <span className=\"order-3 mt-2.5 w-full min-w-0 overflow-hidden text-center text-3xl leading-[1.3] font-normal tracking-tight text-ellipsis whitespace-nowrap md:order-2 md:mt-0 md:w-auto md:text-4xl\">\n          {item.title}\n        </span>\n        {item.label && (\n          <span\n            className={cn(\n              'order-2 ml-auto text-right text-[13px] tracking-wide md:order-3 md:ml-0',\n              filled\n                ? 'text-white/80 dark:text-[#071d1b]/75'\n                : 'text-[#045f64]/75 dark:text-[#9fd4d6]/80'\n            )}\n          >\n            {item.label}\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default function CubeRollItem({\n  items = DEFAULT_ITEMS,\n  className,\n  'aria-label': ariaLabel = 'Sections',\n  ...props\n}: CubeRollItemProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  /** Accumulated drum angle per row, in quarter turns of ±90°. */\n  const anglesRef = useRef<number[]>([]);\n  const activeRef = useRef<boolean[]>([]);\n\n  const { contextSafe } = useGSAP(\n    (_context, contextSafe) => {\n      anglesRef.current = items.map(() => 0);\n      activeRef.current = items.map(() => false);\n\n      if (prefersReducedMotion()) return;\n\n      // Park every drum half a turn over with its back face blanked, so each row\n      // starts as an empty slab and nothing shows in the fallback font.\n      const backContent = '[data-roll-face=\"back\"] > *';\n      gsap.set('[data-roll-rule]', { scaleX: 0 });\n      gsap.set('[data-roll-drum]', { rotationX: 180 });\n      gsap.set(backContent, { opacity: 0 });\n\n      let cancelled = false;\n\n      // Rules draw in while the drums flip over one after another, flashing\n      // their filled face on the way past.\n      const play = contextSafe!(() => {\n        if (cancelled) return;\n        gsap\n          .timeline({ onComplete: () => gsap.set(backContent, { opacity: 1 }) })\n          .to(\n            '[data-roll-rule]',\n            { scaleX: 1, duration: 0.9, ease: 'expo.inOut', stagger: 0.15 },\n            0\n          )\n          .to(\n            '[data-roll-drum]',\n            { rotationX: 0, duration: 0.9, ease: 'power4.inOut', stagger: 0.15 },\n            0.15\n          );\n      });\n\n      // Wait for web fonts so the text never swaps mid-reveal; give up after a\n      // beat so a slow or failed font can't hold the block hidden.\n      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n      Promise.race([fonts, new Promise((resolve) => setTimeout(resolve, 1500))]).then(play);\n\n      return () => {\n        cancelled = true;\n      };\n    },\n    { scope: rootRef, dependencies: [items] }\n  );\n\n  /** Turn a row a quarter — downward when `down`, upward otherwise. */\n  const turn = contextSafe((link: HTMLElement, index: number, active: boolean, down: boolean) => {\n    if (activeRef.current[index] === active) return;\n    activeRef.current[index] = active;\n    link.closest('li')?.setAttribute('data-active', String(active));\n\n    const drum = link.querySelector('[data-roll-drum]');\n    if (!drum) return;\n    // Hovered before the intro finished: the back face may still be blank.\n    gsap.set(link.querySelectorAll('[data-roll-face=\"back\"] > *'), { opacity: 1 });\n\n    if (prefersReducedMotion()) {\n      anglesRef.current[index] = active ? -90 : 0;\n      gsap.set(drum, { rotationX: anglesRef.current[index], opacity: 1 });\n      return;\n    }\n\n    anglesRef.current[index] = (anglesRef.current[index] ?? 0) + (down ? -90 : 90);\n    // `auto` takes rotationX from this drum's intro flip only, so hovering\n    // mid-reveal leaves the other rows' flips running.\n    gsap.to(drum, {\n      rotationX: anglesRef.current[index],\n      duration: active ? 0.6 : 0.7,\n      ease: ROLL_EASE,\n      overwrite: 'auto',\n    });\n  });\n\n  /** Did the pointer cross the top half of the row? */\n  const fromTop = (event: MouseEvent<HTMLElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    return event.clientY < rect.top + rect.height / 2;\n  };\n\n  return (\n    <nav\n      ref={rootRef}\n      data-cube-roll-item\n      aria-label={ariaLabel}\n      className={cn('mx-auto w-full max-w-[1100px] px-4 py-14 md:py-20', className)}\n      {...props}\n    >\n      {/* No gap between rows, so the filled face covers the whole row edge to edge. */}\n      <ul className=\"flex flex-col\">\n        {items.map((item, index) => {\n          const number = String(index + 1).padStart(3, '0');\n\n          return (\n            <li\n              key={item.href + item.title}\n              data-roll-row\n              data-active=\"false\"\n              className=\"relative h-[5.75rem] overflow-y-clip [--depth:5.75rem] md:h-[4.5rem] md:[--depth:4.5rem]\"\n            >\n              <span\n                data-roll-rule\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-x-0 top-0 z-10 h-px origin-left bg-[#045f64] dark:bg-[#9fd4d6]/60\"\n              />\n              <a\n                href={item.href}\n                className=\"block size-full outline-none perspective-[1200px]\"\n                // Enter from the top and the drum rolls down; leave through the\n                // bottom and it keeps rolling down rather than reversing.\n                onMouseEnter={(event) =>\n                  canHover() && turn(event.currentTarget, index, true, fromTop(event))\n                }\n                onMouseLeave={(event) =>\n                  canHover() && turn(event.currentTarget, index, false, !fromTop(event))\n                }\n                onFocus={(event: FocusEvent<HTMLAnchorElement>) =>\n                  isFocusVisible(event.currentTarget) &&\n                  turn(event.currentTarget, index, true, true)\n                }\n                onBlur={(event: FocusEvent<HTMLAnchorElement>) =>\n                  turn(event.currentTarget, index, false, true)\n                }\n              >\n                <div className=\"relative size-full [transform:translateZ(calc(var(--depth)/-2))] transform-3d\">\n                  {/* Under reduced motion the drum only snaps, so skip the layer promotion. */}\n                  <div\n                    data-roll-drum\n                    className=\"absolute inset-0 will-change-transform transform-3d motion-reduce:will-change-auto\"\n                  >\n                    {SIDES.map((side) => (\n                      <Face key={side} side={side} item={item} number={number} />\n                    ))}\n                  </div>\n                </div>\n              </a>\n            </li>\n          );\n        })}\n      </ul>\n      {/* Closing rule under the last row. */}\n      <span\n        data-roll-rule\n        aria-hidden=\"true\"\n        className=\"block h-px origin-left bg-[#045f64] dark:bg-[#9fd4d6]/60\"\n      />\n    </nav>\n  );\n}\n
" } + }, + "shutter-slider": { + "react": { + "code": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { SplitText } from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, SplitText);\nif (!CustomEase.get('shutter-out')) CustomEase.create('shutter-out', '0.16,1,0.3,1');\nif (!CustomEase.get('shutter-in')) CustomEase.create('shutter-in', '0.6,0,0.9,0.35');\nif (!CustomEase.get('shutter-in-out')) CustomEase.create('shutter-in-out', '0.72,0,0.18,1');\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** Hover pauses autoplay — only where there is a real hover. */\nconst canHover = () =>\n typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Vertical slats the incoming image opens through. */\nconst SLATS = 6;\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\nexport interface ShutterSlide {\n /** Large serif title above the image. */\n heading: string;\n /** Image URL. Any aspect works — it is cropped to a 2:1 frame. */\n image: string;\n /** Alt text for the image. Empty marks it decorative. */\n imageAlt?: string;\n /** Paragraph under the pagination. */\n description: string;\n /** Short uppercase line at the bottom. */\n caption?: string;\n}\n\nexport interface ShutterSliderProps extends ComponentPropsWithoutRef<'section'> {\n /** Slides, in order. Defaults to a three-slide sample. */\n slides?: ShutterSlide[];\n /** Seconds each slide holds before advancing. `0` turns autoplay off. */\n autoplay?: number;\n}\n\nconst unsplash = (id: string) =>\n `https://images.unsplash.com/${id}?w=1600&h=800&fit=crop&auto=format&q=80`;\n\nconst DEFAULT_SLIDES: ShutterSlide[] = [\n {\n heading: 'Light first',\n image: unsplash('photo-1512917774080-9991f1c4c750'),\n imageAlt: 'A white modern house with floor-to-ceiling glass, a palm and a pool in the sun',\n description:\n 'Every room is planned around the path of the sun, so mornings start bright and evenings stay warm without reaching for a switch.',\n caption: 'Designed around daylight',\n },\n {\n heading: 'Room to breathe',\n image: unsplash('photo-1600607687939-ce8a6c25118c'),\n imageAlt: 'An open-plan living room flowing into a kitchen under a high white ceiling',\n description:\n 'Open plans, tall ceilings and quiet corners give every home the space to gather, to work and to slow down.',\n caption: 'Space that adapts',\n },\n {\n heading: 'Built to last',\n image: unsplash('photo-1600585154340-be6161a56a0c'),\n imageAlt: 'A timber-clad house lit warmly from inside at dusk, under a large tree',\n description:\n 'Stone, timber and lime plaster, chosen to age gracefully and hold their character for decades.',\n caption: 'Honest materials',\n },\n];\n\ninterface SlideParts {\n slide: HTMLElement;\n heading: HTMLElement;\n copy: HTMLElement;\n caption: HTMLElement | null;\n base: HTMLElement;\n shutter: HTMLElement;\n slats: HTMLElement[];\n slatImgs: HTMLElement[];\n chars: Element[];\n lines: Element[];\n capChars: Element[];\n lineSplit: SplitText | null;\n}\n\nconst LINE_SPLIT = { type: 'lines', linesClass: 'ss-line', mask: 'lines' } as const;\nconst CHAR_SPLIT = { type: 'words,chars', wordsClass: 'ss-word', charsClass: 'ss-char' } as const;\n\n/** Resting pose: everything in place, base image showing, shutter parked. */\nfunction settle(p: SlideParts) {\n gsap.set(p.chars, { rotationX: 0, opacity: 1 });\n gsap.set(p.lines, { yPercent: 0, rotation: 0 });\n gsap.set(p.capChars, { opacity: 1, yPercent: 0 });\n if (p.caption) gsap.set(p.caption, { filter: 'none' });\n gsap.set(p.base, { autoAlpha: 1, scale: 1, filter: 'none' });\n gsap.set(p.shutter, { autoAlpha: 0 });\n}\n\n/** \"About to enter\" pose, mirrored for `dir` (1 = next, -1 = previous). */\nfunction prime(p: SlideParts, dir: number) {\n // Letters turn around an axis behind them, like faces on a drum.\n const depth = parseFloat(getComputedStyle(p.heading).fontSize || '16') * -0.4;\n gsap.set(p.chars, { rotationX: -95 * dir, opacity: 0, transformOrigin: `50% 50% ${depth}px` });\n gsap.set(p.lines, { yPercent: 105 * dir, rotation: 2.5 * dir, transformOrigin: '0% 100%' });\n gsap.set(p.capChars, { opacity: 0, yPercent: 40 * dir });\n if (p.caption) gsap.set(p.caption, { filter: 'blur(6px)' });\n gsap.set(p.base, { autoAlpha: 0, scale: 1, filter: 'brightness(1)' });\n gsap.set(p.shutter, { autoAlpha: 1 });\n // Alternate slats open from opposite ends — swapped when travelling back.\n gsap.set(p.slats, {\n clipPath: (i: number) =>\n (i % 2 === 0) === dir > 0 ? 'inset(100% 0% 0% 0%)' : 'inset(0% 0% 100% 0%)',\n });\n gsap.set(p.slatImgs, { scale: 1.25, yPercent: (i: number) => (i % 2 ? -6 : 6) * dir });\n}\n\nfunction enter(p: SlideParts, dir: number) {\n const from = dir > 0 ? 'start' : 'end';\n return (\n gsap\n .timeline()\n // Slats open in a wave from the side we're travelling toward.\n .to(\n p.slats,\n {\n clipPath: 'inset(0% 0% 0% 0%)',\n duration: 1.1,\n ease: 'shutter-in-out',\n stagger: { each: 0.07, from },\n },\n 0\n )\n .to(\n p.slatImgs,\n {\n scale: 1,\n yPercent: 0,\n duration: 1.5,\n ease: 'shutter-out',\n stagger: { each: 0.07, from },\n },\n 0\n )\n // Hand over to the single base image so no slat seams remain at rest.\n .set(p.base, { autoAlpha: 1 })\n .set(p.shutter, { autoAlpha: 0 })\n .to(\n p.chars,\n {\n rotationX: 0,\n opacity: 1,\n duration: 1,\n ease: 'shutter-out',\n stagger: { each: 0.03, from },\n },\n 0.3\n )\n .to(\n p.lines,\n { yPercent: 0, rotation: 0, duration: 1, ease: 'shutter-out', stagger: 0.08 },\n 0.45\n )\n // Caption pulls focus from the centre outward.\n .to(\n p.capChars,\n {\n opacity: 1,\n yPercent: 0,\n duration: 0.8,\n ease: 'shutter-out',\n stagger: { each: 0.012, from: 'center' },\n },\n 0.65\n )\n .to(p.caption, { filter: 'blur(0px)', duration: 0.9, ease: 'shutter-out' }, 0.65)\n );\n}\n\nfunction exit(p: SlideParts, dir: number) {\n const from = dir > 0 ? 'start' : 'end';\n return (\n gsap\n .timeline()\n .to(\n p.chars,\n {\n rotationX: 95 * dir,\n opacity: 0,\n duration: 0.55,\n ease: 'shutter-in',\n stagger: { each: 0.02, from },\n },\n 0\n )\n .to(\n p.lines,\n {\n yPercent: -105 * dir,\n rotation: -1.5 * dir,\n duration: 0.5,\n ease: 'shutter-in',\n stagger: 0.05,\n },\n 0\n )\n .to(\n p.capChars,\n { opacity: 0, duration: 0.3, ease: 'none', stagger: { each: 0.008, from: 'edges' } },\n 0\n )\n // The old picture sinks back and dims under the incoming slats.\n .to(\n p.base,\n { scale: 1.12, filter: 'brightness(0.55)', duration: 1.3, ease: 'shutter-in-out' },\n 0\n )\n );\n}\n\nfunction Chevron({ back }: { back?: boolean }) {\n return (\n \n \n \n );\n}\n\nexport default function ShutterSlider({\n slides = DEFAULT_SLIDES,\n autoplay = 6,\n className,\n 'aria-label': ariaLabel = 'Highlights',\n ...props\n}: ShutterSliderProps) {\n const rootRef = useRef(null);\n const total = slides.length;\n\n useGSAP(\n (_context, contextSafe) => {\n const root = rootRef.current;\n if (!root || !total) return;\n\n const reduced = prefersReducedMotion();\n const disposers: (() => void)[] = [];\n let cancelled = false;\n\n const init = contextSafe!(() => {\n if (cancelled) return;\n\n const q = (sel: string, scope: ParentNode = root) =>\n scope.querySelector(sel)!;\n const currentEl = q('[data-shutter-count=\"current\"]');\n const nextEl = q('[data-shutter-count=\"next\"]');\n const fill = q('[data-shutter-progress]');\n\n const parts: SlideParts[] = gsap.utils\n .toArray('[data-shutter-slide]', root)\n .map((slide) => {\n const heading = q('[data-shutter-heading]', slide);\n const copy = q('[data-shutter-copy]', slide);\n const caption = slide.querySelector('[data-shutter-caption]');\n // Nothing animates under reduced motion, so the text is left unsplit.\n const lineSplit = reduced ? null : SplitText.create(copy, LINE_SPLIT);\n return {\n slide,\n heading,\n copy,\n caption,\n base: q('[data-shutter-base]', slide),\n shutter: q('[data-shutter-slats]', slide),\n slats: gsap.utils.toArray('[data-shutter-slat]', slide),\n slatImgs: gsap.utils.toArray('[data-shutter-slat] > img', slide),\n chars: reduced ? [] : SplitText.create(heading, CHAR_SPLIT).chars,\n lines: lineSplit?.lines ?? [],\n capChars: caption && !reduced ? SplitText.create(caption, CHAR_SPLIT).chars : [],\n lineSplit,\n };\n });\n\n let index = 0;\n let busy = false;\n let revealed = false;\n let inView = typeof IntersectionObserver === 'undefined';\n let hovering = false;\n let clock: gsap.core.Tween | null = null;\n\n // All slides share one grid cell, so switching is only visibility —\n // the section keeps the tallest slide's height and never jumps.\n const show = (p: SlideParts, on: boolean) =>\n gsap.set(p.slide, { visibility: on ? 'visible' : 'hidden', zIndex: on ? 1 : 'auto' });\n parts.forEach((p, i) => show(p, i === 0));\n\n // Odometer: the old number rolls out as the new one rolls in.\n const roll = (el: HTMLElement, value: number, dir: number, instant?: boolean) => {\n const old = el.lastElementChild as HTMLElement | null;\n const next = document.createElement('span');\n next.textContent = pad(value);\n el.appendChild(next);\n if (instant || !old || reduced) return old?.remove();\n gsap.fromTo(\n next,\n { yPercent: 100 * dir },\n { yPercent: 0, duration: 0.7, ease: 'shutter-out' }\n );\n gsap.to(old, {\n yPercent: -100 * dir,\n duration: 0.7,\n ease: 'shutter-out',\n onComplete: () => old.remove(),\n });\n };\n const counters = (dir: number, instant?: boolean) => {\n roll(currentEl, index + 1, dir, instant);\n roll(nextEl, ((index + 1) % total) + 1, dir, instant);\n };\n currentEl.textContent = '';\n nextEl.textContent = '';\n counters(1, true);\n\n const go = (dir: number) => {\n if (busy || total < 2) return;\n busy = true;\n const out = parts[index];\n index = (index + dir + total) % total;\n const inn = parts[index];\n counters(dir);\n root.setAttribute('data-index', String(index));\n\n show(inn, true);\n gsap.set(out.slide, { zIndex: 0 });\n\n if (reduced) {\n settle(inn);\n show(out, false);\n busy = false;\n return;\n }\n\n prime(inn, dir);\n gsap\n .timeline({\n onComplete: () => {\n show(out, false);\n gsap.set(inn.slide, { zIndex: 1 });\n busy = false;\n },\n })\n .add(exit(out, dir), 0)\n .add(enter(inn, dir), 0.15);\n };\n\n // Autoplay: the progress line is the clock.\n const stop = () => {\n clock?.kill();\n clock = null;\n gsap.set(fill, { scaleX: 0 });\n };\n const start = () => {\n stop();\n if (reduced || autoplay <= 0 || total < 2 || !inView || document.hidden) return;\n clock = gsap.fromTo(\n fill,\n { scaleX: 0 },\n {\n scaleX: 1,\n duration: autoplay,\n ease: 'none',\n paused: hovering,\n onComplete: () => {\n go(1);\n start();\n },\n }\n );\n };\n const step = (dir: number) => {\n if (busy) return;\n go(dir);\n start();\n };\n\n const listen = (\n target: HTMLElement,\n type: K,\n fn: (event: HTMLElementEventMap[K]) => void\n ) => {\n const handler = contextSafe!(fn as (event: Event) => void);\n target.addEventListener(type, handler as EventListener);\n disposers.push(() => target.removeEventListener(type, handler as EventListener));\n };\n\n listen(q('[data-shutter-prev]'), 'click', () => step(-1));\n listen(q('[data-shutter-next]'), 'click', () => step(1));\n listen(root, 'keydown', (event) => {\n if (event.key === 'ArrowLeft') step(-1);\n if (event.key === 'ArrowRight') step(1);\n });\n\n // Horizontal swipe on touch and pen.\n let sx = 0;\n let sy = 0;\n listen(root, 'pointerdown', (event) => {\n sx = event.clientX;\n sy = event.clientY;\n });\n listen(root, 'pointerup', (event) => {\n if (event.pointerType === 'mouse') return;\n const dx = event.clientX - sx;\n const dy = event.clientY - sy;\n if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);\n });\n\n if (canHover()) {\n listen(root, 'pointerenter', () => {\n hovering = true;\n clock?.pause();\n });\n listen(root, 'pointerleave', () => {\n hovering = false;\n clock?.resume();\n });\n }\n\n const onVisibility = contextSafe!(() => (document.hidden ? stop() : start()));\n document.addEventListener('visibilitychange', onVisibility);\n disposers.push(() => document.removeEventListener('visibilitychange', onVisibility));\n\n // Copy lines depend on the width they were split at: re-split after a\n // resize so each line mask matches the real wrapping again.\n let splitWidth = root.offsetWidth;\n let resizeTimer: ReturnType | undefined;\n const resplit = contextSafe!(() => {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(\n contextSafe!(() => {\n if (reduced || root.offsetWidth === splitWidth) return;\n if (busy) return resplit();\n splitWidth = root.offsetWidth;\n parts.forEach((p, i) => {\n p.lineSplit?.revert();\n p.lineSplit = SplitText.create(p.copy, LINE_SPLIT);\n p.lines = p.lineSplit.lines;\n const shown = i === index && revealed;\n gsap.set(\n p.lines,\n shown\n ? { yPercent: 0 }\n : { yPercent: 105, rotation: 2.5, transformOrigin: '0% 100%' }\n );\n });\n }),\n 150\n );\n });\n const resizeObserver = new ResizeObserver(resplit);\n resizeObserver.observe(root);\n disposers.push(() => {\n resizeObserver.disconnect();\n clearTimeout(resizeTimer);\n });\n\n // First slide plays in the first time the slider is on screen.\n const first = parts[0];\n const reveal = () => {\n if (revealed) return;\n revealed = true;\n if (reduced) settle(first);\n else enter(first, 1).delay(0.2);\n };\n\n if (reduced) settle(first);\n else prime(first, 1);\n parts.slice(1).forEach((p) => (reduced ? settle(p) : prime(p, 1)));\n root.setAttribute('data-ready', '');\n\n if (typeof IntersectionObserver === 'undefined') {\n reveal();\n start();\n } else {\n const io = new IntersectionObserver(\n contextSafe!(([entry]: IntersectionObserverEntry[]) => {\n inView = entry.isIntersecting;\n if (!inView) return stop();\n reveal();\n start();\n }),\n { threshold: 0.25 }\n );\n io.observe(root);\n disposers.push(() => io.disconnect());\n }\n\n disposers.push(() => clock?.kill());\n });\n\n // Split only once web fonts are in, so line breaks match the final text;\n // give up after a beat so a slow font can't hold the slider hidden.\n const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n if (fonts) Promise.race([fonts, new Promise((r) => setTimeout(r, 1500))]).then(init);\n else init();\n\n return () => {\n cancelled = true;\n disposers.forEach((dispose) => dispose());\n root.removeAttribute('data-ready');\n };\n },\n { scope: rootRef, dependencies: [slides, autoplay] }\n );\n\n return (\n \n
\n
\n {/* Mirrors a slide's middle row so the pagination lands just under the image. */}\n
\n
\n
\n
\n \n \n \n span]:[grid-area:1/1]\"\n >\n {pad(1)}\n \n \n
\n \n
\n \n span]:[grid-area:1/1]\"\n >\n {pad(Math.min(2, total))}\n \n \n \n
\n
\n\n {/* Hidden until GSAP has posed the first slide, so nothing flashes in place. */}\n
\n {slides.map((slide, i) => (\n 0 && 'invisible'\n )}\n >\n
\n \n {slide.heading}\n \n
\n\n
\n {/* clip-path as well as overflow: WebKit can let composited\n (scaled, will-change) children escape overflow clipping. */}\n
\n \n \n {Array.from({ length: SLATS }, (_, s) => (\n \n {/* A full-frame copy shifted into place, so the slats read as one picture. */}\n \n
\n ))}\n
\n
\n
\n\n {/* pt-16 = pagination (h-12) + the gap under it. */}\n
\n \n {slide.description}\n

\n {slide.caption && (\n \n {slide.caption}\n

\n )}\n
\n
\n ))}\n
\n
\n \n \n );\n}\n", + "codeHtml": "
'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { SplitText } from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, SplitText);\nif (!CustomEase.get('shutter-out')) CustomEase.create('shutter-out', '0.16,1,0.3,1');\nif (!CustomEase.get('shutter-in')) CustomEase.create('shutter-in', '0.6,0,0.9,0.35');\nif (!CustomEase.get('shutter-in-out')) CustomEase.create('shutter-in-out', '0.72,0,0.18,1');\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** Hover pauses autoplay — only where there is a real hover. */\nconst canHover = () =>\n  typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\n/** Vertical slats the incoming image opens through. */\nconst SLATS = 6;\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\nexport interface ShutterSlide {\n  /** Large serif title above the image. */\n  heading: string;\n  /** Image URL. Any aspect works — it is cropped to a 2:1 frame. */\n  image: string;\n  /** Alt text for the image. Empty marks it decorative. */\n  imageAlt?: string;\n  /** Paragraph under the pagination. */\n  description: string;\n  /** Short uppercase line at the bottom. */\n  caption?: string;\n}\n\nexport interface ShutterSliderProps extends ComponentPropsWithoutRef<'section'> {\n  /** Slides, in order. Defaults to a three-slide sample. */\n  slides?: ShutterSlide[];\n  /** Seconds each slide holds before advancing. `0` turns autoplay off. */\n  autoplay?: number;\n}\n\nconst unsplash = (id: string) =>\n  `https://images.unsplash.com/${id}?w=1600&h=800&fit=crop&auto=format&q=80`;\n\nconst DEFAULT_SLIDES: ShutterSlide[] = [\n  {\n    heading: 'Light first',\n    image: unsplash('photo-1512917774080-9991f1c4c750'),\n    imageAlt: 'A white modern house with floor-to-ceiling glass, a palm and a pool in the sun',\n    description:\n      'Every room is planned around the path of the sun, so mornings start bright and evenings stay warm without reaching for a switch.',\n    caption: 'Designed around daylight',\n  },\n  {\n    heading: 'Room to breathe',\n    image: unsplash('photo-1600607687939-ce8a6c25118c'),\n    imageAlt: 'An open-plan living room flowing into a kitchen under a high white ceiling',\n    description:\n      'Open plans, tall ceilings and quiet corners give every home the space to gather, to work and to slow down.',\n    caption: 'Space that adapts',\n  },\n  {\n    heading: 'Built to last',\n    image: unsplash('photo-1600585154340-be6161a56a0c'),\n    imageAlt: 'A timber-clad house lit warmly from inside at dusk, under a large tree',\n    description:\n      'Stone, timber and lime plaster, chosen to age gracefully and hold their character for decades.',\n    caption: 'Honest materials',\n  },\n];\n\ninterface SlideParts {\n  slide: HTMLElement;\n  heading: HTMLElement;\n  copy: HTMLElement;\n  caption: HTMLElement | null;\n  base: HTMLElement;\n  shutter: HTMLElement;\n  slats: HTMLElement[];\n  slatImgs: HTMLElement[];\n  chars: Element[];\n  lines: Element[];\n  capChars: Element[];\n  lineSplit: SplitText | null;\n}\n\nconst LINE_SPLIT = { type: 'lines', linesClass: 'ss-line', mask: 'lines' } as const;\nconst CHAR_SPLIT = { type: 'words,chars', wordsClass: 'ss-word', charsClass: 'ss-char' } as const;\n\n/** Resting pose: everything in place, base image showing, shutter parked. */\nfunction settle(p: SlideParts) {\n  gsap.set(p.chars, { rotationX: 0, opacity: 1 });\n  gsap.set(p.lines, { yPercent: 0, rotation: 0 });\n  gsap.set(p.capChars, { opacity: 1, yPercent: 0 });\n  if (p.caption) gsap.set(p.caption, { filter: 'none' });\n  gsap.set(p.base, { autoAlpha: 1, scale: 1, filter: 'none' });\n  gsap.set(p.shutter, { autoAlpha: 0 });\n}\n\n/** \"About to enter\" pose, mirrored for `dir` (1 = next, -1 = previous). */\nfunction prime(p: SlideParts, dir: number) {\n  // Letters turn around an axis behind them, like faces on a drum.\n  const depth = parseFloat(getComputedStyle(p.heading).fontSize || '16') * -0.4;\n  gsap.set(p.chars, { rotationX: -95 * dir, opacity: 0, transformOrigin: `50% 50% ${depth}px` });\n  gsap.set(p.lines, { yPercent: 105 * dir, rotation: 2.5 * dir, transformOrigin: '0% 100%' });\n  gsap.set(p.capChars, { opacity: 0, yPercent: 40 * dir });\n  if (p.caption) gsap.set(p.caption, { filter: 'blur(6px)' });\n  gsap.set(p.base, { autoAlpha: 0, scale: 1, filter: 'brightness(1)' });\n  gsap.set(p.shutter, { autoAlpha: 1 });\n  // Alternate slats open from opposite ends — swapped when travelling back.\n  gsap.set(p.slats, {\n    clipPath: (i: number) =>\n      (i % 2 === 0) === dir > 0 ? 'inset(100% 0% 0% 0%)' : 'inset(0% 0% 100% 0%)',\n  });\n  gsap.set(p.slatImgs, { scale: 1.25, yPercent: (i: number) => (i % 2 ? -6 : 6) * dir });\n}\n\nfunction enter(p: SlideParts, dir: number) {\n  const from = dir > 0 ? 'start' : 'end';\n  return (\n    gsap\n      .timeline()\n      // Slats open in a wave from the side we're travelling toward.\n      .to(\n        p.slats,\n        {\n          clipPath: 'inset(0% 0% 0% 0%)',\n          duration: 1.1,\n          ease: 'shutter-in-out',\n          stagger: { each: 0.07, from },\n        },\n        0\n      )\n      .to(\n        p.slatImgs,\n        {\n          scale: 1,\n          yPercent: 0,\n          duration: 1.5,\n          ease: 'shutter-out',\n          stagger: { each: 0.07, from },\n        },\n        0\n      )\n      // Hand over to the single base image so no slat seams remain at rest.\n      .set(p.base, { autoAlpha: 1 })\n      .set(p.shutter, { autoAlpha: 0 })\n      .to(\n        p.chars,\n        {\n          rotationX: 0,\n          opacity: 1,\n          duration: 1,\n          ease: 'shutter-out',\n          stagger: { each: 0.03, from },\n        },\n        0.3\n      )\n      .to(\n        p.lines,\n        { yPercent: 0, rotation: 0, duration: 1, ease: 'shutter-out', stagger: 0.08 },\n        0.45\n      )\n      // Caption pulls focus from the centre outward.\n      .to(\n        p.capChars,\n        {\n          opacity: 1,\n          yPercent: 0,\n          duration: 0.8,\n          ease: 'shutter-out',\n          stagger: { each: 0.012, from: 'center' },\n        },\n        0.65\n      )\n      .to(p.caption, { filter: 'blur(0px)', duration: 0.9, ease: 'shutter-out' }, 0.65)\n  );\n}\n\nfunction exit(p: SlideParts, dir: number) {\n  const from = dir > 0 ? 'start' : 'end';\n  return (\n    gsap\n      .timeline()\n      .to(\n        p.chars,\n        {\n          rotationX: 95 * dir,\n          opacity: 0,\n          duration: 0.55,\n          ease: 'shutter-in',\n          stagger: { each: 0.02, from },\n        },\n        0\n      )\n      .to(\n        p.lines,\n        {\n          yPercent: -105 * dir,\n          rotation: -1.5 * dir,\n          duration: 0.5,\n          ease: 'shutter-in',\n          stagger: 0.05,\n        },\n        0\n      )\n      .to(\n        p.capChars,\n        { opacity: 0, duration: 0.3, ease: 'none', stagger: { each: 0.008, from: 'edges' } },\n        0\n      )\n      // The old picture sinks back and dims under the incoming slats.\n      .to(\n        p.base,\n        { scale: 1.12, filter: 'brightness(0.55)', duration: 1.3, ease: 'shutter-in-out' },\n        0\n      )\n  );\n}\n\nfunction Chevron({ back }: { back?: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className={cn('size-4', back && 'rotate-180')}\n      fill=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"m6 3.5 4.5 4.5L6 12.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport default function ShutterSlider({\n  slides = DEFAULT_SLIDES,\n  autoplay = 6,\n  className,\n  'aria-label': ariaLabel = 'Highlights',\n  ...props\n}: ShutterSliderProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const total = slides.length;\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const root = rootRef.current;\n      if (!root || !total) return;\n\n      const reduced = prefersReducedMotion();\n      const disposers: (() => void)[] = [];\n      let cancelled = false;\n\n      const init = contextSafe!(() => {\n        if (cancelled) return;\n\n        const q = <T extends HTMLElement>(sel: string, scope: ParentNode = root) =>\n          scope.querySelector<T>(sel)!;\n        const currentEl = q('[data-shutter-count=\"current\"]');\n        const nextEl = q('[data-shutter-count=\"next\"]');\n        const fill = q('[data-shutter-progress]');\n\n        const parts: SlideParts[] = gsap.utils\n          .toArray<HTMLElement>('[data-shutter-slide]', root)\n          .map((slide) => {\n            const heading = q('[data-shutter-heading]', slide);\n            const copy = q('[data-shutter-copy]', slide);\n            const caption = slide.querySelector<HTMLElement>('[data-shutter-caption]');\n            // Nothing animates under reduced motion, so the text is left unsplit.\n            const lineSplit = reduced ? null : SplitText.create(copy, LINE_SPLIT);\n            return {\n              slide,\n              heading,\n              copy,\n              caption,\n              base: q('[data-shutter-base]', slide),\n              shutter: q('[data-shutter-slats]', slide),\n              slats: gsap.utils.toArray<HTMLElement>('[data-shutter-slat]', slide),\n              slatImgs: gsap.utils.toArray<HTMLElement>('[data-shutter-slat] > img', slide),\n              chars: reduced ? [] : SplitText.create(heading, CHAR_SPLIT).chars,\n              lines: lineSplit?.lines ?? [],\n              capChars: caption && !reduced ? SplitText.create(caption, CHAR_SPLIT).chars : [],\n              lineSplit,\n            };\n          });\n\n        let index = 0;\n        let busy = false;\n        let revealed = false;\n        let inView = typeof IntersectionObserver === 'undefined';\n        let hovering = false;\n        let clock: gsap.core.Tween | null = null;\n\n        // All slides share one grid cell, so switching is only visibility —\n        // the section keeps the tallest slide's height and never jumps.\n        const show = (p: SlideParts, on: boolean) =>\n          gsap.set(p.slide, { visibility: on ? 'visible' : 'hidden', zIndex: on ? 1 : 'auto' });\n        parts.forEach((p, i) => show(p, i === 0));\n\n        // Odometer: the old number rolls out as the new one rolls in.\n        const roll = (el: HTMLElement, value: number, dir: number, instant?: boolean) => {\n          const old = el.lastElementChild as HTMLElement | null;\n          const next = document.createElement('span');\n          next.textContent = pad(value);\n          el.appendChild(next);\n          if (instant || !old || reduced) return old?.remove();\n          gsap.fromTo(\n            next,\n            { yPercent: 100 * dir },\n            { yPercent: 0, duration: 0.7, ease: 'shutter-out' }\n          );\n          gsap.to(old, {\n            yPercent: -100 * dir,\n            duration: 0.7,\n            ease: 'shutter-out',\n            onComplete: () => old.remove(),\n          });\n        };\n        const counters = (dir: number, instant?: boolean) => {\n          roll(currentEl, index + 1, dir, instant);\n          roll(nextEl, ((index + 1) % total) + 1, dir, instant);\n        };\n        currentEl.textContent = '';\n        nextEl.textContent = '';\n        counters(1, true);\n\n        const go = (dir: number) => {\n          if (busy || total < 2) return;\n          busy = true;\n          const out = parts[index];\n          index = (index + dir + total) % total;\n          const inn = parts[index];\n          counters(dir);\n          root.setAttribute('data-index', String(index));\n\n          show(inn, true);\n          gsap.set(out.slide, { zIndex: 0 });\n\n          if (reduced) {\n            settle(inn);\n            show(out, false);\n            busy = false;\n            return;\n          }\n\n          prime(inn, dir);\n          gsap\n            .timeline({\n              onComplete: () => {\n                show(out, false);\n                gsap.set(inn.slide, { zIndex: 1 });\n                busy = false;\n              },\n            })\n            .add(exit(out, dir), 0)\n            .add(enter(inn, dir), 0.15);\n        };\n\n        // Autoplay: the progress line is the clock.\n        const stop = () => {\n          clock?.kill();\n          clock = null;\n          gsap.set(fill, { scaleX: 0 });\n        };\n        const start = () => {\n          stop();\n          if (reduced || autoplay <= 0 || total < 2 || !inView || document.hidden) return;\n          clock = gsap.fromTo(\n            fill,\n            { scaleX: 0 },\n            {\n              scaleX: 1,\n              duration: autoplay,\n              ease: 'none',\n              paused: hovering,\n              onComplete: () => {\n                go(1);\n                start();\n              },\n            }\n          );\n        };\n        const step = (dir: number) => {\n          if (busy) return;\n          go(dir);\n          start();\n        };\n\n        const listen = <K extends keyof HTMLElementEventMap>(\n          target: HTMLElement,\n          type: K,\n          fn: (event: HTMLElementEventMap[K]) => void\n        ) => {\n          const handler = contextSafe!(fn as (event: Event) => void);\n          target.addEventListener(type, handler as EventListener);\n          disposers.push(() => target.removeEventListener(type, handler as EventListener));\n        };\n\n        listen(q('[data-shutter-prev]'), 'click', () => step(-1));\n        listen(q('[data-shutter-next]'), 'click', () => step(1));\n        listen(root, 'keydown', (event) => {\n          if (event.key === 'ArrowLeft') step(-1);\n          if (event.key === 'ArrowRight') step(1);\n        });\n\n        // Horizontal swipe on touch and pen.\n        let sx = 0;\n        let sy = 0;\n        listen(root, 'pointerdown', (event) => {\n          sx = event.clientX;\n          sy = event.clientY;\n        });\n        listen(root, 'pointerup', (event) => {\n          if (event.pointerType === 'mouse') return;\n          const dx = event.clientX - sx;\n          const dy = event.clientY - sy;\n          if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);\n        });\n\n        if (canHover()) {\n          listen(root, 'pointerenter', () => {\n            hovering = true;\n            clock?.pause();\n          });\n          listen(root, 'pointerleave', () => {\n            hovering = false;\n            clock?.resume();\n          });\n        }\n\n        const onVisibility = contextSafe!(() => (document.hidden ? stop() : start()));\n        document.addEventListener('visibilitychange', onVisibility);\n        disposers.push(() => document.removeEventListener('visibilitychange', onVisibility));\n\n        // Copy lines depend on the width they were split at: re-split after a\n        // resize so each line mask matches the real wrapping again.\n        let splitWidth = root.offsetWidth;\n        let resizeTimer: ReturnType<typeof setTimeout> | undefined;\n        const resplit = contextSafe!(() => {\n          clearTimeout(resizeTimer);\n          resizeTimer = setTimeout(\n            contextSafe!(() => {\n              if (reduced || root.offsetWidth === splitWidth) return;\n              if (busy) return resplit();\n              splitWidth = root.offsetWidth;\n              parts.forEach((p, i) => {\n                p.lineSplit?.revert();\n                p.lineSplit = SplitText.create(p.copy, LINE_SPLIT);\n                p.lines = p.lineSplit.lines;\n                const shown = i === index && revealed;\n                gsap.set(\n                  p.lines,\n                  shown\n                    ? { yPercent: 0 }\n                    : { yPercent: 105, rotation: 2.5, transformOrigin: '0% 100%' }\n                );\n              });\n            }),\n            150\n          );\n        });\n        const resizeObserver = new ResizeObserver(resplit);\n        resizeObserver.observe(root);\n        disposers.push(() => {\n          resizeObserver.disconnect();\n          clearTimeout(resizeTimer);\n        });\n\n        // First slide plays in the first time the slider is on screen.\n        const first = parts[0];\n        const reveal = () => {\n          if (revealed) return;\n          revealed = true;\n          if (reduced) settle(first);\n          else enter(first, 1).delay(0.2);\n        };\n\n        if (reduced) settle(first);\n        else prime(first, 1);\n        parts.slice(1).forEach((p) => (reduced ? settle(p) : prime(p, 1)));\n        root.setAttribute('data-ready', '');\n\n        if (typeof IntersectionObserver === 'undefined') {\n          reveal();\n          start();\n        } else {\n          const io = new IntersectionObserver(\n            contextSafe!(([entry]: IntersectionObserverEntry[]) => {\n              inView = entry.isIntersecting;\n              if (!inView) return stop();\n              reveal();\n              start();\n            }),\n            { threshold: 0.25 }\n          );\n          io.observe(root);\n          disposers.push(() => io.disconnect());\n        }\n\n        disposers.push(() => clock?.kill());\n      });\n\n      // Split only once web fonts are in, so line breaks match the final text;\n      // give up after a beat so a slow font can't hold the slider hidden.\n      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n      if (fonts) Promise.race([fonts, new Promise((r) => setTimeout(r, 1500))]).then(init);\n      else init();\n\n      return () => {\n        cancelled = true;\n        disposers.forEach((dispose) => dispose());\n        root.removeAttribute('data-ready');\n      };\n    },\n    { scope: rootRef, dependencies: [slides, autoplay] }\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      data-shutter-slider\n      data-index=\"0\"\n      aria-roledescription=\"carousel\"\n      aria-label={ariaLabel}\n      className={cn(\n        'group/shutter relative overflow-hidden bg-[#eef1f4] px-6 py-20 text-[#1b2a41] md:px-10 md:py-28 dark:bg-[#15130f] dark:text-[#efe6d8]',\n        className\n      )}\n      {...props}\n    >\n      <div className=\"mx-auto grid max-w-[1200px] grid-cols-10 gap-x-4\">\n        <div className=\"relative col-span-10 flex flex-col justify-center md:col-span-8 md:col-start-2\">\n          {/* Mirrors a slide's middle row so the pagination lands just under the image. */}\n          <div className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 flex-col items-center\">\n            <div className=\"h-16 shrink-0\" />\n            <div aria-hidden=\"true\" className=\"aspect-[2/1] w-[min(100%,50rem)] shrink-0\" />\n            <div className=\"h-4 shrink-0\" />\n            <div\n              className={cn(\n                'pointer-events-auto flex h-12 items-center justify-center gap-8 text-xs text-[#a9532f] dark:text-[#e39468]',\n                total < 2 && 'hidden'\n              )}\n            >\n              <button\n                type=\"button\"\n                data-shutter-prev\n                aria-label=\"Previous slide\"\n                className=\"flex h-12 items-center gap-4 pl-2 transition-opacity hover:opacity-60 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-current motion-reduce:transition-none\"\n              >\n                <Chevron back />\n                <span\n                  data-shutter-count=\"current\"\n                  aria-hidden=\"true\"\n                  className=\"grid h-[1em] w-6 overflow-clip text-center leading-none tabular-nums [&>span]:[grid-area:1/1]\"\n                >\n                  {pad(1)}\n                </span>\n              </button>\n              <div className=\"h-px w-40 bg-[#1b2a41]/15 dark:bg-[#efe6d8]/15\">\n                <div\n                  data-shutter-progress\n                  className=\"h-full w-full origin-left scale-x-0 bg-current\"\n                />\n              </div>\n              <button\n                type=\"button\"\n                data-shutter-next\n                aria-label=\"Next slide\"\n                className=\"flex h-12 items-center gap-4 pr-2 transition-opacity hover:opacity-60 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-current motion-reduce:transition-none\"\n              >\n                <span\n                  data-shutter-count=\"next\"\n                  aria-hidden=\"true\"\n                  className=\"grid h-[1em] w-6 overflow-clip text-center leading-none tabular-nums [&>span]:[grid-area:1/1]\"\n                >\n                  {pad(Math.min(2, total))}\n                </span>\n                <Chevron />\n              </button>\n            </div>\n          </div>\n\n          {/* Hidden until GSAP has posed the first slide, so nothing flashes in place. */}\n          <div className=\"grid opacity-0 group-data-[ready]/shutter:opacity-100\">\n            {slides.map((slide, i) => (\n              <div\n                key={`${slide.heading}-${i}`}\n                data-shutter-slide\n                role=\"group\"\n                aria-roledescription=\"slide\"\n                aria-label={`${i + 1} of ${total}`}\n                className={cn(\n                  'grid min-w-0 grid-rows-[1fr_auto_1fr] [grid-area:1/1]',\n                  i > 0 && 'invisible'\n                )}\n              >\n                <div className=\"flex flex-col justify-end pb-8\">\n                  <h3\n                    data-shutter-heading\n                    className=\"text-center font-serif text-[clamp(2.5rem,6.25vw,6rem)] leading-[0.9] font-normal tracking-tight uppercase [&_.ss-char]:inline-block [&_.ss-char]:will-change-transform [&_.ss-char]:backface-hidden motion-reduce:[&_.ss-char]:will-change-auto [&_.ss-word]:inline-block [&_.ss-word]:perspective-[700px]\"\n                  >\n                    {slide.heading}\n                  </h3>\n                </div>\n\n                <div className=\"flex flex-col items-center py-4\">\n                  {/* clip-path as well as overflow: WebKit can let composited\n                      (scaled, will-change) children escape overflow clipping. */}\n                  <div className=\"relative aspect-[2/1] w-[min(100%,50rem)] shrink-0 overflow-clip [clip-path:inset(0)]\">\n                    <img\n                      data-shutter-base\n                      src={slide.image}\n                      alt={slide.imageAlt ?? ''}\n                      className=\"absolute inset-0 size-full max-w-none object-cover\"\n                      draggable={false}\n                    />\n                    <div\n                      data-shutter-slats\n                      aria-hidden=\"true\"\n                      className=\"pointer-events-none invisible absolute inset-0\"\n                    >\n                      {Array.from({ length: SLATS }, (_, s) => (\n                        <div\n                          key={s}\n                          data-shutter-slat\n                          className=\"absolute inset-y-0 overflow-clip\"\n                          style={{ left: `${(s * 100) / SLATS}%`, width: `${100 / SLATS}%` }}\n                        >\n                          {/* A full-frame copy shifted into place, so the slats read as one picture. */}\n                          <img\n                            src={slide.image}\n                            alt=\"\"\n                            className=\"absolute top-0 h-full max-w-none object-cover will-change-transform motion-reduce:will-change-auto\"\n                            style={{ width: `${SLATS * 100}%`, left: `${-s * 100}%` }}\n                            draggable={false}\n                          />\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                </div>\n\n                {/* pt-16 = pagination (h-12) + the gap under it. */}\n                <div className=\"flex flex-col pt-16\">\n                  <p\n                    data-shutter-copy\n                    className=\"mx-auto max-w-[36rem] text-center text-base leading-normal [&_.ss-line]:will-change-transform [&_.ss-line]:backface-hidden motion-reduce:[&_.ss-line]:will-change-auto\"\n                  >\n                    {slide.description}\n                  </p>\n                  {slide.caption && (\n                    <p\n                      data-shutter-caption\n                      className=\"mx-auto mt-6 max-w-[18rem] text-center text-xs leading-snug font-semibold tracking-[0.08em] uppercase [&_.ss-char]:inline-block [&_.ss-word]:inline-block\"\n                    >\n                      {slide.caption}\n                    </p>\n                  )}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n
" + } } }; diff --git a/apps/docs/registry/demos/shutter-slider.tsx b/apps/docs/registry/demos/shutter-slider.tsx new file mode 100644 index 0000000..de9fb6f --- /dev/null +++ b/apps/docs/registry/demos/shutter-slider.tsx @@ -0,0 +1,11 @@ +'use client'; + +import ShutterSlider from '@/registry/tweenui/shutter-slider'; + +export default function ShutterSliderDemo() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/registry/registry-blocks.ts b/apps/docs/registry/registry-blocks.ts index 0882eaf..2c5f4d7 100644 --- a/apps/docs/registry/registry-blocks.ts +++ b/apps/docs/registry/registry-blocks.ts @@ -682,6 +682,39 @@ export default function Example() { export default function Example() { return ; +}`, + }, + }, + { + name: 'shutter-slider', + type: 'block', + title: 'Shutter Slider', + description: + 'An autoplaying feature slider. Each image opens through vertical slats that alternate from the top and bottom, the headline letters roll in like drum faces, copy lines rise through masks and the caption pulls into focus — all mirrored when you travel back.', + isNew: true, + media: {}, + dependencies: ['gsap', '@gsap/react'], + registryDependencies: [], + cssVars: {}, + files: [ + { + path: 'registry/tweenui/shutter-slider.tsx', + target: 'components/tweenui/shutter-slider.tsx', + kind: 'react', + }, + ], + variants: [ + { + id: 'default', + label: 'Preview', + reactSource: 'registry/tweenui/shutter-slider.tsx', + }, + ], + usage: { + react: `import ShutterSlider from '@/components/tweenui/shutter-slider'; + +export default function Example() { + return ; }`, }, }, diff --git a/apps/docs/registry/tweenui/shutter-slider.tsx b/apps/docs/registry/tweenui/shutter-slider.tsx new file mode 100644 index 0000000..749dd41 --- /dev/null +++ b/apps/docs/registry/tweenui/shutter-slider.tsx @@ -0,0 +1,677 @@ +'use client'; + +import { useRef, type ComponentPropsWithoutRef } from 'react'; +import { useGSAP } from '@gsap/react'; +import gsap from 'gsap'; +import { CustomEase } from 'gsap/CustomEase'; +import { SplitText } from 'gsap/SplitText'; +import { cn } from '@/lib/utils'; + +gsap.registerPlugin(useGSAP, CustomEase, SplitText); +if (!CustomEase.get('shutter-out')) CustomEase.create('shutter-out', '0.16,1,0.3,1'); +if (!CustomEase.get('shutter-in')) CustomEase.create('shutter-in', '0.6,0,0.9,0.35'); +if (!CustomEase.get('shutter-in-out')) CustomEase.create('shutter-in-out', '0.72,0,0.18,1'); + +const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** Hover pauses autoplay — only where there is a real hover. */ +const canHover = () => + typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches; + +/** Vertical slats the incoming image opens through. */ +const SLATS = 6; + +const pad = (n: number) => String(n).padStart(2, '0'); + +export interface ShutterSlide { + /** Large serif title above the image. */ + heading: string; + /** Image URL. Any aspect works — it is cropped to a 2:1 frame. */ + image: string; + /** Alt text for the image. Empty marks it decorative. */ + imageAlt?: string; + /** Paragraph under the pagination. */ + description: string; + /** Short uppercase line at the bottom. */ + caption?: string; +} + +export interface ShutterSliderProps extends ComponentPropsWithoutRef<'section'> { + /** Slides, in order. Defaults to a three-slide sample. */ + slides?: ShutterSlide[]; + /** Seconds each slide holds before advancing. `0` turns autoplay off. */ + autoplay?: number; +} + +const unsplash = (id: string) => + `https://images.unsplash.com/${id}?w=1600&h=800&fit=crop&auto=format&q=80`; + +const DEFAULT_SLIDES: ShutterSlide[] = [ + { + heading: 'Light first', + image: unsplash('photo-1512917774080-9991f1c4c750'), + imageAlt: 'A white modern house with floor-to-ceiling glass, a palm and a pool in the sun', + description: + 'Every room is planned around the path of the sun, so mornings start bright and evenings stay warm without reaching for a switch.', + caption: 'Designed around daylight', + }, + { + heading: 'Room to breathe', + image: unsplash('photo-1600607687939-ce8a6c25118c'), + imageAlt: 'An open-plan living room flowing into a kitchen under a high white ceiling', + description: + 'Open plans, tall ceilings and quiet corners give every home the space to gather, to work and to slow down.', + caption: 'Space that adapts', + }, + { + heading: 'Built to last', + image: unsplash('photo-1600585154340-be6161a56a0c'), + imageAlt: 'A timber-clad house lit warmly from inside at dusk, under a large tree', + description: + 'Stone, timber and lime plaster, chosen to age gracefully and hold their character for decades.', + caption: 'Honest materials', + }, +]; + +interface SlideParts { + slide: HTMLElement; + heading: HTMLElement; + copy: HTMLElement; + caption: HTMLElement | null; + base: HTMLElement; + shutter: HTMLElement; + slats: HTMLElement[]; + slatImgs: HTMLElement[]; + chars: Element[]; + lines: Element[]; + capChars: Element[]; + lineSplit: SplitText | null; +} + +const LINE_SPLIT = { type: 'lines', linesClass: 'ss-line', mask: 'lines' } as const; +const CHAR_SPLIT = { type: 'words,chars', wordsClass: 'ss-word', charsClass: 'ss-char' } as const; + +/** Resting pose: everything in place, base image showing, shutter parked. */ +function settle(p: SlideParts) { + gsap.set(p.chars, { rotationX: 0, opacity: 1 }); + gsap.set(p.lines, { yPercent: 0, rotation: 0 }); + gsap.set(p.capChars, { opacity: 1, yPercent: 0 }); + if (p.caption) gsap.set(p.caption, { filter: 'none' }); + gsap.set(p.base, { autoAlpha: 1, scale: 1, filter: 'none' }); + gsap.set(p.shutter, { autoAlpha: 0 }); +} + +/** "About to enter" pose, mirrored for `dir` (1 = next, -1 = previous). */ +function prime(p: SlideParts, dir: number) { + // Letters turn around an axis behind them, like faces on a drum. + const depth = parseFloat(getComputedStyle(p.heading).fontSize || '16') * -0.4; + gsap.set(p.chars, { rotationX: -95 * dir, opacity: 0, transformOrigin: `50% 50% ${depth}px` }); + gsap.set(p.lines, { yPercent: 105 * dir, rotation: 2.5 * dir, transformOrigin: '0% 100%' }); + gsap.set(p.capChars, { opacity: 0, yPercent: 40 * dir }); + if (p.caption) gsap.set(p.caption, { filter: 'blur(6px)' }); + gsap.set(p.base, { autoAlpha: 0, scale: 1, filter: 'brightness(1)' }); + gsap.set(p.shutter, { autoAlpha: 1 }); + // Alternate slats open from opposite ends — swapped when travelling back. + gsap.set(p.slats, { + clipPath: (i: number) => + (i % 2 === 0) === dir > 0 ? 'inset(100% 0% 0% 0%)' : 'inset(0% 0% 100% 0%)', + }); + gsap.set(p.slatImgs, { scale: 1.25, yPercent: (i: number) => (i % 2 ? -6 : 6) * dir }); +} + +function enter(p: SlideParts, dir: number) { + const from = dir > 0 ? 'start' : 'end'; + return ( + gsap + .timeline() + // Slats open in a wave from the side we're travelling toward. + .to( + p.slats, + { + clipPath: 'inset(0% 0% 0% 0%)', + duration: 1.1, + ease: 'shutter-in-out', + stagger: { each: 0.07, from }, + }, + 0 + ) + .to( + p.slatImgs, + { + scale: 1, + yPercent: 0, + duration: 1.5, + ease: 'shutter-out', + stagger: { each: 0.07, from }, + }, + 0 + ) + // Hand over to the single base image so no slat seams remain at rest. + .set(p.base, { autoAlpha: 1 }) + .set(p.shutter, { autoAlpha: 0 }) + .to( + p.chars, + { + rotationX: 0, + opacity: 1, + duration: 1, + ease: 'shutter-out', + stagger: { each: 0.03, from }, + }, + 0.3 + ) + .to( + p.lines, + { yPercent: 0, rotation: 0, duration: 1, ease: 'shutter-out', stagger: 0.08 }, + 0.45 + ) + // Caption pulls focus from the centre outward. + .to( + p.capChars, + { + opacity: 1, + yPercent: 0, + duration: 0.8, + ease: 'shutter-out', + stagger: { each: 0.012, from: 'center' }, + }, + 0.65 + ) + .to(p.caption, { filter: 'blur(0px)', duration: 0.9, ease: 'shutter-out' }, 0.65) + ); +} + +function exit(p: SlideParts, dir: number) { + const from = dir > 0 ? 'start' : 'end'; + return ( + gsap + .timeline() + .to( + p.chars, + { + rotationX: 95 * dir, + opacity: 0, + duration: 0.55, + ease: 'shutter-in', + stagger: { each: 0.02, from }, + }, + 0 + ) + .to( + p.lines, + { + yPercent: -105 * dir, + rotation: -1.5 * dir, + duration: 0.5, + ease: 'shutter-in', + stagger: 0.05, + }, + 0 + ) + .to( + p.capChars, + { opacity: 0, duration: 0.3, ease: 'none', stagger: { each: 0.008, from: 'edges' } }, + 0 + ) + // The old picture sinks back and dims under the incoming slats. + .to( + p.base, + { scale: 1.12, filter: 'brightness(0.55)', duration: 1.3, ease: 'shutter-in-out' }, + 0 + ) + ); +} + +function Chevron({ back }: { back?: boolean }) { + return ( + + ); +} + +export default function ShutterSlider({ + slides = DEFAULT_SLIDES, + autoplay = 6, + className, + 'aria-label': ariaLabel = 'Highlights', + ...props +}: ShutterSliderProps) { + const rootRef = useRef(null); + const total = slides.length; + + useGSAP( + (_context, contextSafe) => { + const root = rootRef.current; + if (!root || !total) return; + + const reduced = prefersReducedMotion(); + const disposers: (() => void)[] = []; + let cancelled = false; + + const init = contextSafe!(() => { + if (cancelled) return; + + const q = (sel: string, scope: ParentNode = root) => + scope.querySelector(sel)!; + const currentEl = q('[data-shutter-count="current"]'); + const nextEl = q('[data-shutter-count="next"]'); + const fill = q('[data-shutter-progress]'); + + const parts: SlideParts[] = gsap.utils + .toArray('[data-shutter-slide]', root) + .map((slide) => { + const heading = q('[data-shutter-heading]', slide); + const copy = q('[data-shutter-copy]', slide); + const caption = slide.querySelector('[data-shutter-caption]'); + // Nothing animates under reduced motion, so the text is left unsplit. + const lineSplit = reduced ? null : SplitText.create(copy, LINE_SPLIT); + return { + slide, + heading, + copy, + caption, + base: q('[data-shutter-base]', slide), + shutter: q('[data-shutter-slats]', slide), + slats: gsap.utils.toArray('[data-shutter-slat]', slide), + slatImgs: gsap.utils.toArray('[data-shutter-slat] > img', slide), + chars: reduced ? [] : SplitText.create(heading, CHAR_SPLIT).chars, + lines: lineSplit?.lines ?? [], + capChars: caption && !reduced ? SplitText.create(caption, CHAR_SPLIT).chars : [], + lineSplit, + }; + }); + + let index = 0; + let busy = false; + let revealed = false; + let inView = typeof IntersectionObserver === 'undefined'; + let hovering = false; + let clock: gsap.core.Tween | null = null; + + // All slides share one grid cell, so switching is only visibility — + // the section keeps the tallest slide's height and never jumps. + const show = (p: SlideParts, on: boolean) => + gsap.set(p.slide, { visibility: on ? 'visible' : 'hidden', zIndex: on ? 1 : 'auto' }); + parts.forEach((p, i) => show(p, i === 0)); + + // Odometer: the old number rolls out as the new one rolls in. + const roll = (el: HTMLElement, value: number, dir: number, instant?: boolean) => { + const old = el.lastElementChild as HTMLElement | null; + const next = document.createElement('span'); + next.textContent = pad(value); + el.appendChild(next); + if (instant || !old || reduced) return old?.remove(); + gsap.fromTo( + next, + { yPercent: 100 * dir }, + { yPercent: 0, duration: 0.7, ease: 'shutter-out' } + ); + gsap.to(old, { + yPercent: -100 * dir, + duration: 0.7, + ease: 'shutter-out', + onComplete: () => old.remove(), + }); + }; + const counters = (dir: number, instant?: boolean) => { + roll(currentEl, index + 1, dir, instant); + roll(nextEl, ((index + 1) % total) + 1, dir, instant); + }; + currentEl.textContent = ''; + nextEl.textContent = ''; + counters(1, true); + + const go = (dir: number) => { + if (busy || total < 2) return; + busy = true; + const out = parts[index]; + index = (index + dir + total) % total; + const inn = parts[index]; + counters(dir); + root.setAttribute('data-index', String(index)); + + show(inn, true); + gsap.set(out.slide, { zIndex: 0 }); + + if (reduced) { + settle(inn); + show(out, false); + busy = false; + return; + } + + prime(inn, dir); + gsap + .timeline({ + onComplete: () => { + show(out, false); + gsap.set(inn.slide, { zIndex: 1 }); + busy = false; + }, + }) + .add(exit(out, dir), 0) + .add(enter(inn, dir), 0.15); + }; + + // Autoplay: the progress line is the clock. + const stop = () => { + clock?.kill(); + clock = null; + gsap.set(fill, { scaleX: 0 }); + }; + const start = () => { + stop(); + if (reduced || autoplay <= 0 || total < 2 || !inView || document.hidden) return; + clock = gsap.fromTo( + fill, + { scaleX: 0 }, + { + scaleX: 1, + duration: autoplay, + ease: 'none', + paused: hovering, + onComplete: () => { + go(1); + start(); + }, + } + ); + }; + const step = (dir: number) => { + if (busy) return; + go(dir); + start(); + }; + + const listen = ( + target: HTMLElement, + type: K, + fn: (event: HTMLElementEventMap[K]) => void + ) => { + const handler = contextSafe!(fn as (event: Event) => void); + target.addEventListener(type, handler as EventListener); + disposers.push(() => target.removeEventListener(type, handler as EventListener)); + }; + + listen(q('[data-shutter-prev]'), 'click', () => step(-1)); + listen(q('[data-shutter-next]'), 'click', () => step(1)); + listen(root, 'keydown', (event) => { + if (event.key === 'ArrowLeft') step(-1); + if (event.key === 'ArrowRight') step(1); + }); + + // Horizontal swipe on touch and pen. + let sx = 0; + let sy = 0; + listen(root, 'pointerdown', (event) => { + sx = event.clientX; + sy = event.clientY; + }); + listen(root, 'pointerup', (event) => { + if (event.pointerType === 'mouse') return; + const dx = event.clientX - sx; + const dy = event.clientY - sy; + if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1); + }); + + if (canHover()) { + listen(root, 'pointerenter', () => { + hovering = true; + clock?.pause(); + }); + listen(root, 'pointerleave', () => { + hovering = false; + clock?.resume(); + }); + } + + const onVisibility = contextSafe!(() => (document.hidden ? stop() : start())); + document.addEventListener('visibilitychange', onVisibility); + disposers.push(() => document.removeEventListener('visibilitychange', onVisibility)); + + // Copy lines depend on the width they were split at: re-split after a + // resize so each line mask matches the real wrapping again. + let splitWidth = root.offsetWidth; + let resizeTimer: ReturnType | undefined; + const resplit = contextSafe!(() => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout( + contextSafe!(() => { + if (reduced || root.offsetWidth === splitWidth) return; + if (busy) return resplit(); + splitWidth = root.offsetWidth; + parts.forEach((p, i) => { + p.lineSplit?.revert(); + p.lineSplit = SplitText.create(p.copy, LINE_SPLIT); + p.lines = p.lineSplit.lines; + const shown = i === index && revealed; + gsap.set( + p.lines, + shown + ? { yPercent: 0 } + : { yPercent: 105, rotation: 2.5, transformOrigin: '0% 100%' } + ); + }); + }), + 150 + ); + }); + const resizeObserver = new ResizeObserver(resplit); + resizeObserver.observe(root); + disposers.push(() => { + resizeObserver.disconnect(); + clearTimeout(resizeTimer); + }); + + // First slide plays in the first time the slider is on screen. + const first = parts[0]; + const reveal = () => { + if (revealed) return; + revealed = true; + if (reduced) settle(first); + else enter(first, 1).delay(0.2); + }; + + if (reduced) settle(first); + else prime(first, 1); + parts.slice(1).forEach((p) => (reduced ? settle(p) : prime(p, 1))); + root.setAttribute('data-ready', ''); + + if (typeof IntersectionObserver === 'undefined') { + reveal(); + start(); + } else { + const io = new IntersectionObserver( + contextSafe!(([entry]: IntersectionObserverEntry[]) => { + inView = entry.isIntersecting; + if (!inView) return stop(); + reveal(); + start(); + }), + { threshold: 0.25 } + ); + io.observe(root); + disposers.push(() => io.disconnect()); + } + + disposers.push(() => clock?.kill()); + }); + + // Split only once web fonts are in, so line breaks match the final text; + // give up after a beat so a slow font can't hold the slider hidden. + const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined; + if (fonts) Promise.race([fonts, new Promise((r) => setTimeout(r, 1500))]).then(init); + else init(); + + return () => { + cancelled = true; + disposers.forEach((dispose) => dispose()); + root.removeAttribute('data-ready'); + }; + }, + { scope: rootRef, dependencies: [slides, autoplay] } + ); + + return ( +
+
+
+ {/* Mirrors a slide's middle row so the pagination lands just under the image. */} +
+
+ +
+ ); +} diff --git a/apps/docs/tests/shutter-slider.test.tsx b/apps/docs/tests/shutter-slider.test.tsx new file mode 100644 index 0000000..ad8a450 --- /dev/null +++ b/apps/docs/tests/shutter-slider.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import gsap from 'gsap'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ShutterSlider, { type ShutterSlide } from '@/registry/tweenui/shutter-slider'; + +const SLIDES: ShutterSlide[] = [ + { + heading: 'Light first', + image: 'https://example.com/a.jpg', + imageAlt: 'A sunny house', + description: 'Planned around the sun.', + caption: 'Designed around daylight', + }, + { + heading: 'Room to breathe', + image: 'https://example.com/b.jpg', + description: 'Open plans and tall ceilings.', + caption: 'Space that adapts', + }, + { + heading: 'Built to last', + image: 'https://example.com/c.jpg', + description: 'Stone, timber and lime.', + }, +]; + +// SplitText needs real layout; reduced motion skips splitting and all tweens in jsdom. +const setMedia = ({ reduce = true, hover = true } = {}) => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('reduce') ? reduce : query.includes('hover') ? hover : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +}; + +const slidesOf = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-shutter-slide]')); + +/** The last span is the settled number; an outgoing one may still be rolling away. */ +const counter = (container: HTMLElement, which: 'current' | 'next') => + container.querySelector(`[data-shutter-count="${which}"]`)?.lastElementChild?.textContent; + +describe('Shutter Slider', () => { + beforeEach(() => setMedia()); + afterEach(() => vi.restoreAllMocks()); + + it('renders a labelled carousel with one labelled group per slide', () => { + render(); + + expect(screen.getByRole('region', { name: 'Why us' })).toHaveAttribute( + 'aria-roledescription', + 'carousel' + ); + const groups = screen.getAllByRole('group', { hidden: true }); + expect(groups).toHaveLength(3); + expect(groups[0]).toHaveAccessibleName('1 of 3'); + // Inactive slides are hidden, so they have no accessible name until shown. + expect(groups[2]).toHaveAttribute('aria-label', '3 of 3'); + }); + + it('renders every slide’s heading, copy and optional caption', () => { + const { container } = render(); + const [first, , last] = slidesOf(container); + + expect(first).toHaveTextContent('Light first'); + expect(first).toHaveTextContent('Planned around the sun.'); + expect(first.querySelector('[data-shutter-caption]')).toHaveTextContent( + 'Designed around daylight' + ); + expect(last.querySelector('[data-shutter-caption]')).toBeNull(); + }); + + it('gives the base image its alt text and hides the shutter slats', () => { + const { container } = render(); + const [first] = slidesOf(container); + + expect(first.querySelector('[data-shutter-base]')).toHaveAttribute('alt', 'A sunny house'); + const shutter = first.querySelector('[data-shutter-slats]')!; + expect(shutter).toHaveAttribute('aria-hidden', 'true'); + expect(shutter.querySelectorAll('[data-shutter-slat]')).toHaveLength(6); + }); + + it('shows only the first slide and numbers the pagination', () => { + const { container } = render(); + const [first, second, third] = slidesOf(container); + + expect(first.style.visibility).toBe('visible'); + expect(second.style.visibility).toBe('hidden'); + expect(third.style.visibility).toBe('hidden'); + expect(counter(container, 'current')).toBe('01'); + expect(counter(container, 'next')).toBe('02'); + }); + + it('steps forward and back with the buttons, wrapping at the ends', () => { + const { container } = render(); + const slides = slidesOf(container); + const root = container.querySelector('[data-shutter-slider]')!; + + fireEvent.click(screen.getByRole('button', { name: 'Next slide' })); + expect(root).toHaveAttribute('data-index', '1'); + expect(slides[1].style.visibility).toBe('visible'); + expect(slides[0].style.visibility).toBe('hidden'); + expect(counter(container, 'current')).toBe('02'); + expect(counter(container, 'next')).toBe('03'); + + fireEvent.click(screen.getByRole('button', { name: 'Previous slide' })); + fireEvent.click(screen.getByRole('button', { name: 'Previous slide' })); + expect(root).toHaveAttribute('data-index', '2'); + expect(slides[2].style.visibility).toBe('visible'); + expect(counter(container, 'current')).toBe('03'); + expect(counter(container, 'next')).toBe('01'); + }); + + it('steps with the arrow keys', () => { + const { container } = render(); + const root = container.querySelector('[data-shutter-slider]')!; + + fireEvent.keyDown(root, { key: 'ArrowRight' }); + expect(root).toHaveAttribute('data-index', '1'); + fireEvent.keyDown(root, { key: 'ArrowLeft' }); + expect(root).toHaveAttribute('data-index', '0'); + }); + + it('swaps instantly and never autoplays under reduced motion', () => { + const timeline = vi.spyOn(gsap, 'timeline'); + const fromTo = vi.spyOn(gsap, 'fromTo'); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next slide' })); + expect(timeline).not.toHaveBeenCalled(); + expect(fromTo).not.toHaveBeenCalled(); + }); + + it('hides the pagination for a single slide', () => { + render(); + expect( + screen.getByRole('button', { name: 'Next slide', hidden: true }).parentElement + ).toHaveClass('hidden'); + }); + + it('merges a custom className onto the section', () => { + const { container } = render(); + expect(container.querySelector('[data-shutter-slider]')).toHaveClass('mt-10'); + }); +});