diff --git a/apps/docs/components/nav/animated-icon.tsx b/apps/docs/components/nav/animated-icon.tsx index 7afa0fc..e105d3f 100644 --- a/apps/docs/components/nav/animated-icon.tsx +++ b/apps/docs/components/nav/animated-icon.tsx @@ -138,6 +138,7 @@ const PLAY: Record = { 'fold-horizontal': squeeze, 'flip-horizontal': flip, 'gallery-horizontal': flip, + 'gallery-horizontal-end': flip, cylinder: flip, images: flip, 'image-plus': flip, diff --git a/apps/docs/content/2.block/arc-carousel.mdx b/apps/docs/content/2.block/arc-carousel.mdx new file mode 100644 index 0000000..f549b47 --- /dev/null +++ b/apps/docs/content/2.block/arc-carousel.mdx @@ -0,0 +1,171 @@ +--- +title: Arc Carousel +description: A showcase carousel whose cards hang on a downward arc and swing round it as you step. +icon: gallery-horizontal-end +--- + +A showcase for a handful of pieces, laid out on a curve. The cards **hang on a +downward arc**, each one further from the centre sitting a little lower and +**turned a little more**, three to a side. **Step** and the whole arc swings +round: every card glides to its neighbour's slot on a slow, weighted curve, +while the one arriving in the middle **eases up in size** and takes a **lime +outline**. A card that wraps past the edge **keeps travelling off-screen** +while it comes back in **from the far side**, so the loop never snaps. +**Swipe**, **drag**, use the **arrow keys**, or **click a side card** to bring +it to the centre. Plain CSS transitions, no animation library. + +::component-preview{name="arc-carousel" size="xl"} +:: + +## Installation + +::tabs +:::tabs-item{label="CLI" icon="i-lucide-terminal"} + +::install-tabs{item="arc-carousel"} +:: + +::: +:::tabs-item{label="Manual" icon="i-lucide-clipboard"} + +::component-source{name="arc-carousel"} +:: + +::: +:: + +## Usage + +::tabs +:::tabs-item{label="React" icon="i-lucide-atom"} + +```tsx [app/page.tsx] +import ArcCarousel from '@/components/tweenui/arc-carousel'; + +export default function Page() { + return ; +} +``` + +::: +:: + +It ships with eight sample projects. Pass your own as `items`, and point the +button wherever the full list lives: + +```tsx [app/page.tsx] +import ArcCarousel from '@/components/tweenui/arc-carousel'; + +export default function Page() { + return ( + + ); +} +``` + +The colors are the Tween UI theme tokens, used as Tailwind classes rather than +raw hex: `tween-ink-deep` (`#03110f`) for the stage and the edge fades, +`tween-ink` (`#071d1b`) under each photo, a `tween-teal` (`#045f64`) glow +behind the arc, `tween-teal-soft` (`#9fd4d6`) for the supporting text and +`tween-lime` (`#c6f56f`) on the badge, the centre card's outline and arrow, +and the button. The CLI adds them to your `@theme`; with a manual install, +copy the CSS block from the Manual tab into your global stylesheet. Change a +token there and the block follows. + +### Autoplay + +Autoplay is on by default: a new card takes the centre every 5 seconds, and a +pause / play button sits beside the arrows with a lime ring filling up as the +current card's time runs out. Set the pace with `interval`, or turn it off with +`autoplay={false}`, which also drops the button: + +```tsx [app/page.tsx] + // a new card every 4s + // steps only when the viewer does +``` + +## Props + +| Prop | Type | Default | Description | +| ------------- | ----------------------------------------- | ------------------------------------ | -------------------------------------------------- | +| `items` | `ArcCarouselItem[]` | Eight sample projects | The cards on the arc. | +| `badge` | `string` | `'Featured work'` | Pill above the title. Pass `''` to drop it. | +| `title` | `string` | `'Projects we shipped this season.'` | Heading, and the carousel's accessible name. | +| `description` | `string` | How to browse | Line under the heading. Pass `''` to drop it. | +| `cta` | `{ label: string; href: string } \| null` | `View all projects` | Button between the arrows. Pass `null` to drop it. | +| `autoplay` | `boolean` | `true` | Step to the next card on its own. | +| `interval` | `number` | `5000` | Time each card holds the centre, in ms. | + +Each item is `{ title, image, tag?, meta?, href? }`. The `tag` is the chip on +the top left of the card and `meta` the line under the title. With an `href` +the centre card is a link and shows a round arrow. Extends native `
` +attributes (`className`, `style`, …). + +## Behavior + +- **One card size, one arc.** Every card is 312 × 430px and the slots are + fixed offsets from the centre, so the curve keeps its shape whatever the + content. Below 1024px of container width the whole arc scales to 80%, and + below 768px to 62%, so it fits a phone without relaying out. +- **Wraps without a jump.** When a card has to move from one end of the loop + to the other, a copy of it carries on off the near edge and fades, while + the card itself is placed past the far edge and slides in. The copy is + removed once its move has finished. +- **Side cards step, the centre card opens.** A click on a side card moves + the arc one step toward it instead of following its link, and a swipe or + drag of more than 50px sideways steps it the other way and swallows the + click that ends it. Vertical swipes still scroll the page. +- **The centre card steps forward.** Side cards sit under a light shade of + the stage color; the centre card loses it, grows 5%, takes a lime outline + and turns its arrow lime, so the eye lands on it first. +- **The edges fade, the arc does not stop.** The soft fades on both sides sit + above the cards, so the outer ones run out into the background instead of + being cut off by the edge of the section. +- **Light on the browser.** Cards move on `transform` and `opacity` only, + with no blur. Photos come at 1x and 2x with fixed dimensions; the five + nearest the centre load first and the rest lazily. +- **Autoplay waits for you.** It holds while the mouse rests on the cards, + while the cards have keyboard focus and while the block is off-screen, and + picks up where it left off. Stepping by hand gives the new card a full + interval. A backgrounded tab never makes it skip ahead on return. +- **It is in place before it runs.** The first layout is written into the + markup, so the arc renders already curved and nothing flashes into place + on load. + +## Accessibility + +::note +The carousel is a focusable group with `aria-roledescription="carousel"`, +named by the title, and the left and right arrow keys step it while it has +focus. Each card is a slide labelled `n of total: title`; cards off the +centre are hidden from screen readers and out of the tab order, so only the +card you are looking at can be reached. The current card is announced +politely after every step. The arrows are real buttons with labels and every +control has a visible lime focus ring. With autoplay on there is always a +labelled pause button, and the announcement goes quiet while the carousel plays +itself, so a screen reader is not interrupted every few seconds. Under +`prefers-reduced-motion: reduce` autoplay starts paused, the cards fade +between slots instead of travelling, and the centre card does not grow. +:: diff --git a/apps/docs/public/media/blocks/arc-carousel-poster.webp b/apps/docs/public/media/blocks/arc-carousel-poster.webp new file mode 100644 index 0000000..cc0a626 Binary files /dev/null and b/apps/docs/public/media/blocks/arc-carousel-poster.webp differ diff --git a/apps/docs/public/media/blocks/arc-carousel-preview.mp4 b/apps/docs/public/media/blocks/arc-carousel-preview.mp4 new file mode 100644 index 0000000..d5050b0 Binary files /dev/null and b/apps/docs/public/media/blocks/arc-carousel-preview.mp4 differ diff --git a/apps/docs/public/r/arc-carousel-demo.json b/apps/docs/public/r/arc-carousel-demo.json new file mode 100644 index 0000000..0034461 --- /dev/null +++ b/apps/docs/public/r/arc-carousel-demo.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "arc-carousel-demo", + "type": "registry:example", + "title": "Arc Carousel Demo", + "description": "Usage examples for Arc Carousel.", + "registryDependencies": [ + "https://tween-ui.vercel.app/r/arc-carousel.json" + ], + "files": [ + { + "path": "examples/arc-carousel-demo.tsx", + "type": "registry:example", + "content": "import ArcCarousel from '@/components/tweenui/arc-carousel';\n\nexport default function Page() {\n return ;\n}" + }, + { + "path": "examples/arc-carousel-demo-2.tsx", + "type": "registry:example", + "content": "import ArcCarousel from '@/components/tweenui/arc-carousel';\n\nexport default function Page() {\n return (\n \n );\n}" + }, + { + "path": "examples/arc-carousel-demo-3.tsx", + "type": "registry:example", + "content": " // a new card every 4s\n // steps only when the viewer does" + } + ] +} \ No newline at end of file diff --git a/apps/docs/public/r/arc-carousel.json b/apps/docs/public/r/arc-carousel.json new file mode 100644 index 0000000..a0fb6dc --- /dev/null +++ b/apps/docs/public/r/arc-carousel.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "arc-carousel", + "type": "registry:block", + "title": "Arc Carousel", + "description": "A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline, and a card that wraps past the edge keeps travelling off-screen while it comes back in from the far side. Swipe, drag, the arrow keys or a click on a side card all step it.", + "dependencies": [], + "registryDependencies": [], + "cssVars": { + "theme": { + "color-tween-teal": "#045f64", + "color-tween-teal-soft": "#9fd4d6", + "color-tween-lime": "#c6f56f", + "color-tween-ink": "#071d1b", + "color-tween-ink-deep": "#03110f", + "ease-tween": "cubic-bezier(0.32, 0.72, 0, 1)" + } + }, + "files": [ + { + "path": "components/tweenui/arc-carousel.tsx", + "type": "registry:component", + "target": "components/tweenui/arc-carousel.tsx", + "content": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n} from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n// Layout effects warn on the server; there is nothing to lay out there anyway\nconst useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport interface ArcCarouselItem {\n title: string;\n image: string;\n /** Category chip on the top left of the card, e.g. \"Brand identity\". */\n tag?: string;\n /** A short line under the title, e.g. a client and year or a price. */\n meta?: string;\n href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n items?: ArcCarouselItem[];\n /** The pill above the title. Pass an empty string to drop it. */\n badge?: string;\n title?: string;\n /** A line under the title. Pass an empty string to drop it. */\n description?: string;\n cta?: { label: string; href: string } | null;\n /**\n * Step to the next card on its own. On by default; pass `false` to turn it off. It\n * pauses while the pointer is over the cards, while they have keyboard focus and\n * while the block is off-screen, and adds a pause / play button with a progress ring.\n */\n autoplay?: boolean;\n /** Time each card holds the centre while autoplaying, in ms. */\n interval?: number;\n}\n\n/**\n * Where a card sits by its distance from the centre: px across, px down and degrees of\n * turn. The steps grow as they go out, so the row bends down into an arc. Cards further\n * out than the last slot carry on along the same line, invisible, which is where a\n * wrapping card leaves to and comes back from.\n */\nconst SLOTS = [\n { x: 0, y: 0, r: 0 },\n { x: 359, y: 26, r: 8.77 },\n { x: 711, y: 113, r: 18.83 },\n { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)';\nconst MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease';\n/** A swipe has to travel this far, mostly sideways, to count as one. */\nconst SWIPE = 50;\n/** How long a wrapping card's off-screen copy lives: the move, plus a frame or two. */\nconst GHOST_LIFE = 850;\n/** Autoplay never counts more than this per frame, so a backgrounded tab does not skip ahead. */\nconst MAX_FRAME = 100;\n\nconst slot = (offset: number) => {\n const t = Math.abs(offset);\n const sign = Math.sign(offset);\n const last = SLOTS.length - 1;\n let p = SLOTS[Math.min(t, last)];\n if (t > last) {\n const prev = SLOTS[last - 1];\n const n = t - last;\n p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n };\n }\n return {\n transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`,\n opacity: t <= last ? '1' : '0',\n zIndex: String(100 - t),\n };\n};\n\n/** Signed distance of card `index` from the centre, wrapped to the shortest way round. */\nconst offsetOf = (index: number, position: number, count: number) => {\n let r = (((index - position) % count) + count) % count;\n if (r > Math.floor(count / 2)) r -= count;\n return r;\n};\n\nconst wrap = (position: number, count: number) => ((position % count) + count) % count;\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\n/** Unsplash, cropped to the card's 312 × 430 at 1x and 2x. */\nconst photo = (id: string, width = 640) =>\n `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n src.includes('images.unsplash.com')\n ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n {\n title: 'Nightfall',\n tag: 'Astronomy app',\n meta: 'Stellar Co. · 2026',\n image: photo('1519681393784-d120267933ba'),\n },\n {\n title: 'Chroma Identity',\n tag: 'Brand identity',\n meta: 'Chroma Studio · 2026',\n image: photo('1541701494587-cb58502866ab'),\n },\n {\n title: 'Alpine Lodge',\n tag: 'Hospitality',\n meta: 'Moraine Hotels · 2026',\n image: photo('1493246507139-91e8fad9978e'),\n },\n {\n title: 'Metro Pulse',\n tag: 'Data visualisation',\n meta: 'City of Lakes · 2025',\n image: photo('1477959858617-67f85cf4f1df'),\n },\n {\n title: 'Orbit Motion',\n tag: '3D & motion',\n meta: 'Orbit Labs · 2025',\n image: photo('1618005198919-d3d4b5a92ead'),\n },\n {\n title: 'Open Road',\n tag: 'Campaign film',\n meta: 'Westward · 2025',\n image: photo('1500530855697-b586d89ba3ee'),\n },\n {\n title: 'Still Water',\n tag: 'Wellness app',\n meta: 'Calm Harbor · 2024',\n image: photo('1534447677768-be436bb09401'),\n },\n {\n title: 'Lago Retreats',\n tag: 'Travel platform',\n meta: 'Lago Travel · 2024',\n image: photo('1501785888041-af3ef285b470'),\n },\n];\n\nconst STEP = cn(\n 'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white',\n 'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none'\n);\n/** The soft edges the arc runs out under. They sit above the cards, below the copy. */\nconst FADE =\n 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n \n \n \n);\n\nconst PauseIcon = () => (\n \n \n \n \n);\n\nconst PlayIcon = () => (\n \n \n \n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n \n \n \n);\n\nexport default function ArcCarousel({\n items = DEFAULT_ITEMS,\n badge = 'Featured work',\n title = 'Projects we shipped this season.',\n description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.',\n cta = { label: 'View all projects', href: '#' },\n autoplay = true,\n interval = 5000,\n className,\n ...props\n}: ArcCarouselProps) {\n const count = items.length;\n const [position, setPosition] = useState(0);\n const listRef = useRef(null);\n const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n const artRefs = useRef<(HTMLDivElement | null)[]>([]);\n const state = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] });\n const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n const titleId = useId();\n const descriptionId = useId();\n const sectionRef = useRef(null);\n const ringRef = useRef(null);\n const progress = useRef(0);\n // `playing` is the viewer's choice; `held` is a pause they did not ask for (hover, focus, off-screen)\n const [playing, setPlaying] = useState(autoplay);\n const holds = useRef({ hover: false, focus: false, offscreen: false });\n const [held, setHeld] = useState(false);\n const hold = (key: keyof typeof holds.current, on: boolean) => {\n holds.current[key] = on;\n setHeld(Object.values(holds.current).some(Boolean));\n };\n\n const place = (item: HTMLElement, offset: number) => {\n const s = slot(offset);\n item.style.transform = s.transform;\n item.style.opacity = s.opacity;\n item.style.zIndex = s.zIndex;\n item.style.pointerEvents = s.opacity === '1' ? '' : 'none';\n };\n\n // Lay the cards out on their slots whenever the position changes (animated after the first layout)\n useIsoLayoutEffect(() => {\n const list = listRef.current;\n if (!list || !count) return;\n const reduced = prefersReducedMotion();\n const move = reduced ? 'opacity 0.3s ease' : MOVE;\n const art = reduced ? 'none' : ART;\n const st = state.current;\n const animate = st.offsets.length > 0;\n const delta = animate ? position - st.last : 0;\n st.last = position;\n\n itemRefs.current.forEach((item, i) => {\n if (!item) return;\n const next = offsetOf(i, position, count);\n const prev = st.offsets[i];\n\n const scaleEl = artRefs.current[i];\n if (scaleEl) {\n const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)';\n scaleEl.style.transition = animate ? art : 'none';\n scaleEl.style.transform = scale;\n if (!animate) {\n void scaleEl.offsetWidth;\n scaleEl.style.transition = art;\n }\n }\n\n if (!animate || prev === undefined) {\n item.style.transition = 'none';\n place(item, next);\n } else if (prev - delta !== next) {\n // Wrapped round: a ghost keeps travelling off-screen, the card re-enters from the far side\n const last = SLOTS.length - 1;\n const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1));\n const ghost = item.cloneNode(true) as HTMLElement;\n ghost.setAttribute('aria-hidden', 'true');\n ghost.inert = true;\n ghost.style.pointerEvents = 'none';\n ghost.style.transition = 'none';\n list.appendChild(ghost);\n const out = slot(prev - delta);\n void ghost.offsetWidth;\n ghost.style.transition = move;\n ghost.style.transform = out.transform;\n ghost.style.opacity = '0';\n st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE));\n\n item.style.transition = 'none';\n place(item, entry);\n item.style.opacity = '0';\n void item.offsetWidth;\n item.style.transition = move;\n place(item, next);\n } else {\n item.style.transition = move;\n place(item, next);\n }\n st.offsets[i] = next;\n });\n }, [position, count]);\n\n useEffect(() => () => state.current.ghosts.forEach((id) => window.clearTimeout(id)), []);\n\n const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n // Autoplay starts off for viewers who asked the system for less motion; they can still press play\n useEffect(() => {\n setPlaying(autoplay && !prefersReducedMotion());\n }, [autoplay]);\n\n // Any step, by hand or on its own, gives the new card a full interval\n useEffect(() => {\n progress.current = 0;\n ringRef.current?.style.setProperty('stroke-dashoffset', '1');\n }, [position, playing]);\n\n // The clock: counts only while playing and not held, so a pause resumes where it left off\n useEffect(() => {\n if (!playing || held || count < 2) return;\n let frame = 0;\n let last = performance.now();\n const tick = (now: number) => {\n progress.current += Math.min(now - last, MAX_FRAME) / interval;\n last = now;\n if (progress.current >= 1) {\n progress.current = 0;\n step(1);\n }\n ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current));\n frame = requestAnimationFrame(tick);\n };\n frame = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(frame);\n }, [playing, held, interval, count, step]);\n\n // Off-screen, autoplay waits\n useEffect(() => {\n const section = sectionRef.current;\n if (!autoplay || !section || typeof IntersectionObserver === 'undefined') return;\n const observer = new IntersectionObserver(([entry]) =>\n hold('offscreen', !entry.isIntersecting)\n );\n observer.observe(section);\n return () => observer.disconnect();\n }, [autoplay]);\n\n // Swipe / drag: the release can happen outside the list\n useEffect(() => {\n const onUp = (event: PointerEvent) => {\n const s = swipe.current;\n if (!s.tracking) return;\n s.tracking = false;\n const dx = event.clientX - s.x;\n const dy = event.clientY - s.y;\n if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) {\n s.swiped = true;\n step(dx < 0 ? 1 : -1);\n }\n };\n const onCancel = () => {\n swipe.current.tracking = false;\n };\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n return () => {\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n };\n }, [step]);\n\n // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n const onClickCapture = (event: MouseEvent) => {\n const s = swipe.current;\n if (s.swiped) {\n s.swiped = false;\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n // Keyboard activation (detail 0) keeps its normal behaviour\n if (event.detail === 0) return;\n const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node));\n if (index < 0) return;\n const offset = offsetOf(index, position, count);\n if (offset !== 0) {\n event.preventDefault();\n event.stopPropagation();\n step(Math.sign(offset));\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n event.preventDefault();\n step(event.key === 'ArrowRight' ? 1 : -1);\n };\n\n const current = count ? wrap(position, count) : 0;\n const active = items[current];\n\n return (\n \n {/* The teal glow the arc hangs in */}\n \n\n
\n {badge && (\n \n \n {badge}\n \n )}\n \n {title}\n \n {description && (\n \n {description}\n

\n )}\n
\n\n {/* Its own stacking context, so the cards' z-indexes stay under the edge fades */}\n {/* A mouse or pen resting on the cards holds autoplay; a finger lifts at once, so it never does */}\n event.pointerType !== 'touch' && hold('hover', true)}\n onPointerLeave={() => hold('hover', false)}\n >\n
\n {\n swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n }}\n onDragStart={(event) => event.preventDefault()}\n onClickCapture={onClickCapture}\n onKeyDown={onKeyDown}\n // Keyboard focus on the cards holds autoplay, so nothing moves while you read\n onFocus={(event) => {\n let visible = true;\n try {\n visible = event.target.matches(':focus-visible');\n } catch {\n // Older engines without :focus-visible: treat every focus as visible\n }\n if (visible) hold('focus', true);\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n hold('focus', false);\n }\n }}\n >\n {items.map((item, i) => {\n const offset = offsetOf(i, position, count);\n const isActive = offset === 0;\n const Card = item.href ? 'a' : 'div';\n // The first frame is set here too, so the arc is in place before hydration\n const first = slot(offsetOf(i, 0, count));\n return (\n {\n itemRefs.current[i] = el;\n }}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}: ${item.title}`}\n aria-hidden={isActive ? undefined : true}\n data-active={isActive ? '' : undefined}\n className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n style={{\n transform: first.transform,\n opacity: first.opacity,\n zIndex: first.zIndex,\n }}\n >\n {\n artRefs.current[i] = el;\n }}\n className=\"size-full origin-center\"\n >\n \n \n\n {/* Side cards sit back in shade; the centre card steps out of it */}\n \n {/* A scrim so the caption reads on any photo */}\n \n\n \n {item.tag ? (\n \n {item.tag}\n \n ) : (\n \n )}\n \n {pad2(i + 1)}\n / {pad2(count)}\n \n \n\n \n \n \n {item.title}\n \n {item.meta && (\n \n {item.meta}\n \n )}\n \n {item.href && (\n \n \n \n )}\n \n\n {/* The outline, drawn over the photo so it never gets clipped */}\n \n \n
\n \n );\n })}\n \n \n \n\n
\n step(-1)}\n >\n \n \n {cta && (\n \n {cta.label}\n \n \n )}\n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n {/* Time left on the current card */}\n \n \n \n \n )}\n
\n\n {/* Quiet while it plays itself, so a screen reader is not interrupted every few seconds */}\n

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

\n\n
\n
\n
\n );\n}\n" + } + ], + "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/arc-carousel\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping.\nColors come from the Tween UI theme tokens (`tween-ink-deep`, `tween-lime`, …), which the CLI adds to your `@theme`. Pass your own `items`; seven or more keep the arc full, since three cards show on each side of the centre." +} \ No newline at end of file diff --git a/apps/docs/public/r/cube-roll-item.json b/apps/docs/public/r/cube-roll-item.json index 254ddeb..5d34f52 100644 --- a/apps/docs/public/r/cube-roll-item.json +++ b/apps/docs/public/r/cube-roll-item.json @@ -17,7 +17,7 @@ "path": "components/tweenui/cube-roll-item.tsx", "type": "registry:component", "target": "components/tweenui/cube-roll-item.tsx", - "content": "'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" + "content": "'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" } ], "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/cube-roll-item" diff --git a/apps/docs/public/r/registry.json b/apps/docs/public/r/registry.json index ae7ee20..ed4d11e 100644 --- a/apps/docs/public/r/registry.json +++ b/apps/docs/public/r/registry.json @@ -1498,6 +1498,38 @@ "type": "registry:example" } ] + }, + { + "name": "arc-carousel", + "type": "registry:block", + "title": "Arc Carousel", + "description": "A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline, and a card that wraps past the edge keeps travelling off-screen while it comes back in from the far side. Swipe, drag, the arrow keys or a click on a side card all step it.", + "files": [ + { + "path": "components/tweenui/arc-carousel.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "arc-carousel-demo", + "type": "registry:example", + "title": "Arc Carousel Demo", + "description": "Usage examples for Arc Carousel.", + "files": [ + { + "path": "examples/arc-carousel-demo.tsx", + "type": "registry:example" + }, + { + "path": "examples/arc-carousel-demo-2.tsx", + "type": "registry:example" + }, + { + "path": "examples/arc-carousel-demo-3.tsx", + "type": "registry:example" + } + ] } ] } \ No newline at end of file diff --git a/apps/docs/registry/__index__.generated.tsx b/apps/docs/registry/__index__.generated.tsx index 4054920..91b3343 100644 --- a/apps/docs/registry/__index__.generated.tsx +++ b/apps/docs/registry/__index__.generated.tsx @@ -67,6 +67,7 @@ export const demos: Record = { 'navbar-pill-glide:default': dynamic(() => import('./demos/navbar-pill-glide')), 'navbar-corner-panel:default': dynamic(() => import('./demos/navbar-corner-panel')), 'footer-pulse-links:default': dynamic(() => import('./demos/footer-pulse-links')), + 'arc-carousel:default': dynamic(() => import('./demos/arc-carousel')), }; 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 3474fd6..48270e4 100644 --- a/apps/docs/registry/__media__.generated.ts +++ b/apps/docs/registry/__media__.generated.ts @@ -63,4 +63,5 @@ export const discoveredMedia: Record = { 'navbar-pill-glide': {"image":"/media/blocks/navbar-pill-glide-poster.webp","video":"/media/blocks/navbar-pill-glide-preview.mp4"}, 'navbar-corner-panel': {"image":"/media/blocks/navbar-corner-panel-poster.webp","video":"/media/blocks/navbar-corner-panel-preview.mp4"}, 'footer-pulse-links': {"image":"/media/blocks/footer-pulse-links-poster.webp","video":"/media/blocks/footer-pulse-links-preview.mp4"}, + 'arc-carousel': {"image":"/media/blocks/arc-carousel-poster.webp","video":"/media/blocks/arc-carousel-preview.mp4"}, }; diff --git a/apps/docs/registry/__sources__.generated.ts b/apps/docs/registry/__sources__.generated.ts index 35e8616..17d44db 100644 --- a/apps/docs/registry/__sources__.generated.ts +++ b/apps/docs/registry/__sources__.generated.ts @@ -4,7 +4,12 @@ export interface Snippet { codeHtml: string; } -export const cssTokens: Record = {}; +export const cssTokens: Record = { + "arc-carousel": { + "code": "@theme {\n --color-tween-teal: #045f64;\n --color-tween-teal-soft: #9fd4d6;\n --color-tween-lime: #c6f56f;\n --color-tween-ink: #071d1b;\n --color-tween-ink-deep: #03110f;\n --ease-tween: cubic-bezier(0.32, 0.72, 0, 1);\n}", + "codeHtml": "
@theme {\n  --color-tween-teal: #045f64;\n  --color-tween-teal-soft: #9fd4d6;\n  --color-tween-lime: #c6f56f;\n  --color-tween-ink: #071d1b;\n  --color-tween-ink-deep: #03110f;\n  --ease-tween: cubic-bezier(0.32, 0.72, 0, 1);\n}
" + } +}; export const usage: Record = { "icon-trail-button": { @@ -342,6 +347,12 @@ export const usage: Record = { "code": "import FooterPulseLinks from '@/components/tweenui/footer-pulse-links';\n\nexport default function Example() {\n return ;\n}", "codeHtml": "
import FooterPulseLinks from '@/components/tweenui/footer-pulse-links';\n\nexport default function Example() {\n  return <FooterPulseLinks />;\n}
" } + }, + "arc-carousel": { + "react": { + "code": "import ArcCarousel from '@/components/tweenui/arc-carousel';\n\nexport default function Example() {\n return ;\n}", + "codeHtml": "
import ArcCarousel from '@/components/tweenui/arc-carousel';\n\nexport default function Example() {\n  return <ArcCarousel />;\n}
" + } } }; @@ -606,8 +617,8 @@ export const manual: Record = { }, "cube-roll-item": { "react": { - "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
" + "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=\"bg-tween-teal dark:bg-tween-teal-soft/60 block h-px origin-left\"\n      />\n    </nav>\n  );\n}\n
" } }, "shutter-slider": { @@ -681,5 +692,11 @@ export const manual: Record = { "code": "'use client';\n\nimport { useId, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst canHover = () =>\n typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\nexport type FooterPulseSocialIcon = 'x' | 'instagram' | 'linkedin' | 'dribbble' | 'github';\n\nexport interface FooterPulseLink {\n label: string;\n href: string;\n}\n\nexport interface FooterPulseColumn {\n heading: string;\n links: FooterPulseLink[];\n}\n\nexport interface FooterPulseSocial {\n label: string;\n href: string;\n icon: FooterPulseSocialIcon;\n}\n\nexport interface FooterPulseLinksProps extends ComponentPropsWithoutRef<'footer'> {\n brand?: string;\n logo?: ReactNode;\n description?: string;\n columns?: FooterPulseColumn[];\n socials?: FooterPulseSocial[];\n legal?: FooterPulseLink[];\n copyright?: string;\n}\n\nconst REPO = 'https://github.com/StaticMania/tween-ui';\n\nconst DEFAULT_COLUMNS: FooterPulseColumn[] = [\n {\n heading: 'Components',\n links: [\n { label: 'All components', href: '/components' },\n { label: 'Icon Trail Button', href: '/component/icon-trail-button' },\n { label: 'Sliding Tabs', href: '/component/sliding-tabs' },\n { label: 'Number Counter', href: '/component/number-counter' },\n ],\n },\n {\n heading: 'Blocks',\n links: [\n { label: 'All blocks', href: '/components#blocks' },\n { label: 'Kinetic Type Ring', href: '/block/kinetic-type-ring' },\n { label: 'Pricing Plan Switch', href: '/block/pricing-plan-switch' },\n { label: 'Shutter Slider', href: '/block/shutter-slider' },\n ],\n },\n {\n heading: 'Community',\n links: [\n { label: 'GitHub', href: REPO },\n { label: 'Report an issue', href: `${REPO}/issues` },\n ],\n },\n];\n\nconst DEFAULT_SOCIALS: FooterPulseSocial[] = [{ label: 'GitHub', href: REPO, icon: 'github' }];\n\nconst DEFAULT_LEGAL: FooterPulseLink[] = [\n { label: 'MIT License', href: `${REPO}/blob/main/LICENSE` },\n { label: 'Changelog', href: `${REPO}/blob/main/CHANGELOG.md` },\n { label: 'Contributing', href: `${REPO}/blob/main/CONTRIBUTING.md` },\n];\n\nconst TWEEN_MARK = (\n \n \n \n \n \n);\n\nconst EASE_OUT = 'ease-[cubic-bezier(0.16,1,0.3,1)]';\n\nconst EASE_ROLL = 'ease-[cubic-bezier(0.45,0,0.15,1)]';\n\nconst focusRing =\n 'rounded-xs focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';\n\nconst SOCIAL_PATHS: Record = {\n x: (\n \n ),\n instagram: (\n <>\n \n \n \n \n ),\n linkedin: (\n <>\n \n \n \n \n ),\n dribbble: (\n <>\n \n \n \n \n \n ),\n github: (\n <>\n \n \n \n ),\n};\n\nconst isExternal = (href: string) => /^https?:/.test(href);\n\nconst externalProps = (href: string) =>\n isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};\n\nfunction RollLabel({ label }: { label: string }) {\n return (\n <>\n {label}\n \n {Array.from(label).map((char, index) => (\n \n {char}\n \n ))}\n \n \n );\n}\n\nfunction PulseUnderline() {\n return (\n \n \n \n );\n}\n\nfunction PulseLink({ link, className }: { link: FooterPulseLink; className?: string }) {\n return (\n \n \n \n \n );\n}\n\nfunction LinkGroup({ column }: { column: FooterPulseColumn }) {\n const headingId = useId();\n\n return (\n
\n

\n {column.heading}\n

\n
    \n {column.links.map((link) => (\n
  • \n \n
  • \n ))}\n
\n
\n );\n}\n\nconst LIFT_SETTLE_MS = 160;\n\nconst followPointer = (mark: HTMLElement, letters: HTMLElement[]) => {\n const lifts = letters.map(() => 0);\n const tilts = letters.map(() => 0);\n const pointer = { x: 0, y: 0, isInside: false };\n let centers: { x: number; y: number }[] = [];\n let frame = 0;\n let lastTime = 0;\n\n const measure = () => {\n centers = letters.map((letter) => ({\n x: letter.offsetLeft + letter.offsetWidth / 2,\n y: letter.offsetTop + letter.offsetHeight / 2,\n }));\n };\n\n const tick = (time: number) => {\n frame = 0;\n const elapsed = lastTime ? Math.min(64, time - lastTime) : 16;\n lastTime = time;\n const blend = 1 - Math.exp(-elapsed / LIFT_SETTLE_MS);\n if (centers.length !== letters.length) measure();\n\n const size = parseFloat(getComputedStyle(mark).fontSize) || 120;\n const maxLift = Math.min(70, size * 0.18);\n const radius = size * 1.9 + 170;\n const origin = mark.getBoundingClientRect();\n let isMoving = false;\n\n letters.forEach((letter, index) => {\n const center = centers[index] ?? { x: 0, y: 0 };\n const dx = pointer.x - (origin.left + center.x);\n const dy = pointer.y - (origin.top + center.y);\n const reach = pointer.isInside ? Math.max(0, 1 - Math.hypot(dx, dy) / radius) : 0;\n const eased = reach * reach * (3 - 2 * reach);\n const side = Math.max(-1, Math.min(1, dx / (radius * 0.35)));\n const lift = (lifts[index] ?? 0) + (eased * maxLift - (lifts[index] ?? 0)) * blend;\n const tilt = (tilts[index] ?? 0) + (-side * eased * 4.5 - (tilts[index] ?? 0)) * blend;\n if (Math.abs(eased * maxLift - lift) > 0.05 || Math.abs(-side * eased * 4.5 - tilt) > 0.01) {\n isMoving = true;\n }\n lifts[index] = lift;\n tilts[index] = tilt;\n letter.style.transform = `translate3d(0, ${(-lift).toFixed(3)}px, 0) rotate(${tilt.toFixed(3)}deg)`;\n });\n\n if (isMoving || pointer.isInside) {\n frame = requestAnimationFrame(tick);\n } else {\n lastTime = 0;\n }\n };\n\n const wake = () => {\n if (!frame) frame = requestAnimationFrame(tick);\n };\n\n const handleMove = (event: PointerEvent) => {\n pointer.x = event.clientX;\n pointer.y = event.clientY;\n pointer.isInside = true;\n wake();\n };\n\n const handleLeave = () => {\n pointer.isInside = false;\n wake();\n };\n\n const observer = new ResizeObserver(measure);\n observer.observe(mark);\n mark.addEventListener('pointermove', handleMove);\n mark.addEventListener('pointerleave', handleLeave);\n\n return () => {\n observer.disconnect();\n mark.removeEventListener('pointermove', handleMove);\n mark.removeEventListener('pointerleave', handleLeave);\n cancelAnimationFrame(frame);\n letters.forEach((letter) => {\n letter.style.transform = '';\n });\n };\n};\n\nexport default function FooterPulseLinks({\n brand = 'Tween UI',\n logo = TWEEN_MARK,\n description = 'GSAP & CSS animated components for React. Copy the source, own the animation.',\n columns = DEFAULT_COLUMNS,\n socials = DEFAULT_SOCIALS,\n legal = DEFAULT_LEGAL,\n copyright,\n className,\n ...props\n}: FooterPulseLinksProps) {\n const rootRef = useRef(null);\n const boxRef = useRef(null);\n const markRef = useRef(null);\n const wordRef = useRef(null);\n\n useGSAP(\n () => {\n const box = boxRef.current;\n const mark = markRef.current;\n const word = wordRef.current;\n if (!box || !mark || !word) return;\n\n let fittedFor = 0;\n const fit = () => {\n const width = box.clientWidth;\n if (!width || width === fittedFor) return;\n fittedFor = width;\n mark.style.fontSize = '';\n const size = parseFloat(getComputedStyle(mark).fontSize);\n const trailing = parseFloat(getComputedStyle(word).letterSpacing) || 0;\n const ink = word.offsetWidth - trailing;\n if (ink > 0) mark.style.fontSize = `${(size * width) / ink}px`;\n };\n const observer = new ResizeObserver(fit);\n observer.observe(box);\n fit();\n\n const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n fonts?.then(() => {\n fittedFor = 0;\n fit();\n });\n\n const letters = Array.from(word.querySelectorAll('[data-letter]'));\n let stopFollowing: (() => void) | null = null;\n const startFollowing = () => {\n if (canHover()) stopFollowing = followPointer(mark, letters);\n };\n\n const reduce = prefersReducedMotion();\n const entrance = reduce\n ? null\n : gsap.fromTo(\n letters,\n { autoAlpha: 0, y: 46 },\n {\n autoAlpha: 1,\n y: 0,\n duration: 0.62,\n ease: 'power3.out',\n stagger: 0.04,\n paused: true,\n onComplete: () => {\n gsap.set(letters, { clearProps: 'transform,opacity,visibility' });\n startFollowing();\n },\n }\n );\n\n const trigger = entrance\n ? ScrollTrigger.create({\n trigger: box,\n start: 'top 95%',\n once: true,\n onEnter: () => entrance.play(),\n })\n : null;\n\n if (reduce) startFollowing();\n\n return () => {\n observer.disconnect();\n trigger?.kill();\n entrance?.kill();\n stopFollowing?.();\n mark.style.fontSize = '';\n };\n },\n { scope: rootRef, dependencies: [brand] }\n );\n\n const notice = copyright ?? `© ${new Date().getFullYear()} ${brand}`;\n const lastWordStart = brand.lastIndexOf(' ') + 1;\n\n return (\n \n \n \n \n \n\n
\n
\n
\n \n {logo}\n {brand}\n \n

{description}

\n {socials.length > 0 ? (\n
    \n {socials.map((social) => (\n
  • \n \n \n {SOCIAL_PATHS[social.icon]}\n \n \n
  • \n ))}\n
\n ) : null}\n
\n\n \n
\n\n
\n \n \n {Array.from(brand).map((char, index) => (\n = lastWordStart\n ? 'bg-[linear-gradient(to_top,rgb(198_245_111/0.15)_13%,rgb(198_245_111/0.95)_100%)]'\n : 'bg-[linear-gradient(to_top,rgb(232_243_242/0.12)_4%,rgb(240_248_247/0.82)_38%,rgb(255_255_255/1)_100%)]'\n )}\n >\n {char}\n \n ))}\n \n

\n
\n\n
\n

\n {notice}\n

\n
    \n {legal.map((link) => (\n
  • \n \n
  • \n ))}\n
\n
\n
\n \n );\n}\n", "codeHtml": "
'use client';\n\nimport { useId, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst canHover = () =>\n  typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\nexport type FooterPulseSocialIcon = 'x' | 'instagram' | 'linkedin' | 'dribbble' | 'github';\n\nexport interface FooterPulseLink {\n  label: string;\n  href: string;\n}\n\nexport interface FooterPulseColumn {\n  heading: string;\n  links: FooterPulseLink[];\n}\n\nexport interface FooterPulseSocial {\n  label: string;\n  href: string;\n  icon: FooterPulseSocialIcon;\n}\n\nexport interface FooterPulseLinksProps extends ComponentPropsWithoutRef<'footer'> {\n  brand?: string;\n  logo?: ReactNode;\n  description?: string;\n  columns?: FooterPulseColumn[];\n  socials?: FooterPulseSocial[];\n  legal?: FooterPulseLink[];\n  copyright?: string;\n}\n\nconst REPO = 'https://github.com/StaticMania/tween-ui';\n\nconst DEFAULT_COLUMNS: FooterPulseColumn[] = [\n  {\n    heading: 'Components',\n    links: [\n      { label: 'All components', href: '/components' },\n      { label: 'Icon Trail Button', href: '/component/icon-trail-button' },\n      { label: 'Sliding Tabs', href: '/component/sliding-tabs' },\n      { label: 'Number Counter', href: '/component/number-counter' },\n    ],\n  },\n  {\n    heading: 'Blocks',\n    links: [\n      { label: 'All blocks', href: '/components#blocks' },\n      { label: 'Kinetic Type Ring', href: '/block/kinetic-type-ring' },\n      { label: 'Pricing Plan Switch', href: '/block/pricing-plan-switch' },\n      { label: 'Shutter Slider', href: '/block/shutter-slider' },\n    ],\n  },\n  {\n    heading: 'Community',\n    links: [\n      { label: 'GitHub', href: REPO },\n      { label: 'Report an issue', href: `${REPO}/issues` },\n    ],\n  },\n];\n\nconst DEFAULT_SOCIALS: FooterPulseSocial[] = [{ label: 'GitHub', href: REPO, icon: 'github' }];\n\nconst DEFAULT_LEGAL: FooterPulseLink[] = [\n  { label: 'MIT License', href: `${REPO}/blob/main/LICENSE` },\n  { label: 'Changelog', href: `${REPO}/blob/main/CHANGELOG.md` },\n  { label: 'Contributing', href: `${REPO}/blob/main/CONTRIBUTING.md` },\n];\n\nconst TWEEN_MARK = (\n  <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" className=\"size-7\">\n    <rect width=\"24\" height=\"24\" rx=\"6\" fill=\"#045f64\" />\n    <path\n      d=\"M5 19 C 9.5 8.9, 5 5, 19 5\"\n      fill=\"none\"\n      stroke=\"#c6f56f\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n    />\n    <circle cx=\"19\" cy=\"5\" r=\"1.8\" fill=\"#c6f56f\" />\n  </svg>\n);\n\nconst EASE_OUT = 'ease-[cubic-bezier(0.16,1,0.3,1)]';\n\nconst EASE_ROLL = 'ease-[cubic-bezier(0.45,0,0.15,1)]';\n\nconst focusRing =\n  'rounded-xs focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';\n\nconst SOCIAL_PATHS: Record<FooterPulseSocialIcon, ReactNode> = {\n  x: (\n    <path d=\"M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z\" />\n  ),\n  instagram: (\n    <>\n      <rect width=\"20\" height=\"20\" x=\"2\" y=\"2\" rx=\"5\" ry=\"5\" />\n      <path d=\"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z\" />\n      <line x1=\"17.5\" x2=\"17.51\" y1=\"6.5\" y2=\"6.5\" />\n    </>\n  ),\n  linkedin: (\n    <>\n      <path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\" />\n      <rect width=\"4\" height=\"12\" x=\"2\" y=\"9\" />\n      <circle cx=\"4\" cy=\"4\" r=\"2\" />\n    </>\n  ),\n  dribbble: (\n    <>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M19.13 5.09C15.22 9.14 10 10.44 2.25 10.94\" />\n      <path d=\"M21.75 12.84c-6.62-1.41-12.14 1-16.38 6.32\" />\n      <path d=\"M8.56 2.75c4.37 6 6 9.42 8 17.72\" />\n    </>\n  ),\n  github: (\n    <>\n      <path d=\"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4\" />\n      <path d=\"M9 18c-4.51 2-5-2-7-2\" />\n    </>\n  ),\n};\n\nconst isExternal = (href: string) => /^https?:/.test(href);\n\nconst externalProps = (href: string) =>\n  isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};\n\nfunction RollLabel({ label }: { label: string }) {\n  return (\n    <>\n      <span className=\"sr-only\">{label}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex h-[1.25em] overflow-hidden leading-[1.25em]\">\n        {Array.from(label).map((char, index) => (\n          <span\n            key={`${char}-${index}`}\n            data-char={char}\n            className={cn(\n              'relative inline-block whitespace-pre transition-[translate] duration-700',\n              EASE_ROLL,\n              'after:absolute after:top-full after:left-0 after:text-[#c6f56f] after:content-[attr(data-char)]',\n              'group-hover/link:-translate-y-full group-focus-visible/link:-translate-y-full',\n              'motion-reduce:transition-none'\n            )}\n            style={{ transitionDelay: `${index * 22}ms` }}\n          >\n            {char}\n          </span>\n        ))}\n      </span>\n    </>\n  );\n}\n\nfunction PulseUnderline() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 100 10\"\n      preserveAspectRatio=\"none\"\n      className=\"pointer-events-none absolute inset-x-0 -bottom-1.5 h-2 w-full overflow-visible\"\n    >\n      <path\n        d=\"M0 5 H40 L45 1 L51 9 L56 5 H100\"\n        fill=\"none\"\n        stroke=\"#c6f56f\"\n        strokeWidth=\"1.4\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        className={cn(\n          'transition-[stroke-dashoffset] duration-900 [stroke-dasharray:1_2] [stroke-dashoffset:1]',\n          EASE_ROLL,\n          'group-hover/link:[stroke-dashoffset:0] group-focus-visible/link:[stroke-dashoffset:0]',\n          'motion-reduce:transition-none'\n        )}\n      />\n    </svg>\n  );\n}\n\nfunction PulseLink({ link, className }: { link: FooterPulseLink; className?: string }) {\n  return (\n    <a\n      href={link.href}\n      {...externalProps(link.href)}\n      className={cn('group/link relative inline-flex whitespace-nowrap', focusRing, className)}\n    >\n      <RollLabel label={link.label} />\n      <PulseUnderline />\n    </a>\n  );\n}\n\nfunction LinkGroup({ column }: { column: FooterPulseColumn }) {\n  const headingId = useId();\n\n  return (\n    <div className=\"grid content-start gap-4\">\n      <p id={headingId} className=\"text-xs tracking-[0.14em] text-[#9fd4d6]/60 uppercase\">\n        {column.heading}\n      </p>\n      <ul aria-labelledby={headingId} className=\"grid justify-items-start gap-3\">\n        {column.links.map((link) => (\n          <li key={`${link.label}-${link.href}`}>\n            <PulseLink link={link} className=\"text-[15px] text-[#e8f3f2]/85\" />\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nconst LIFT_SETTLE_MS = 160;\n\nconst followPointer = (mark: HTMLElement, letters: HTMLElement[]) => {\n  const lifts = letters.map(() => 0);\n  const tilts = letters.map(() => 0);\n  const pointer = { x: 0, y: 0, isInside: false };\n  let centers: { x: number; y: number }[] = [];\n  let frame = 0;\n  let lastTime = 0;\n\n  const measure = () => {\n    centers = letters.map((letter) => ({\n      x: letter.offsetLeft + letter.offsetWidth / 2,\n      y: letter.offsetTop + letter.offsetHeight / 2,\n    }));\n  };\n\n  const tick = (time: number) => {\n    frame = 0;\n    const elapsed = lastTime ? Math.min(64, time - lastTime) : 16;\n    lastTime = time;\n    const blend = 1 - Math.exp(-elapsed / LIFT_SETTLE_MS);\n    if (centers.length !== letters.length) measure();\n\n    const size = parseFloat(getComputedStyle(mark).fontSize) || 120;\n    const maxLift = Math.min(70, size * 0.18);\n    const radius = size * 1.9 + 170;\n    const origin = mark.getBoundingClientRect();\n    let isMoving = false;\n\n    letters.forEach((letter, index) => {\n      const center = centers[index] ?? { x: 0, y: 0 };\n      const dx = pointer.x - (origin.left + center.x);\n      const dy = pointer.y - (origin.top + center.y);\n      const reach = pointer.isInside ? Math.max(0, 1 - Math.hypot(dx, dy) / radius) : 0;\n      const eased = reach * reach * (3 - 2 * reach);\n      const side = Math.max(-1, Math.min(1, dx / (radius * 0.35)));\n      const lift = (lifts[index] ?? 0) + (eased * maxLift - (lifts[index] ?? 0)) * blend;\n      const tilt = (tilts[index] ?? 0) + (-side * eased * 4.5 - (tilts[index] ?? 0)) * blend;\n      if (Math.abs(eased * maxLift - lift) > 0.05 || Math.abs(-side * eased * 4.5 - tilt) > 0.01) {\n        isMoving = true;\n      }\n      lifts[index] = lift;\n      tilts[index] = tilt;\n      letter.style.transform = `translate3d(0, ${(-lift).toFixed(3)}px, 0) rotate(${tilt.toFixed(3)}deg)`;\n    });\n\n    if (isMoving || pointer.isInside) {\n      frame = requestAnimationFrame(tick);\n    } else {\n      lastTime = 0;\n    }\n  };\n\n  const wake = () => {\n    if (!frame) frame = requestAnimationFrame(tick);\n  };\n\n  const handleMove = (event: PointerEvent) => {\n    pointer.x = event.clientX;\n    pointer.y = event.clientY;\n    pointer.isInside = true;\n    wake();\n  };\n\n  const handleLeave = () => {\n    pointer.isInside = false;\n    wake();\n  };\n\n  const observer = new ResizeObserver(measure);\n  observer.observe(mark);\n  mark.addEventListener('pointermove', handleMove);\n  mark.addEventListener('pointerleave', handleLeave);\n\n  return () => {\n    observer.disconnect();\n    mark.removeEventListener('pointermove', handleMove);\n    mark.removeEventListener('pointerleave', handleLeave);\n    cancelAnimationFrame(frame);\n    letters.forEach((letter) => {\n      letter.style.transform = '';\n    });\n  };\n};\n\nexport default function FooterPulseLinks({\n  brand = 'Tween UI',\n  logo = TWEEN_MARK,\n  description = 'GSAP & CSS animated components for React. Copy the source, own the animation.',\n  columns = DEFAULT_COLUMNS,\n  socials = DEFAULT_SOCIALS,\n  legal = DEFAULT_LEGAL,\n  copyright,\n  className,\n  ...props\n}: FooterPulseLinksProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const markRef = useRef<HTMLParagraphElement>(null);\n  const wordRef = useRef<HTMLSpanElement>(null);\n\n  useGSAP(\n    () => {\n      const box = boxRef.current;\n      const mark = markRef.current;\n      const word = wordRef.current;\n      if (!box || !mark || !word) return;\n\n      let fittedFor = 0;\n      const fit = () => {\n        const width = box.clientWidth;\n        if (!width || width === fittedFor) return;\n        fittedFor = width;\n        mark.style.fontSize = '';\n        const size = parseFloat(getComputedStyle(mark).fontSize);\n        const trailing = parseFloat(getComputedStyle(word).letterSpacing) || 0;\n        const ink = word.offsetWidth - trailing;\n        if (ink > 0) mark.style.fontSize = `${(size * width) / ink}px`;\n      };\n      const observer = new ResizeObserver(fit);\n      observer.observe(box);\n      fit();\n\n      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n      fonts?.then(() => {\n        fittedFor = 0;\n        fit();\n      });\n\n      const letters = Array.from(word.querySelectorAll<HTMLElement>('[data-letter]'));\n      let stopFollowing: (() => void) | null = null;\n      const startFollowing = () => {\n        if (canHover()) stopFollowing = followPointer(mark, letters);\n      };\n\n      const reduce = prefersReducedMotion();\n      const entrance = reduce\n        ? null\n        : gsap.fromTo(\n            letters,\n            { autoAlpha: 0, y: 46 },\n            {\n              autoAlpha: 1,\n              y: 0,\n              duration: 0.62,\n              ease: 'power3.out',\n              stagger: 0.04,\n              paused: true,\n              onComplete: () => {\n                gsap.set(letters, { clearProps: 'transform,opacity,visibility' });\n                startFollowing();\n              },\n            }\n          );\n\n      const trigger = entrance\n        ? ScrollTrigger.create({\n            trigger: box,\n            start: 'top 95%',\n            once: true,\n            onEnter: () => entrance.play(),\n          })\n        : null;\n\n      if (reduce) startFollowing();\n\n      return () => {\n        observer.disconnect();\n        trigger?.kill();\n        entrance?.kill();\n        stopFollowing?.();\n        mark.style.fontSize = '';\n      };\n    },\n    { scope: rootRef, dependencies: [brand] }\n  );\n\n  const notice = copyright ?? `© ${new Date().getFullYear()} ${brand}`;\n  const lastWordStart = brand.lastIndexOf(' ') + 1;\n\n  return (\n    <footer\n      ref={rootRef}\n      className={cn(\n        'relative isolate w-full overflow-hidden border-t border-[#9fd4d6]/12 bg-[#03110f] pt-16 pb-8 text-[#e8f3f2] max-sm:pt-12',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 -z-10 h-[420px] max-sm:h-[300px]\"\n      >\n        <span className=\"absolute top-full left-1/2 h-[200%] w-[150%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(4_95_100/0.6)_0%,rgb(4_95_100/0.24)_45%,rgb(4_95_100/0)_100%)]\" />\n        <span className=\"absolute top-full left-1/2 h-[110%] w-[70%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(198_245_111/0.16)_0%,rgb(198_245_111/0.05)_55%,rgb(198_245_111/0)_100%)] mix-blend-plus-lighter\" />\n      </div>\n\n      <div className=\"mx-auto grid w-full max-w-[1320px] gap-4 px-5 max-sm:gap-10 sm:px-8\">\n        <div className=\"flex flex-wrap items-start justify-between gap-12 max-md:flex-col max-md:gap-10\">\n          <div className=\"grid w-[300px] justify-items-start gap-6 max-md:w-full\">\n            <a href=\"/\" className={cn('flex items-center gap-2.5', focusRing)}>\n              {logo}\n              <span className=\"text-lg font-semibold tracking-tight\">{brand}</span>\n            </a>\n            <p className=\"text-[15px] leading-relaxed text-[#9fd4d6]/70\">{description}</p>\n            {socials.length > 0 ? (\n              <ul aria-label=\"Social links\" className=\"flex gap-3\">\n                {socials.map((social) => (\n                  <li key={social.href}>\n                    <a\n                      href={social.href}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      aria-label={`${brand} on ${social.label}`}\n                      className={cn(\n                        'group/social relative flex size-11 items-center justify-center rounded-full opacity-70',\n                        'bg-[linear-gradient(175deg,rgb(159_212_214/0.36)_0%,rgb(159_212_214/0)_19%,rgb(159_212_214/1)_41%,rgb(159_212_214/0)_77%,rgb(159_212_214/0.05)_100%)]',\n                        \"before:absolute before:inset-px before:rounded-full before:bg-[#03110f] before:transition-colors before:duration-300 before:content-['']\",\n                        'transition-[opacity,translate] duration-500 hover:-translate-y-0.5 hover:opacity-100 hover:before:bg-[#071d1b]',\n                        EASE_OUT,\n                        'focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f]',\n                        'motion-reduce:transition-none motion-reduce:hover:translate-y-0'\n                      )}\n                    >\n                      <svg\n                        aria-hidden=\"true\"\n                        viewBox=\"0 0 24 24\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"2\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        className=\"relative size-4 text-[#e8f3f2] transition-colors duration-300 group-hover/social:text-[#c6f56f] motion-reduce:transition-none\"\n                      >\n                        {SOCIAL_PATHS[social.icon]}\n                      </svg>\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            ) : null}\n          </div>\n\n          <nav aria-label=\"Footer\" className=\"flex flex-wrap gap-x-20 gap-y-10 max-lg:gap-x-12\">\n            {columns.map((column) => (\n              <LinkGroup key={column.heading} column={column} />\n            ))}\n          </nav>\n        </div>\n\n        <div ref={boxRef} className=\"min-w-0\">\n          <p\n            ref={markRef}\n            aria-hidden=\"true\"\n            className=\"relative pt-6 pb-2 text-center text-[length:min(20vw,240px)] leading-none font-bold tracking-[0.02em] whitespace-nowrap uppercase select-none\"\n          >\n            <span ref={wordRef} className=\"-mr-[0.02em] inline-block\">\n              {Array.from(brand).map((char, index) => (\n                <span\n                  key={`${char}-${index}`}\n                  data-letter=\"\"\n                  className={cn(\n                    'inline-block bg-clip-text whitespace-pre text-transparent will-change-transform',\n                    index >= lastWordStart\n                      ? 'bg-[linear-gradient(to_top,rgb(198_245_111/0.15)_13%,rgb(198_245_111/0.95)_100%)]'\n                      : 'bg-[linear-gradient(to_top,rgb(232_243_242/0.12)_4%,rgb(240_248_247/0.82)_38%,rgb(255_255_255/1)_100%)]'\n                  )}\n                >\n                  {char}\n                </span>\n              ))}\n            </span>\n          </p>\n        </div>\n\n        <div className=\"flex flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-[#9fd4d6]/12 pt-6 max-sm:flex-col max-sm:items-start\">\n          <p className=\"text-xs tracking-[0.14em] whitespace-nowrap text-[#9fd4d6]/65 uppercase\">\n            {notice}\n          </p>\n          <ul className=\"flex flex-wrap items-center gap-x-8 gap-y-3 max-sm:gap-x-6\">\n            {legal.map((link) => (\n              <li key={`${link.label}-${link.href}`}>\n                <PulseLink\n                  link={link}\n                  className=\"text-xs tracking-[0.14em] text-[#9fd4d6]/65 uppercase\"\n                />\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n    </footer>\n  );\n}\n
" } + }, + "arc-carousel": { + "react": { + "code": "'use client';\n\nimport {\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n type KeyboardEvent,\n type MouseEvent,\n} from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n// Layout effects warn on the server; there is nothing to lay out there anyway\nconst useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport interface ArcCarouselItem {\n title: string;\n image: string;\n /** Category chip on the top left of the card, e.g. \"Brand identity\". */\n tag?: string;\n /** A short line under the title, e.g. a client and year or a price. */\n meta?: string;\n href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n items?: ArcCarouselItem[];\n /** The pill above the title. Pass an empty string to drop it. */\n badge?: string;\n title?: string;\n /** A line under the title. Pass an empty string to drop it. */\n description?: string;\n cta?: { label: string; href: string } | null;\n /**\n * Step to the next card on its own. On by default; pass `false` to turn it off. It\n * pauses while the pointer is over the cards, while they have keyboard focus and\n * while the block is off-screen, and adds a pause / play button with a progress ring.\n */\n autoplay?: boolean;\n /** Time each card holds the centre while autoplaying, in ms. */\n interval?: number;\n}\n\n/**\n * Where a card sits by its distance from the centre: px across, px down and degrees of\n * turn. The steps grow as they go out, so the row bends down into an arc. Cards further\n * out than the last slot carry on along the same line, invisible, which is where a\n * wrapping card leaves to and comes back from.\n */\nconst SLOTS = [\n { x: 0, y: 0, r: 0 },\n { x: 359, y: 26, r: 8.77 },\n { x: 711, y: 113, r: 18.83 },\n { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)';\nconst MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease';\n/** A swipe has to travel this far, mostly sideways, to count as one. */\nconst SWIPE = 50;\n/** How long a wrapping card's off-screen copy lives: the move, plus a frame or two. */\nconst GHOST_LIFE = 850;\n/** Autoplay never counts more than this per frame, so a backgrounded tab does not skip ahead. */\nconst MAX_FRAME = 100;\n\nconst slot = (offset: number) => {\n const t = Math.abs(offset);\n const sign = Math.sign(offset);\n const last = SLOTS.length - 1;\n let p = SLOTS[Math.min(t, last)];\n if (t > last) {\n const prev = SLOTS[last - 1];\n const n = t - last;\n p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n };\n }\n return {\n transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`,\n opacity: t <= last ? '1' : '0',\n zIndex: String(100 - t),\n };\n};\n\n/** Signed distance of card `index` from the centre, wrapped to the shortest way round. */\nconst offsetOf = (index: number, position: number, count: number) => {\n let r = (((index - position) % count) + count) % count;\n if (r > Math.floor(count / 2)) r -= count;\n return r;\n};\n\nconst wrap = (position: number, count: number) => ((position % count) + count) % count;\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\n/** Unsplash, cropped to the card's 312 × 430 at 1x and 2x. */\nconst photo = (id: string, width = 640) =>\n `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n src.includes('images.unsplash.com')\n ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n {\n title: 'Nightfall',\n tag: 'Astronomy app',\n meta: 'Stellar Co. · 2026',\n image: photo('1519681393784-d120267933ba'),\n },\n {\n title: 'Chroma Identity',\n tag: 'Brand identity',\n meta: 'Chroma Studio · 2026',\n image: photo('1541701494587-cb58502866ab'),\n },\n {\n title: 'Alpine Lodge',\n tag: 'Hospitality',\n meta: 'Moraine Hotels · 2026',\n image: photo('1493246507139-91e8fad9978e'),\n },\n {\n title: 'Metro Pulse',\n tag: 'Data visualisation',\n meta: 'City of Lakes · 2025',\n image: photo('1477959858617-67f85cf4f1df'),\n },\n {\n title: 'Orbit Motion',\n tag: '3D & motion',\n meta: 'Orbit Labs · 2025',\n image: photo('1618005198919-d3d4b5a92ead'),\n },\n {\n title: 'Open Road',\n tag: 'Campaign film',\n meta: 'Westward · 2025',\n image: photo('1500530855697-b586d89ba3ee'),\n },\n {\n title: 'Still Water',\n tag: 'Wellness app',\n meta: 'Calm Harbor · 2024',\n image: photo('1534447677768-be436bb09401'),\n },\n {\n title: 'Lago Retreats',\n tag: 'Travel platform',\n meta: 'Lago Travel · 2024',\n image: photo('1501785888041-af3ef285b470'),\n },\n];\n\nconst STEP = cn(\n 'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white',\n 'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none'\n);\n/** The soft edges the arc runs out under. They sit above the cards, below the copy. */\nconst FADE =\n 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n \n \n \n);\n\nconst PauseIcon = () => (\n \n \n \n \n);\n\nconst PlayIcon = () => (\n \n \n \n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n \n \n \n);\n\nexport default function ArcCarousel({\n items = DEFAULT_ITEMS,\n badge = 'Featured work',\n title = 'Projects we shipped this season.',\n description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.',\n cta = { label: 'View all projects', href: '#' },\n autoplay = true,\n interval = 5000,\n className,\n ...props\n}: ArcCarouselProps) {\n const count = items.length;\n const [position, setPosition] = useState(0);\n const listRef = useRef(null);\n const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n const artRefs = useRef<(HTMLDivElement | null)[]>([]);\n const state = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] });\n const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n const titleId = useId();\n const descriptionId = useId();\n const sectionRef = useRef(null);\n const ringRef = useRef(null);\n const progress = useRef(0);\n // `playing` is the viewer's choice; `held` is a pause they did not ask for (hover, focus, off-screen)\n const [playing, setPlaying] = useState(autoplay);\n const holds = useRef({ hover: false, focus: false, offscreen: false });\n const [held, setHeld] = useState(false);\n const hold = (key: keyof typeof holds.current, on: boolean) => {\n holds.current[key] = on;\n setHeld(Object.values(holds.current).some(Boolean));\n };\n\n const place = (item: HTMLElement, offset: number) => {\n const s = slot(offset);\n item.style.transform = s.transform;\n item.style.opacity = s.opacity;\n item.style.zIndex = s.zIndex;\n item.style.pointerEvents = s.opacity === '1' ? '' : 'none';\n };\n\n // Lay the cards out on their slots whenever the position changes (animated after the first layout)\n useIsoLayoutEffect(() => {\n const list = listRef.current;\n if (!list || !count) return;\n const reduced = prefersReducedMotion();\n const move = reduced ? 'opacity 0.3s ease' : MOVE;\n const art = reduced ? 'none' : ART;\n const st = state.current;\n const animate = st.offsets.length > 0;\n const delta = animate ? position - st.last : 0;\n st.last = position;\n\n itemRefs.current.forEach((item, i) => {\n if (!item) return;\n const next = offsetOf(i, position, count);\n const prev = st.offsets[i];\n\n const scaleEl = artRefs.current[i];\n if (scaleEl) {\n const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)';\n scaleEl.style.transition = animate ? art : 'none';\n scaleEl.style.transform = scale;\n if (!animate) {\n void scaleEl.offsetWidth;\n scaleEl.style.transition = art;\n }\n }\n\n if (!animate || prev === undefined) {\n item.style.transition = 'none';\n place(item, next);\n } else if (prev - delta !== next) {\n // Wrapped round: a ghost keeps travelling off-screen, the card re-enters from the far side\n const last = SLOTS.length - 1;\n const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1));\n const ghost = item.cloneNode(true) as HTMLElement;\n ghost.setAttribute('aria-hidden', 'true');\n ghost.inert = true;\n ghost.style.pointerEvents = 'none';\n ghost.style.transition = 'none';\n list.appendChild(ghost);\n const out = slot(prev - delta);\n void ghost.offsetWidth;\n ghost.style.transition = move;\n ghost.style.transform = out.transform;\n ghost.style.opacity = '0';\n st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE));\n\n item.style.transition = 'none';\n place(item, entry);\n item.style.opacity = '0';\n void item.offsetWidth;\n item.style.transition = move;\n place(item, next);\n } else {\n item.style.transition = move;\n place(item, next);\n }\n st.offsets[i] = next;\n });\n }, [position, count]);\n\n useEffect(() => () => state.current.ghosts.forEach((id) => window.clearTimeout(id)), []);\n\n const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n // Autoplay starts off for viewers who asked the system for less motion; they can still press play\n useEffect(() => {\n setPlaying(autoplay && !prefersReducedMotion());\n }, [autoplay]);\n\n // Any step, by hand or on its own, gives the new card a full interval\n useEffect(() => {\n progress.current = 0;\n ringRef.current?.style.setProperty('stroke-dashoffset', '1');\n }, [position, playing]);\n\n // The clock: counts only while playing and not held, so a pause resumes where it left off\n useEffect(() => {\n if (!playing || held || count < 2) return;\n let frame = 0;\n let last = performance.now();\n const tick = (now: number) => {\n progress.current += Math.min(now - last, MAX_FRAME) / interval;\n last = now;\n if (progress.current >= 1) {\n progress.current = 0;\n step(1);\n }\n ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current));\n frame = requestAnimationFrame(tick);\n };\n frame = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(frame);\n }, [playing, held, interval, count, step]);\n\n // Off-screen, autoplay waits\n useEffect(() => {\n const section = sectionRef.current;\n if (!autoplay || !section || typeof IntersectionObserver === 'undefined') return;\n const observer = new IntersectionObserver(([entry]) =>\n hold('offscreen', !entry.isIntersecting)\n );\n observer.observe(section);\n return () => observer.disconnect();\n }, [autoplay]);\n\n // Swipe / drag: the release can happen outside the list\n useEffect(() => {\n const onUp = (event: PointerEvent) => {\n const s = swipe.current;\n if (!s.tracking) return;\n s.tracking = false;\n const dx = event.clientX - s.x;\n const dy = event.clientY - s.y;\n if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) {\n s.swiped = true;\n step(dx < 0 ? 1 : -1);\n }\n };\n const onCancel = () => {\n swipe.current.tracking = false;\n };\n window.addEventListener('pointerup', onUp);\n window.addEventListener('pointercancel', onCancel);\n return () => {\n window.removeEventListener('pointerup', onUp);\n window.removeEventListener('pointercancel', onCancel);\n };\n }, [step]);\n\n // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n const onClickCapture = (event: MouseEvent) => {\n const s = swipe.current;\n if (s.swiped) {\n s.swiped = false;\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n // Keyboard activation (detail 0) keeps its normal behaviour\n if (event.detail === 0) return;\n const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node));\n if (index < 0) return;\n const offset = offsetOf(index, position, count);\n if (offset !== 0) {\n event.preventDefault();\n event.stopPropagation();\n step(Math.sign(offset));\n }\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n event.preventDefault();\n step(event.key === 'ArrowRight' ? 1 : -1);\n };\n\n const current = count ? wrap(position, count) : 0;\n const active = items[current];\n\n return (\n \n {/* The teal glow the arc hangs in */}\n \n\n
\n {badge && (\n \n \n {badge}\n \n )}\n \n {title}\n \n {description && (\n \n {description}\n

\n )}\n
\n\n {/* Its own stacking context, so the cards' z-indexes stay under the edge fades */}\n {/* A mouse or pen resting on the cards holds autoplay; a finger lifts at once, so it never does */}\n event.pointerType !== 'touch' && hold('hover', true)}\n onPointerLeave={() => hold('hover', false)}\n >\n
\n {\n swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n }}\n onDragStart={(event) => event.preventDefault()}\n onClickCapture={onClickCapture}\n onKeyDown={onKeyDown}\n // Keyboard focus on the cards holds autoplay, so nothing moves while you read\n onFocus={(event) => {\n let visible = true;\n try {\n visible = event.target.matches(':focus-visible');\n } catch {\n // Older engines without :focus-visible: treat every focus as visible\n }\n if (visible) hold('focus', true);\n }}\n onBlur={(event) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n hold('focus', false);\n }\n }}\n >\n {items.map((item, i) => {\n const offset = offsetOf(i, position, count);\n const isActive = offset === 0;\n const Card = item.href ? 'a' : 'div';\n // The first frame is set here too, so the arc is in place before hydration\n const first = slot(offsetOf(i, 0, count));\n return (\n {\n itemRefs.current[i] = el;\n }}\n role=\"group\"\n aria-roledescription=\"slide\"\n aria-label={`${i + 1} of ${count}: ${item.title}`}\n aria-hidden={isActive ? undefined : true}\n data-active={isActive ? '' : undefined}\n className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n style={{\n transform: first.transform,\n opacity: first.opacity,\n zIndex: first.zIndex,\n }}\n >\n {\n artRefs.current[i] = el;\n }}\n className=\"size-full origin-center\"\n >\n \n \n\n {/* Side cards sit back in shade; the centre card steps out of it */}\n \n {/* A scrim so the caption reads on any photo */}\n \n\n \n {item.tag ? (\n \n {item.tag}\n \n ) : (\n \n )}\n \n {pad2(i + 1)}\n / {pad2(count)}\n \n \n\n \n \n \n {item.title}\n \n {item.meta && (\n \n {item.meta}\n \n )}\n \n {item.href && (\n \n \n \n )}\n \n\n {/* The outline, drawn over the photo so it never gets clipped */}\n \n \n
\n \n );\n })}\n \n \n \n\n
\n step(-1)}\n >\n \n \n {cta && (\n \n {cta.label}\n \n \n )}\n \n {autoplay && (\n setPlaying((on) => !on)}\n >\n {playing ? : }\n {/* Time left on the current card */}\n \n \n \n \n )}\n
\n\n {/* Quiet while it plays itself, so a screen reader is not interrupted every few seconds */}\n

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

\n\n
\n
\n \n );\n}\n", + "codeHtml": "
'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  type MouseEvent,\n} from 'react';\nimport { cn } from '@/lib/utils';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n// Layout effects warn on the server; there is nothing to lay out there anyway\nconst useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\nexport interface ArcCarouselItem {\n  title: string;\n  image: string;\n  /** Category chip on the top left of the card, e.g. \"Brand identity\". */\n  tag?: string;\n  /** A short line under the title, e.g. a client and year or a price. */\n  meta?: string;\n  href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n  items?: ArcCarouselItem[];\n  /** The pill above the title. Pass an empty string to drop it. */\n  badge?: string;\n  title?: string;\n  /** A line under the title. Pass an empty string to drop it. */\n  description?: string;\n  cta?: { label: string; href: string } | null;\n  /**\n   * Step to the next card on its own. On by default; pass `false` to turn it off. It\n   * pauses while the pointer is over the cards, while they have keyboard focus and\n   * while the block is off-screen, and adds a pause / play button with a progress ring.\n   */\n  autoplay?: boolean;\n  /** Time each card holds the centre while autoplaying, in ms. */\n  interval?: number;\n}\n\n/**\n * Where a card sits by its distance from the centre: px across, px down and degrees of\n * turn. The steps grow as they go out, so the row bends down into an arc. Cards further\n * out than the last slot carry on along the same line, invisible, which is where a\n * wrapping card leaves to and comes back from.\n */\nconst SLOTS = [\n  { x: 0, y: 0, r: 0 },\n  { x: 359, y: 26, r: 8.77 },\n  { x: 711, y: 113, r: 18.83 },\n  { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)';\nconst MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease';\n/** A swipe has to travel this far, mostly sideways, to count as one. */\nconst SWIPE = 50;\n/** How long a wrapping card's off-screen copy lives: the move, plus a frame or two. */\nconst GHOST_LIFE = 850;\n/** Autoplay never counts more than this per frame, so a backgrounded tab does not skip ahead. */\nconst MAX_FRAME = 100;\n\nconst slot = (offset: number) => {\n  const t = Math.abs(offset);\n  const sign = Math.sign(offset);\n  const last = SLOTS.length - 1;\n  let p = SLOTS[Math.min(t, last)];\n  if (t > last) {\n    const prev = SLOTS[last - 1];\n    const n = t - last;\n    p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n };\n  }\n  return {\n    transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`,\n    opacity: t <= last ? '1' : '0',\n    zIndex: String(100 - t),\n  };\n};\n\n/** Signed distance of card `index` from the centre, wrapped to the shortest way round. */\nconst offsetOf = (index: number, position: number, count: number) => {\n  let r = (((index - position) % count) + count) % count;\n  if (r > Math.floor(count / 2)) r -= count;\n  return r;\n};\n\nconst wrap = (position: number, count: number) => ((position % count) + count) % count;\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\n/** Unsplash, cropped to the card's 312 × 430 at 1x and 2x. */\nconst photo = (id: string, width = 640) =>\n  `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n  src.includes('images.unsplash.com')\n    ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n    : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n  {\n    title: 'Nightfall',\n    tag: 'Astronomy app',\n    meta: 'Stellar Co. · 2026',\n    image: photo('1519681393784-d120267933ba'),\n  },\n  {\n    title: 'Chroma Identity',\n    tag: 'Brand identity',\n    meta: 'Chroma Studio · 2026',\n    image: photo('1541701494587-cb58502866ab'),\n  },\n  {\n    title: 'Alpine Lodge',\n    tag: 'Hospitality',\n    meta: 'Moraine Hotels · 2026',\n    image: photo('1493246507139-91e8fad9978e'),\n  },\n  {\n    title: 'Metro Pulse',\n    tag: 'Data visualisation',\n    meta: 'City of Lakes · 2025',\n    image: photo('1477959858617-67f85cf4f1df'),\n  },\n  {\n    title: 'Orbit Motion',\n    tag: '3D & motion',\n    meta: 'Orbit Labs · 2025',\n    image: photo('1618005198919-d3d4b5a92ead'),\n  },\n  {\n    title: 'Open Road',\n    tag: 'Campaign film',\n    meta: 'Westward · 2025',\n    image: photo('1500530855697-b586d89ba3ee'),\n  },\n  {\n    title: 'Still Water',\n    tag: 'Wellness app',\n    meta: 'Calm Harbor · 2024',\n    image: photo('1534447677768-be436bb09401'),\n  },\n  {\n    title: 'Lago Retreats',\n    tag: 'Travel platform',\n    meta: 'Lago Travel · 2024',\n    image: photo('1501785888041-af3ef285b470'),\n  },\n];\n\nconst STEP = cn(\n  'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white',\n  'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none'\n);\n/** The soft edges the arc runs out under. They sit above the cards, below the copy. */\nconst FADE =\n  'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n  <svg\n    viewBox=\"0 0 9 16\"\n    aria-hidden=\"true\"\n    className={cn('h-4 w-[8.5px]', flip && 'rotate-180')}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M7.5 1.5 1.5 8l6 6.5\" />\n  </svg>\n);\n\nconst PauseIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <rect x=\"3.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n    <rect x=\"9.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n  </svg>\n);\n\nconst PlayIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <path d=\"M4.5 2.9v10.2a1 1 0 0 0 1.53.85l8.1-5.1a1 1 0 0 0 0-1.7l-8.1-5.1A1 1 0 0 0 4.5 2.9Z\" />\n  </svg>\n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n  <svg\n    viewBox=\"0 0 16 16\"\n    aria-hidden=\"true\"\n    className={className}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M4.5 11.5 11.5 4.5M5.5 4.5h6v6\" />\n  </svg>\n);\n\nexport default function ArcCarousel({\n  items = DEFAULT_ITEMS,\n  badge = 'Featured work',\n  title = 'Projects we shipped this season.',\n  description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.',\n  cta = { label: 'View all projects', href: '#' },\n  autoplay = true,\n  interval = 5000,\n  className,\n  ...props\n}: ArcCarouselProps) {\n  const count = items.length;\n  const [position, setPosition] = useState(0);\n  const listRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const artRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const state = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] });\n  const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n  const titleId = useId();\n  const descriptionId = useId();\n  const sectionRef = useRef<HTMLElement>(null);\n  const ringRef = useRef<SVGCircleElement>(null);\n  const progress = useRef(0);\n  // `playing` is the viewer's choice; `held` is a pause they did not ask for (hover, focus, off-screen)\n  const [playing, setPlaying] = useState(autoplay);\n  const holds = useRef({ hover: false, focus: false, offscreen: false });\n  const [held, setHeld] = useState(false);\n  const hold = (key: keyof typeof holds.current, on: boolean) => {\n    holds.current[key] = on;\n    setHeld(Object.values(holds.current).some(Boolean));\n  };\n\n  const place = (item: HTMLElement, offset: number) => {\n    const s = slot(offset);\n    item.style.transform = s.transform;\n    item.style.opacity = s.opacity;\n    item.style.zIndex = s.zIndex;\n    item.style.pointerEvents = s.opacity === '1' ? '' : 'none';\n  };\n\n  // Lay the cards out on their slots whenever the position changes (animated after the first layout)\n  useIsoLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list || !count) return;\n    const reduced = prefersReducedMotion();\n    const move = reduced ? 'opacity 0.3s ease' : MOVE;\n    const art = reduced ? 'none' : ART;\n    const st = state.current;\n    const animate = st.offsets.length > 0;\n    const delta = animate ? position - st.last : 0;\n    st.last = position;\n\n    itemRefs.current.forEach((item, i) => {\n      if (!item) return;\n      const next = offsetOf(i, position, count);\n      const prev = st.offsets[i];\n\n      const scaleEl = artRefs.current[i];\n      if (scaleEl) {\n        const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)';\n        scaleEl.style.transition = animate ? art : 'none';\n        scaleEl.style.transform = scale;\n        if (!animate) {\n          void scaleEl.offsetWidth;\n          scaleEl.style.transition = art;\n        }\n      }\n\n      if (!animate || prev === undefined) {\n        item.style.transition = 'none';\n        place(item, next);\n      } else if (prev - delta !== next) {\n        // Wrapped round: a ghost keeps travelling off-screen, the card re-enters from the far side\n        const last = SLOTS.length - 1;\n        const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1));\n        const ghost = item.cloneNode(true) as HTMLElement;\n        ghost.setAttribute('aria-hidden', 'true');\n        ghost.inert = true;\n        ghost.style.pointerEvents = 'none';\n        ghost.style.transition = 'none';\n        list.appendChild(ghost);\n        const out = slot(prev - delta);\n        void ghost.offsetWidth;\n        ghost.style.transition = move;\n        ghost.style.transform = out.transform;\n        ghost.style.opacity = '0';\n        st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE));\n\n        item.style.transition = 'none';\n        place(item, entry);\n        item.style.opacity = '0';\n        void item.offsetWidth;\n        item.style.transition = move;\n        place(item, next);\n      } else {\n        item.style.transition = move;\n        place(item, next);\n      }\n      st.offsets[i] = next;\n    });\n  }, [position, count]);\n\n  useEffect(() => () => state.current.ghosts.forEach((id) => window.clearTimeout(id)), []);\n\n  const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n  // Autoplay starts off for viewers who asked the system for less motion; they can still press play\n  useEffect(() => {\n    setPlaying(autoplay && !prefersReducedMotion());\n  }, [autoplay]);\n\n  // Any step, by hand or on its own, gives the new card a full interval\n  useEffect(() => {\n    progress.current = 0;\n    ringRef.current?.style.setProperty('stroke-dashoffset', '1');\n  }, [position, playing]);\n\n  // The clock: counts only while playing and not held, so a pause resumes where it left off\n  useEffect(() => {\n    if (!playing || held || count < 2) return;\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      progress.current += Math.min(now - last, MAX_FRAME) / interval;\n      last = now;\n      if (progress.current >= 1) {\n        progress.current = 0;\n        step(1);\n      }\n      ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current));\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, held, interval, count, step]);\n\n  // Off-screen, autoplay waits\n  useEffect(() => {\n    const section = sectionRef.current;\n    if (!autoplay || !section || typeof IntersectionObserver === 'undefined') return;\n    const observer = new IntersectionObserver(([entry]) =>\n      hold('offscreen', !entry.isIntersecting)\n    );\n    observer.observe(section);\n    return () => observer.disconnect();\n  }, [autoplay]);\n\n  // Swipe / drag: the release can happen outside the list\n  useEffect(() => {\n    const onUp = (event: PointerEvent) => {\n      const s = swipe.current;\n      if (!s.tracking) return;\n      s.tracking = false;\n      const dx = event.clientX - s.x;\n      const dy = event.clientY - s.y;\n      if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) {\n        s.swiped = true;\n        step(dx < 0 ? 1 : -1);\n      }\n    };\n    const onCancel = () => {\n      swipe.current.tracking = false;\n    };\n    window.addEventListener('pointerup', onUp);\n    window.addEventListener('pointercancel', onCancel);\n    return () => {\n      window.removeEventListener('pointerup', onUp);\n      window.removeEventListener('pointercancel', onCancel);\n    };\n  }, [step]);\n\n  // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n  const onClickCapture = (event: MouseEvent<HTMLDivElement>) => {\n    const s = swipe.current;\n    if (s.swiped) {\n      s.swiped = false;\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    // Keyboard activation (detail 0) keeps its normal behaviour\n    if (event.detail === 0) return;\n    const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node));\n    if (index < 0) return;\n    const offset = offsetOf(index, position, count);\n    if (offset !== 0) {\n      event.preventDefault();\n      event.stopPropagation();\n      step(Math.sign(offset));\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n    event.preventDefault();\n    step(event.key === 'ArrowRight' ? 1 : -1);\n  };\n\n  const current = count ? wrap(position, count) : 0;\n  const active = items[current];\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-labelledby={titleId}\n      aria-describedby={description ? descriptionId : undefined}\n      className={cn(\n        'bg-tween-ink-deep @container relative isolate flex w-full flex-col items-center gap-4 overflow-hidden pt-28 pb-24 text-white',\n        '@max-[64rem]:pt-24 @max-[64rem]:pb-18 @max-[48rem]:gap-8 @max-[48rem]:py-16',\n        className\n      )}\n      {...props}\n    >\n      {/* The teal glow the arc hangs in */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-[62%] left-1/2 -z-10 size-[min(120cqw,70rem)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--color-tween-teal)_45%,transparent),color-mix(in_oklch,var(--color-tween-teal)_10%,transparent)_55%,transparent)]\"\n      />\n\n      <header className=\"relative z-3 flex w-full max-w-2xl flex-col items-center gap-5 px-6 text-center @max-[48rem]:gap-3\">\n        {badge && (\n          <span className=\"border-tween-lime/30 bg-tween-lime/10 text-tween-lime inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium tracking-[0.14em] uppercase\">\n            <span aria-hidden=\"true\" className=\"bg-tween-lime size-1.5 rounded-full\" />\n            {badge}\n          </span>\n        )}\n        <h2\n          id={titleId}\n          className=\"text-[clamp(2.25rem,5.5cqw,4rem)] leading-[1.02] font-semibold tracking-[-0.045em] text-balance\"\n        >\n          {title}\n        </h2>\n        {description && (\n          <p\n            id={descriptionId}\n            className=\"text-tween-teal-soft/75 max-w-lg text-[15px] leading-relaxed text-pretty @max-[48rem]:text-sm\"\n          >\n            {description}\n          </p>\n        )}\n      </header>\n\n      {/* Its own stacking context, so the cards' z-indexes stay under the edge fades */}\n      {/* A mouse or pen resting on the cards holds autoplay; a finger lifts at once, so it never does */}\n      <div\n        className=\"relative isolate z-1 h-150 w-78 @max-[64rem]:h-134 @max-[48rem]:h-72.5\"\n        onPointerEnter={(event) => event.pointerType !== 'touch' && hold('hover', true)}\n        onPointerLeave={() => hold('hover', false)}\n      >\n        <div className=\"absolute inset-0 origin-[50%_0] @max-[64rem]:scale-80 @max-[48rem]:scale-62\">\n          <div\n            ref={listRef}\n            role=\"group\"\n            tabIndex={0}\n            aria-roledescription=\"carousel\"\n            aria-label={title}\n            className=\"focus-visible:ring-tween-lime focus-visible:ring-offset-tween-ink-deep absolute top-16 left-0 h-107.5 w-78 touch-pan-y rounded-[8px] outline-none select-none focus-visible:ring-2 focus-visible:ring-offset-8 @max-[48rem]:top-2\"\n            onPointerDown={(event) => {\n              swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n            }}\n            onDragStart={(event) => event.preventDefault()}\n            onClickCapture={onClickCapture}\n            onKeyDown={onKeyDown}\n            // Keyboard focus on the cards holds autoplay, so nothing moves while you read\n            onFocus={(event) => {\n              let visible = true;\n              try {\n                visible = event.target.matches(':focus-visible');\n              } catch {\n                // Older engines without :focus-visible: treat every focus as visible\n              }\n              if (visible) hold('focus', true);\n            }}\n            onBlur={(event) => {\n              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n                hold('focus', false);\n              }\n            }}\n          >\n            {items.map((item, i) => {\n              const offset = offsetOf(i, position, count);\n              const isActive = offset === 0;\n              const Card = item.href ? 'a' : 'div';\n              // The first frame is set here too, so the arc is in place before hydration\n              const first = slot(offsetOf(i, 0, count));\n              return (\n                <div\n                  key={`${item.title}-${i}`}\n                  ref={(el) => {\n                    itemRefs.current[i] = el;\n                  }}\n                  role=\"group\"\n                  aria-roledescription=\"slide\"\n                  aria-label={`${i + 1} of ${count}: ${item.title}`}\n                  aria-hidden={isActive ? undefined : true}\n                  data-active={isActive ? '' : undefined}\n                  className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n                  style={{\n                    transform: first.transform,\n                    opacity: first.opacity,\n                    zIndex: first.zIndex,\n                  }}\n                >\n                  <div\n                    ref={(el) => {\n                      artRefs.current[i] = el;\n                    }}\n                    className=\"size-full origin-center\"\n                  >\n                    <Card\n                      {...(item.href ? { href: item.href } : {})}\n                      tabIndex={item.href && isActive ? 0 : -1}\n                      draggable={false}\n                      className={cn(\n                        'bg-tween-ink relative block size-full overflow-hidden rounded-[8px]',\n                        'shadow-[0_30px_60px_-24px_rgb(0_0_0/0.75)]',\n                        'focus-visible:outline-tween-lime focus-visible:outline-2 focus-visible:outline-offset-4'\n                      )}\n                    >\n                      <img\n                        src={item.image}\n                        srcSet={srcSetOf(item.image)}\n                        alt=\"\"\n                        width={312}\n                        height={430}\n                        draggable={false}\n                        decoding=\"async\"\n                        loading={Math.abs(offsetOf(i, 0, count)) <= 2 ? 'eager' : 'lazy'}\n                        className=\"absolute inset-0 size-full object-cover\"\n                      />\n\n                      {/* Side cards sit back in shade; the centre card steps out of it */}\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"bg-tween-ink-deep/40 ease-tween absolute inset-0 transition-opacity duration-500 group-data-active/card:opacity-0 motion-reduce:transition-none\"\n                      />\n                      {/* A scrim so the caption reads on any photo */}\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"from-tween-ink-deep via-tween-ink-deep/60 absolute inset-x-0 bottom-0 h-3/5 bg-linear-to-t to-transparent\"\n                      />\n\n                      <span className=\"absolute inset-x-4 top-4 flex items-center justify-between gap-3\">\n                        {item.tag ? (\n                          <span className=\"bg-tween-ink-deep/70 truncate rounded-full px-3 py-1.5 text-[11px] leading-none font-medium tracking-[0.12em] text-white uppercase ring-1 ring-white/10\">\n                            {item.tag}\n                          </span>\n                        ) : (\n                          <span />\n                        )}\n                        <span className=\"shrink-0 text-xs font-medium text-white/70 tabular-nums\">\n                          {pad2(i + 1)}\n                          <span className=\"text-white/35\"> / {pad2(count)}</span>\n                        </span>\n                      </span>\n\n                      <span className=\"absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-5\">\n                        <span className=\"grid min-w-0 gap-1.5\">\n                          <span className=\"truncate text-[22px] leading-tight font-semibold tracking-[-0.025em] text-white\">\n                            {item.title}\n                          </span>\n                          {item.meta && (\n                            <span className=\"text-tween-teal-soft/80 truncate text-sm\">\n                              {item.meta}\n                            </span>\n                          )}\n                        </span>\n                        {item.href && (\n                          <span\n                            aria-hidden=\"true\"\n                            className={cn(\n                              'grid size-11 shrink-0 place-items-center rounded-full bg-white/10 text-white ring-1 ring-white/20',\n                              'ease-tween transition-[background-color,color,box-shadow] duration-500 motion-reduce:transition-none',\n                              'group-data-active/card:bg-tween-lime group-data-active/card:text-tween-ink-deep group-data-active/card:ring-transparent'\n                            )}\n                          >\n                            <ArrowUpRight className=\"ease-tween size-4 transition-transform duration-300 group-hover/card:rotate-45 motion-reduce:transition-none\" />\n                          </span>\n                        )}\n                      </span>\n\n                      {/* The outline, drawn over the photo so it never gets clipped */}\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"ease-tween group-data-active/card:ring-tween-lime/80 pointer-events-none absolute inset-0 rounded-[8px] ring-1 ring-white/10 transition-shadow duration-500 ring-inset group-data-active/card:ring-2 motion-reduce:transition-none\"\n                      />\n                    </Card>\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"relative z-3 flex items-center gap-2\">\n        <button\n          className={STEP}\n          type=\"button\"\n          aria-label=\"Previous project\"\n          onClick={() => step(-1)}\n        >\n          <Caret />\n        </button>\n        {cta && (\n          <a\n            href={cta.href}\n            className={cn(\n              'bg-tween-lime text-tween-ink-deep inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-semibold',\n              'ease-tween transition-[filter,transform] duration-300 hover:brightness-110 active:scale-[0.97]',\n              'focus-visible:outline-tween-lime focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:transition-none'\n            )}\n          >\n            {cta.label}\n            <ArrowUpRight className=\"size-3.5\" />\n          </a>\n        )}\n        <button className={STEP} type=\"button\" aria-label=\"Next project\" onClick={() => step(1)}>\n          <Caret flip />\n        </button>\n        {autoplay && (\n          <button\n            className={cn(STEP, 'relative ml-2')}\n            type=\"button\"\n            aria-label={playing ? 'Pause autoplay' : 'Start autoplay'}\n            onClick={() => setPlaying((on) => !on)}\n          >\n            {playing ? <PauseIcon /> : <PlayIcon />}\n            {/* Time left on the current card */}\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 44 44\"\n              className={cn(\n                'pointer-events-none absolute -inset-px -rotate-90 transition-opacity duration-300',\n                playing ? 'opacity-100' : 'opacity-0'\n              )}\n            >\n              <circle\n                ref={ringRef}\n                cx=\"22\"\n                cy=\"22\"\n                r=\"21\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                pathLength={1}\n                strokeDasharray=\"1\"\n                strokeDashoffset=\"1\"\n                className=\"text-tween-lime\"\n              />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Quiet while it plays itself, so a screen reader is not interrupted every few seconds */}\n      <p aria-live={playing && !held ? 'off' : 'polite'} className=\"sr-only\">\n        {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n      </p>\n\n      <div aria-hidden=\"true\" className={cn(FADE, 'left-0 bg-linear-to-r')} />\n      <div aria-hidden=\"true\" className={cn(FADE, 'right-0 bg-linear-to-l')} />\n    </section>\n  );\n}\n
" + } } }; diff --git a/apps/docs/registry/demos/arc-carousel.tsx b/apps/docs/registry/demos/arc-carousel.tsx new file mode 100644 index 0000000..eb67dc5 --- /dev/null +++ b/apps/docs/registry/demos/arc-carousel.tsx @@ -0,0 +1,11 @@ +'use client'; + +import ArcCarousel from '@/registry/tweenui/arc-carousel'; + +export default function ArcCarouselDemo() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/registry/registry-blocks.ts b/apps/docs/registry/registry-blocks.ts index 919cf6d..75d5f47 100644 --- a/apps/docs/registry/registry-blocks.ts +++ b/apps/docs/registry/registry-blocks.ts @@ -1112,4 +1112,45 @@ export default function Example() { }, docs: 'Pass your own `columns`, `socials` and `legal` links; the link hover is plain CSS and only the wordmark uses GSAP.', }, + { + name: 'arc-carousel', + type: 'block', + addedAt: '2026-10-14', + title: 'Arc Carousel', + description: + 'A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline, and a card that wraps past the edge keeps travelling off-screen while it comes back in from the far side. Swipe, drag, the arrow keys or a click on a side card all step it.', + media: {}, + dependencies: [], + registryDependencies: [], + cssVars: { + '--color-tween-teal': '#045f64', + '--color-tween-teal-soft': '#9fd4d6', + '--color-tween-lime': '#c6f56f', + '--color-tween-ink': '#071d1b', + '--color-tween-ink-deep': '#03110f', + '--ease-tween': 'cubic-bezier(0.32, 0.72, 0, 1)', + }, + files: [ + { + path: 'registry/tweenui/arc-carousel.tsx', + target: 'components/tweenui/arc-carousel.tsx', + kind: 'react', + }, + ], + variants: [ + { + id: 'default', + label: 'Preview', + reactSource: 'registry/tweenui/arc-carousel.tsx', + }, + ], + usage: { + react: `import ArcCarousel from '@/components/tweenui/arc-carousel'; + +export default function Example() { + return ; +}`, + }, + docs: 'Colors come from the Tween UI theme tokens (`tween-ink-deep`, `tween-lime`, …), which the CLI adds to your `@theme`. Pass your own `items`; seven or more keep the arc full, since three cards show on each side of the centre.', + }, ]; diff --git a/apps/docs/registry/tweenui/arc-carousel.tsx b/apps/docs/registry/tweenui/arc-carousel.tsx new file mode 100644 index 0000000..6341529 --- /dev/null +++ b/apps/docs/registry/tweenui/arc-carousel.tsx @@ -0,0 +1,674 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type KeyboardEvent, + type MouseEvent, +} from 'react'; +import { cn } from '@/lib/utils'; + +const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +// Layout effects warn on the server; there is nothing to lay out there anyway +const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +export interface ArcCarouselItem { + title: string; + image: string; + /** Category chip on the top left of the card, e.g. "Brand identity". */ + tag?: string; + /** A short line under the title, e.g. a client and year or a price. */ + meta?: string; + href?: string; +} + +export interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> { + items?: ArcCarouselItem[]; + /** The pill above the title. Pass an empty string to drop it. */ + badge?: string; + title?: string; + /** A line under the title. Pass an empty string to drop it. */ + description?: string; + cta?: { label: string; href: string } | null; + /** + * Step to the next card on its own. On by default; pass `false` to turn it off. It + * pauses while the pointer is over the cards, while they have keyboard focus and + * while the block is off-screen, and adds a pause / play button with a progress ring. + */ + autoplay?: boolean; + /** Time each card holds the centre while autoplaying, in ms. */ + interval?: number; +} + +/** + * Where a card sits by its distance from the centre: px across, px down and degrees of + * turn. The steps grow as they go out, so the row bends down into an arc. Cards further + * out than the last slot carry on along the same line, invisible, which is where a + * wrapping card leaves to and comes back from. + */ +const SLOTS = [ + { x: 0, y: 0, r: 0 }, + { x: 359, y: 26, r: 8.77 }, + { x: 711, y: 113, r: 18.83 }, + { x: 1033, y: 243, r: 25.05 }, +]; +const ACTIVE_SCALE = 1.05; +const ART = 'transform 470ms cubic-bezier(0.42, 1, 0.46, 1)'; +const MOVE = 'transform 800ms cubic-bezier(0.98, 0.30, 0.30, 1), opacity 400ms ease'; +/** A swipe has to travel this far, mostly sideways, to count as one. */ +const SWIPE = 50; +/** How long a wrapping card's off-screen copy lives: the move, plus a frame or two. */ +const GHOST_LIFE = 850; +/** Autoplay never counts more than this per frame, so a backgrounded tab does not skip ahead. */ +const MAX_FRAME = 100; + +const slot = (offset: number) => { + const t = Math.abs(offset); + const sign = Math.sign(offset); + const last = SLOTS.length - 1; + let p = SLOTS[Math.min(t, last)]; + if (t > last) { + const prev = SLOTS[last - 1]; + const n = t - last; + p = { x: p.x + (p.x - prev.x) * n, y: p.y + (p.y - prev.y) * n, r: p.r + (p.r - prev.r) * n }; + } + return { + transform: `translate(${p.x * sign}px, ${p.y}px) rotate(${p.r * sign}deg)`, + opacity: t <= last ? '1' : '0', + zIndex: String(100 - t), + }; +}; + +/** Signed distance of card `index` from the centre, wrapped to the shortest way round. */ +const offsetOf = (index: number, position: number, count: number) => { + let r = (((index - position) % count) + count) % count; + if (r > Math.floor(count / 2)) r -= count; + return r; +}; + +const wrap = (position: number, count: number) => ((position % count) + count) % count; +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** Unsplash, cropped to the card's 312 × 430 at 1x and 2x. */ +const photo = (id: string, width = 640) => + `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`; +const srcSetOf = (src: string) => + src.includes('images.unsplash.com') + ? `${src.replace(/([?&])w=\d+&h=\d+/, '$1w=320&h=441')} 1x, ${src} 2x` + : undefined; + +const DEFAULT_ITEMS: ArcCarouselItem[] = [ + { + title: 'Nightfall', + tag: 'Astronomy app', + meta: 'Stellar Co. · 2026', + image: photo('1519681393784-d120267933ba'), + }, + { + title: 'Chroma Identity', + tag: 'Brand identity', + meta: 'Chroma Studio · 2026', + image: photo('1541701494587-cb58502866ab'), + }, + { + title: 'Alpine Lodge', + tag: 'Hospitality', + meta: 'Moraine Hotels · 2026', + image: photo('1493246507139-91e8fad9978e'), + }, + { + title: 'Metro Pulse', + tag: 'Data visualisation', + meta: 'City of Lakes · 2025', + image: photo('1477959858617-67f85cf4f1df'), + }, + { + title: 'Orbit Motion', + tag: '3D & motion', + meta: 'Orbit Labs · 2025', + image: photo('1618005198919-d3d4b5a92ead'), + }, + { + title: 'Open Road', + tag: 'Campaign film', + meta: 'Westward · 2025', + image: photo('1500530855697-b586d89ba3ee'), + }, + { + title: 'Still Water', + tag: 'Wellness app', + meta: 'Calm Harbor · 2024', + image: photo('1534447677768-be436bb09401'), + }, + { + title: 'Lago Retreats', + tag: 'Travel platform', + meta: 'Lago Travel · 2024', + image: photo('1501785888041-af3ef285b470'), + }, +]; + +const STEP = cn( + 'grid size-11 place-items-center rounded-full border border-tween-teal-soft/25 text-white', + 'transition-[background-color,border-color,color] duration-300 ease-tween hover:border-tween-lime hover:bg-tween-lime hover:text-tween-ink-deep', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tween-lime motion-reduce:transition-none' +); +/** The soft edges the arc runs out under. They sit above the cards, below the copy. */ +const FADE = + 'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-tween-ink-deep via-tween-ink-deep/70 to-transparent'; + +const Caret = ({ flip }: { flip?: boolean }) => ( + +); + +const PauseIcon = () => ( + +); + +const PlayIcon = () => ( + +); + +const ArrowUpRight = ({ className }: { className?: string }) => ( + +); + +export default function ArcCarousel({ + items = DEFAULT_ITEMS, + badge = 'Featured work', + title = 'Projects we shipped this season.', + description = 'A rotating look at recent launches across brand, product and research. Swipe, drag or use the arrows to browse.', + cta = { label: 'View all projects', href: '#' }, + autoplay = true, + interval = 5000, + className, + ...props +}: ArcCarouselProps) { + const count = items.length; + const [position, setPosition] = useState(0); + const listRef = useRef(null); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + const artRefs = useRef<(HTMLDivElement | null)[]>([]); + const state = useRef({ last: 0, offsets: [] as (number | undefined)[], ghosts: [] as number[] }); + const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 }); + const titleId = useId(); + const descriptionId = useId(); + const sectionRef = useRef(null); + const ringRef = useRef(null); + const progress = useRef(0); + // `playing` is the viewer's choice; `held` is a pause they did not ask for (hover, focus, off-screen) + const [playing, setPlaying] = useState(autoplay); + const holds = useRef({ hover: false, focus: false, offscreen: false }); + const [held, setHeld] = useState(false); + const hold = (key: keyof typeof holds.current, on: boolean) => { + holds.current[key] = on; + setHeld(Object.values(holds.current).some(Boolean)); + }; + + const place = (item: HTMLElement, offset: number) => { + const s = slot(offset); + item.style.transform = s.transform; + item.style.opacity = s.opacity; + item.style.zIndex = s.zIndex; + item.style.pointerEvents = s.opacity === '1' ? '' : 'none'; + }; + + // Lay the cards out on their slots whenever the position changes (animated after the first layout) + useIsoLayoutEffect(() => { + const list = listRef.current; + if (!list || !count) return; + const reduced = prefersReducedMotion(); + const move = reduced ? 'opacity 0.3s ease' : MOVE; + const art = reduced ? 'none' : ART; + const st = state.current; + const animate = st.offsets.length > 0; + const delta = animate ? position - st.last : 0; + st.last = position; + + itemRefs.current.forEach((item, i) => { + if (!item) return; + const next = offsetOf(i, position, count); + const prev = st.offsets[i]; + + const scaleEl = artRefs.current[i]; + if (scaleEl) { + const scale = next === 0 && !reduced ? `scale(${ACTIVE_SCALE})` : 'scale(1)'; + scaleEl.style.transition = animate ? art : 'none'; + scaleEl.style.transform = scale; + if (!animate) { + void scaleEl.offsetWidth; + scaleEl.style.transition = art; + } + } + + if (!animate || prev === undefined) { + item.style.transition = 'none'; + place(item, next); + } else if (prev - delta !== next) { + // Wrapped round: a ghost keeps travelling off-screen, the card re-enters from the far side + const last = SLOTS.length - 1; + const entry = delta > 0 ? Math.max(next + 1, last + 1) : Math.min(next - 1, -(last + 1)); + const ghost = item.cloneNode(true) as HTMLElement; + ghost.setAttribute('aria-hidden', 'true'); + ghost.inert = true; + ghost.style.pointerEvents = 'none'; + ghost.style.transition = 'none'; + list.appendChild(ghost); + const out = slot(prev - delta); + void ghost.offsetWidth; + ghost.style.transition = move; + ghost.style.transform = out.transform; + ghost.style.opacity = '0'; + st.ghosts.push(window.setTimeout(() => ghost.remove(), GHOST_LIFE)); + + item.style.transition = 'none'; + place(item, entry); + item.style.opacity = '0'; + void item.offsetWidth; + item.style.transition = move; + place(item, next); + } else { + item.style.transition = move; + place(item, next); + } + st.offsets[i] = next; + }); + }, [position, count]); + + useEffect(() => () => state.current.ghosts.forEach((id) => window.clearTimeout(id)), []); + + const step = useCallback((direction: number) => setPosition((p) => p + direction), []); + + // Autoplay starts off for viewers who asked the system for less motion; they can still press play + useEffect(() => { + setPlaying(autoplay && !prefersReducedMotion()); + }, [autoplay]); + + // Any step, by hand or on its own, gives the new card a full interval + useEffect(() => { + progress.current = 0; + ringRef.current?.style.setProperty('stroke-dashoffset', '1'); + }, [position, playing]); + + // The clock: counts only while playing and not held, so a pause resumes where it left off + useEffect(() => { + if (!playing || held || count < 2) return; + let frame = 0; + let last = performance.now(); + const tick = (now: number) => { + progress.current += Math.min(now - last, MAX_FRAME) / interval; + last = now; + if (progress.current >= 1) { + progress.current = 0; + step(1); + } + ringRef.current?.style.setProperty('stroke-dashoffset', String(1 - progress.current)); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [playing, held, interval, count, step]); + + // Off-screen, autoplay waits + useEffect(() => { + const section = sectionRef.current; + if (!autoplay || !section || typeof IntersectionObserver === 'undefined') return; + const observer = new IntersectionObserver(([entry]) => + hold('offscreen', !entry.isIntersecting) + ); + observer.observe(section); + return () => observer.disconnect(); + }, [autoplay]); + + // Swipe / drag: the release can happen outside the list + useEffect(() => { + const onUp = (event: PointerEvent) => { + const s = swipe.current; + if (!s.tracking) return; + s.tracking = false; + const dx = event.clientX - s.x; + const dy = event.clientY - s.y; + if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy)) { + s.swiped = true; + step(dx < 0 ? 1 : -1); + } + }; + const onCancel = () => { + swipe.current.tracking = false; + }; + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); + return () => { + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + }; + }, [step]); + + // After a swipe, or on a side card, a click steps the arc instead of following the card's link + const onClickCapture = (event: MouseEvent) => { + const s = swipe.current; + if (s.swiped) { + s.swiped = false; + event.preventDefault(); + event.stopPropagation(); + return; + } + // Keyboard activation (detail 0) keeps its normal behaviour + if (event.detail === 0) return; + const index = itemRefs.current.findIndex((item) => item?.contains(event.target as Node)); + if (index < 0) return; + const offset = offsetOf(index, position, count); + if (offset !== 0) { + event.preventDefault(); + event.stopPropagation(); + step(Math.sign(offset)); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + step(event.key === 'ArrowRight' ? 1 : -1); + }; + + const current = count ? wrap(position, count) : 0; + const active = items[current]; + + return ( +
+ {/* The teal glow the arc hangs in */} +
+ ); +} diff --git a/apps/docs/registry/tweenui/cube-roll-item.tsx b/apps/docs/registry/tweenui/cube-roll-item.tsx index 4b8a4e3..45526c6 100644 --- a/apps/docs/registry/tweenui/cube-roll-item.tsx +++ b/apps/docs/registry/tweenui/cube-roll-item.tsx @@ -270,7 +270,7 @@ export default function CubeRollItem({