# DiMaac UI

> Animated React components and templates from the HankTheTank YouTube builds, by DiMaac. TypeScript, React 19 and Tailwind v4, with light and dark built in. Free components install with the shadcn CLI.

Important notes for agents:
- Install a free component with `npx shadcn@latest add https://ui.dimaac.com/r/<slug>.json`. With `"@dimaac": "https://ui.dimaac.com/r/{name}.json"` under `registries` in components.json it's `npx shadcn@latest add @dimaac/<slug>`. The index is https://ui.dimaac.com/r/registry.json.
- Every component page has a Markdown twin at /components/<slug>.md with the install commands, the props and the full source.
- Pro components (marked Pro below) come with Pro Components, a Templates plan or All-Access (https://ui.dimaac.com/pricing). Their source is not in the .md twins or at any public address: it needs the owner's licence key. Install one from the authenticated registry: add `"@dimaac-pro": { "url": "https://api.dimaac.com/v1/ui/r/{name}.json", "headers": { "Authorization": "Bearer ${DIMAAC_LICENSE_KEY}" } }` under `registries` in components.json, put the key in .env.local as `DIMAAC_LICENSE_KEY=<key>`, then run `npx shadcn@latest add @dimaac-pro/<slug>`. Ask the owner for the key; never print it or commit it. The registry answers 401 without a key, 403 for a key that isn't valid, and 402 for a key whose purchase doesn't include Pro components (see https://ui.dimaac.com/pricing).
- Each item lists the npm packages it imports (gsap, @gsap/react, ...). Install them; the shadcn CLI does it for you.
- Components land in `components/dimaac/<slug>.tsx`. They use Tailwind v4 utility classes, so Tailwind must scan that folder.
- Components fill their parent: give it a size. They respect prefers-reduced-motion; keep that working.
- Templates are zips, not registry items. Free ones download from their page; Pro ones are bought one by one or claimed with a Templates plan, then download from their page or the account. Never try to fetch them from GitHub.

## Components

- [ASCII Reveal](https://ui.dimaac.com/components/ascii-reveal.md): A photo typed out in ASCII that scrambles into the real image. `@dimaac/ascii-reveal`
- [Aurora Background](https://ui.dimaac.com/components/aurora-background.md): Soft colour blobs rising from an edge and drifting behind your content. `@dimaac/aurora-background`
- [3D Card Ring](https://ui.dimaac.com/components/card-ring-3d.md): Photo cards bent around a 3D cylinder that spins with wheel and drag. (Pro: `@dimaac-pro/card-ring-3d`)
- [Clip-Path Slider](https://ui.dimaac.com/components/clip-slider.md): Full-bleed slides that open through a clip-path while the old one zooms back. `@dimaac/clip-slider`
- [Colour Trail](https://ui.dimaac.com/components/colour-trail.md): A greyscale photo the cursor paints back into colour. `@dimaac/colour-trail`
- [Context Menu](https://ui.dimaac.com/components/context-menu.md): A right-click menu that pops open from the cursor. `@dimaac/context-menu`
- [Electric Border](https://ui.dimaac.com/components/electric-border.md): A card edge that crackles like electricity. `@dimaac/electric-border`
- [Expandable Panel](https://ui.dimaac.com/components/expandable-panel.md): Photo strips that open wide on hover, focus or tap. `@dimaac/expandable-panel`
- [Fashion Preloader](https://ui.dimaac.com/components/fashion-preloader.md): Tilted photo cards open one by one, a brand name rises and a counter runs to 100. `@dimaac/fashion-preloader`
- [Film Split Loader](https://ui.dimaac.com/components/film-split-loader.md): Loader words spread apart and a tilted card grows between them into the full-screen picture. `@dimaac/film-split-loader`
- [Flip Book](https://ui.dimaac.com/components/flip-book.md): A hardcover photo book whose pages turn and bend in 3D. (Pro: `@dimaac-pro/flip-book`)
- [Flow Art](https://ui.dimaac.com/components/flow-art.md): Colour-block sections that swing in over each other as you scroll. `@dimaac/flow-art`
- [Folder Hover Tabs](https://ui.dimaac.com/components/folder-hover.md): Service folders that lift and fan three photos open on hover. `@dimaac/folder-hover`
- [Opening Card Row](https://ui.dimaac.com/components/form-cards.md): An acronym preloader, then tall cards that slide across a pinned hero and open. (Pro: `@dimaac-pro/form-cards`)
- [Rotating Image Gallery](https://ui.dimaac.com/components/image-gallery.md): A ring of photos you spin with a drag, a swipe or the arrow keys. `@dimaac/image-gallery`
- [Image Loader](https://ui.dimaac.com/components/image-loader.md): A counter climbs to 100 while photos open in a stack and the last one fills the screen. `@dimaac/image-loader`
- [Images flow](https://ui.dimaac.com/components/images-flow.md): Images spread from the centre into 3D. `@dimaac/images-flow`
- [Infinite Drag Gallery](https://ui.dimaac.com/components/infinite-drag-gallery.md): A wall of work you drag in any direction, forever. (Pro: `@dimaac-pro/infinite-drag-gallery`)
- [Ink Hover Reveal](https://ui.dimaac.com/components/ink-hover.md): A brush stroke of ink pours the colour photo in on hover. `@dimaac/ink-hover`
- [Jelly Spiral Gallery](https://ui.dimaac.com/components/jelly-spiral.md): Photos on a 3D spiral that bend like jelly when you spin it. (Pro: `@dimaac-pro/jelly-spiral`)
- [Lanyard Badge](https://ui.dimaac.com/components/lanyard-badge.md): An ID badge on two straps that you can grab, swing and flick. (Pro: `@dimaac-pro/lanyard-badge`)
- [Line Reveal](https://ui.dimaac.com/components/line-reveal.md): Headings and paragraphs that rise line by line out of a mask. `@dimaac/line-reveal`
- [Liquid image reveal](https://ui.dimaac.com/components/liquid-image-reveal.md): An organic reveal with SVG filters. `@dimaac/liquid-image-reveal`
- [Magnetic Button](https://ui.dimaac.com/components/magnetic-button.md): A button that leans toward the cursor and snaps back like a spring. `@dimaac/magnetic-button`
- [Mask Reveal on Hover](https://ui.dimaac.com/components/mask-reveal-on-hover.md): A round window that follows the cursor and shows different content underneath. `@dimaac/mask-reveal-on-hover`
- [Mouse Tilt Card](https://ui.dimaac.com/components/mouse-tilt-card.md): A card that leans toward the cursor with a moving glare. `@dimaac/mouse-tilt-card`
- [Mouse trail](https://ui.dimaac.com/components/mouse-trail.md): An image trail that follows the cursor. `@dimaac/mouse-trail`
- [Odometer](https://ui.dimaac.com/components/odometer.md): Numbers that roll digit by digit, like an odometer. `@dimaac/odometer`
- [Perspective Card Stack](https://ui.dimaac.com/components/perspective-card-stack.md): A pile of photos that swings open into a 3D fan. `@dimaac/perspective-card-stack`
- [Pin rotate sections](https://ui.dimaac.com/components/pin-rotate-sections.md): Pinned sections that turn in 3D. `@dimaac/pin-rotate-sections`
- [Pixel Hover Reveal](https://ui.dimaac.com/components/pixel-hover-reveal.md): Hover a name and its picture breaks in, pixel by pixel. `@dimaac/pixel-hover-reveal`
- [Portrait Burst](https://ui.dimaac.com/components/portrait-burst.md): Hover a word and a burst of portraits flies out, then tucks back in. `@dimaac/portrait-burst`
- [Post Swiper](https://ui.dimaac.com/components/post-swiper.md): A deck of social posts you swipe through, card by card. `@dimaac/post-swiper`
- [Project hover](https://ui.dimaac.com/components/project-hover-section.md): Thumbnails float with the cursor. `@dimaac/project-hover-section`
- [Proof Of Work](https://ui.dimaac.com/components/proof-of-work.md): A big title over a grid of work that flies in, or tumbles into view, as you scroll. `@dimaac/proof-of-work`
- [Scrolling Gallery](https://ui.dimaac.com/components/scrolling-gallery.md): A zig-zag column of pictures that slide past each other as you scroll. `@dimaac/scrolling-gallery`
- [Section 3D](https://ui.dimaac.com/components/section-3d.md): Tall cards that fall back into 3D as the next one arrives. `@dimaac/section-3d`
- [Settle Text](https://ui.dimaac.com/components/settle-text.md): Headline rows that slide in while their letters drop in and settle. `@dimaac/settle-text`
- [Social Card](https://ui.dimaac.com/components/social-card.md): A realistic post card in three looks: photo feed, short-text timeline and news feed. `@dimaac/social-card`
- [Spotlight Cards](https://ui.dimaac.com/components/spotlight-cards.md): A grid of cards lit by one soft light that follows the cursor. `@dimaac/spotlight-cards`
- [Stack Fly-Out Cards](https://ui.dimaac.com/components/stack-fly-cards.md): Cards rise into a peeking stack, then fly off one by one as you scroll. `@dimaac/stack-fly-cards`
- [Stacked card slider](https://ui.dimaac.com/components/stacked-card-slider.md): Click to cycle cards with GSAP Flip. `@dimaac/stacked-card-slider`
- [Swipeable Cards](https://ui.dimaac.com/components/swipeable-cards.md): A deck of cards you swipe through. `@dimaac/swipeable-cards`
- [Text block effect](https://ui.dimaac.com/components/text-block-effect.md): A coloured block sweeps each line in. `@dimaac/text-block-effect`
- [Text Loader](https://ui.dimaac.com/components/text-loader.md): Letters rise out of a mask, fill with a gradient, leave, and the panel lifts away. `@dimaac/text-loader`
- [Text scramble reveal](https://ui.dimaac.com/components/text-scramble-reveal.md): Letters scramble near the mouse. `@dimaac/text-scramble-reveal`
- [Text to Timeline](https://ui.dimaac.com/components/text-to-timeline.md): Images inside a sentence fly out on scroll and land on an editor timeline. (Pro: `@dimaac-pro/text-to-timeline`)
- [Tilt Menu](https://ui.dimaac.com/components/tilt-menu.md): The page swings aside and tilts to show a full menu behind it. `@dimaac/tilt-menu`
- [Velocity Marquee](https://ui.dimaac.com/components/velocity-marquee.md): A marquee that speeds up, leans and turns around with your scroll. `@dimaac/velocity-marquee`
- [Card Fan](https://ui.dimaac.com/components/card-fan.md): A hand of photo cards that fans out, lifts and turns over. `@dimaac/card-fan`
- [Curved Gallery](https://ui.dimaac.com/components/curved-gallery.md): A WebGL photo strip on a curved screen that bends with speed. (Pro: `@dimaac-pro/curved-gallery`)
- [Dot Globe](https://ui.dimaac.com/components/dot-globe.md): A dotted three.js globe with arcs that fly between cities. (Pro: `@dimaac-pro/dot-globe`)
- [Dynamic Island](https://ui.dimaac.com/components/dynamic-island.md): A black pill that springs between live activities. `@dimaac/dynamic-island`
- [Magnetic Dot Grid](https://ui.dimaac.com/components/magnetic-dot-grid.md): A dot grid that parts around the cursor and springs back. `@dimaac/magnetic-dot-grid`
- [Mesh Gradient](https://ui.dimaac.com/components/mesh-gradient.md): Soft fields of colour that drift, swirl and blend, with fine film grain. `@dimaac/mesh-gradient`
- [Physics Letters](https://ui.dimaac.com/components/physics-letters.md): Huge letters that drop in, bounce, pile up and can be thrown. `@dimaac/physics-letters`
- [Water Ripple](https://ui.dimaac.com/components/water-ripple.md): A photo under live water: the cursor leaves a wake, a click drops a ripple. (Pro: `@dimaac-pro/water-ripple`)

## Docs

- [Installation and AI agents](https://ui.dimaac.com/docs): shadcn setup, the @dimaac namespace, Pro components, the shadcn MCP server, licence notes
- [Registry index](https://ui.dimaac.com/r/registry.json): every free component as shadcn registry JSON

## Templates

- [Templates](https://ui.dimaac.com/templates): full sites from the videos, as zips (not registry items)
- [Atelier Nord](https://ui.dimaac.com/templates/atelier-nord): A creative-studio site for fashion and beauty brands. Next.js 15. (Free)
- [Form & Co](https://ui.dimaac.com/templates/form-and-co): A design-studio portfolio with a custom cursor and editorial reveals. Next.js 14. (Free)
- [Lorea](https://ui.dimaac.com/templates/lorea): A multi-page freelancer portfolio with case studies and a blog. Next.js 15. (Pro: bought on its own or claimed with a plan)
- [Arlo](https://ui.dimaac.com/templates/arlo): A motion-designer portfolio built as a design canvas. Vite 5. (Pro: bought on its own or claimed with a plan)
- [Quiet](https://ui.dimaac.com/templates/minimal): A calm one-column serif developer portfolio. HTML. (Free)
- [Aurora](https://ui.dimaac.com/templates/aurora): A product-engineer portfolio with an aurora footer and a WebGL glow. HTML. (Free)
- [Ember](https://ui.dimaac.com/templates/ember): A warm cream portfolio with a WebGL ember shader. HTML. (Free)
- [Prism](https://ui.dimaac.com/templates/prism): A dark design-engineer portfolio with a spectral WebGL wash. HTML. (Free)
- [Maya](https://ui.dimaac.com/templates/maya): A serif editorial portfolio with hover project previews. HTML. (Free)
- [Terminal](https://ui.dimaac.com/templates/backend): A terminal-style portfolio for backend engineers. HTML. (Free)
- [Altura](https://ui.dimaac.com/templates/altura): A seven-page investment firm site with GSAP scroll fills. HTML. (Pro: bought on its own or claimed with a plan)
- [Lumen](https://ui.dimaac.com/templates/lumen): A WebGL gallery exhibition with a scroll-driven 3D artwork rail. HTML + three.js. (Pro: bought on its own or claimed with a plan)
- [Overclock](https://ui.dimaac.com/templates/overclock): An indie-hacker landing page with GSAP scroll choreography. HTML. (Pro: bought on its own or claimed with a plan)
- [Studio](https://ui.dimaac.com/templates/phantom-sphere): A creative-studio portfolio around an interactive 3D project sphere. HTML + three.js. (Pro: bought on its own or claimed with a plan)
- [Versant](https://ui.dimaac.com/templates/versant): An architecture-studio site with pinned work cards and halftone WebGL. Next.js 16. (Pro: bought on its own or claimed with a plan)
- [Mara Vance](https://ui.dimaac.com/templates/portfolio-landing): A designer portfolio landing with a WebGL hero scene. HTML. (Free)
- [Veldt](https://ui.dimaac.com/templates/veldt-folio): A Vite portfolio with a 3D helix gallery. Vite 6. (Free)
- [Étude](https://ui.dimaac.com/templates/etude): A Next.js and R3F portfolio with a scroll-synced WebGL gallery. Next.js 15 + R3F. (Pro: bought on its own or claimed with a plan)
- [Pricing](https://ui.dimaac.com/pricing): Pro Components once, Pro templates one by one, Templates Monthly or Yearly, All-Access

## Optional

- [Full text](https://ui.dimaac.com/llms-full.txt): every component page in one file
- [This file](https://ui.dimaac.com/llms.txt)

---

---
title: ASCII Reveal
description: A photo typed out in ASCII that scrambles into the real image.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/ascii-reveal
registry: https://ui.dimaac.com/r/ascii-reveal.json
---

# ASCII Reveal

A photo that starts as ASCII characters and scrambles into the real image. The photo is shrunk to one pixel per character and each pixel's brightness picks a character, so the portrait is still recognisable in text. One GSAP tween drives it all: the characters flicker in from nothing, then every cell scrambles again, flashes the colour of its patch of the photo and opens onto the real picture, each at its own random moment. It plays on hover (and keyboard focus), when it scrolls into view, or on load; on touch screens hover falls back to in view. The characters follow the theme (light ink is drawn as a negative automatically), it fits any box and any grid from 10 to 80 cells with no measuring, and it shows the plain photo when the visitor prefers reduced motion or the image has no CORS.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/ascii-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/ascii-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { AsciiReveal } from "@/components/dimaac/ascii-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { AsciiReveal } from "@/components/dimaac/ascii-reveal";

<AsciiReveal src={src} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` | - | The photo. Remote images need CORS (the canvas reads their pixels); without it the photo shows as is. |
| `alt` | `string` | `""` | Describes the photo for screen readers. Empty means the photo is decoration. |
| `columns` | `number` | `25` | Characters across. |
| `rows` | `number` | `22` | Characters down. |
| `charset` | `string` | `DEFAULT_CHARSET` | The characters, from the emptiest to the densest. The first one is usually a space. |
| `scrambleChars` | `string` | `"#%@=+*"` | The characters a cell flickers through while it scrambles. |
| `duration` | `number` | `1.2` | How long one change takes (ASCII in, or ASCII to photo), in seconds. |
| `trigger` | `"hover" \| "inView" \| "load"` | `"hover"` | What reveals the photo: hovering or focusing it (touch screens use inView), scrolling it into view, or the page loading. |
| `delay` | `number` | `0` | Wait before the ASCII starts to scramble in, in seconds. Handy to stagger a grid of them. |
| `mosaic` | `boolean` | `true` | Each cell flashes the colour of its patch of the photo just before it opens. |
| `invert` | `boolean` | `false` | Use dense characters for the other end of the brightness range (a negative). |
| `background` | `string` | `"#111111"` | Background behind the characters. Any CSS colour, including var(--something). A transparent or see-through one takes the nearest solid background behind the box, so the photo never shows through the ASCII. |
| `color` | `string` | `"#d4d4d4"` | Colour of the characters. Any CSS colour, including var(--something). |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |

## Credits

- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s), Lummi License
- Portrait: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait: Dynamic Wang, Lummi (https://www.lummi.ai/photo/profile-portrait-of-woman-xyyue), Lummi License
- Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Portrait: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License

## Source

### ascii-reveal.tsx → `components/dimaac/ascii-reveal.tsx`

```tsx
"use client";

import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A photo that starts as ASCII characters and scrambles into the real image. The photo is shrunk
// to one pixel per character, and each pixel's brightness picks a character from the charset.
// One number, `progress`, drives everything and one GSAP tween moves it:
//   0 → 1  the characters scramble in from nothing until the ASCII portrait is there
//   1 → 2  each character scrambles again, flashes the colour of its patch of the photo, then
//          opens like a window onto the real photo underneath
// Every cell starts at its own random moment, so the picture builds up and dissolves in grain.
// The cells' background is a tiny canvas (one pixel per cell) stretched over the box, so even
// 80 x 80 cells cost almost nothing per frame.

export type AsciiRevealProps = {
  /** The photo. Remote images need CORS (the canvas reads their pixels); without it the photo shows as is. */
  src: string;
  /** Describes the photo for screen readers. Empty means the photo is decoration. */
  alt?: string;
  /** Characters across. */
  columns?: number;
  /** Characters down. */
  rows?: number;
  /** The characters, from the emptiest to the densest. The first one is usually a space. */
  charset?: string;
  /** The characters a cell flickers through while it scrambles. */
  scrambleChars?: string;
  /** How long one change takes (ASCII in, or ASCII to photo), in seconds. */
  duration?: number;
  /** What reveals the photo: hovering or focusing it (touch screens use inView), scrolling it into view, or the page loading. */
  trigger?: "hover" | "inView" | "load";
  /** Wait before the ASCII starts to scramble in, in seconds. Handy to stagger a grid of them. */
  delay?: number;
  /** Each cell flashes the colour of its patch of the photo just before it opens. */
  mosaic?: boolean;
  /** Use dense characters for the other end of the brightness range (a negative). */
  invert?: boolean;
  /** Background behind the characters. Any CSS colour, including var(--something). A transparent or see-through one takes the nearest solid background behind the box, so the photo never shows through the ASCII. */
  background?: string;
  /** Colour of the characters. Any CSS colour, including var(--something). */
  color?: string;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// The share of a phase over which the cells' start times are spread. The rest is each cell's own change.
const SPREAD = 0.6;
// While a cell opens, the first part of its change is scrambled characters, the rest is the colour flash
const SCRAMBLE_SHARE = 0.55;

type Sample = {
  /** The ASCII character of every cell, row by row */
  chars: string[];
  /** The average colour of every cell's patch, "rgb(r g b)" */
  colours: string[];
};

const DEFAULT_CHARSET = " .:-=+*#%@";

// Paint CSS colours (rgb, oklch, a variable's value...) on top of each other on a 1 x 1 canvas
// and read back the result as [r, g, b, a]. The canvas does the parsing and the blending for us.
function paint(colours: string[]): [number, number, number, number] {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 1;
  const context = canvas.getContext("2d", { willReadFrequently: true });
  if (!context) return [0, 0, 0, 255];
  for (const colour of colours) {
    context.fillStyle = colour;
    context.fillRect(0, 0, 1, 1);
  }
  const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data;
  return [r, g, b, a];
}

const readColour = (colour: string): [number, number, number] => {
  const [r, g, b] = paint([colour]);
  return [r, g, b];
};

const lightness = ([r, g, b]: [number, number, number]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;

// The solid colour the visitor actually sees behind the box. The cells must be opaque (they hide
// the photo until they open), so a transparent or see-through background is blended over the
// backgrounds of the elements behind it, up to the first solid one.
function solidBackground(box: HTMLElement): [number, number, number] {
  const layers: string[] = [];
  let solid = false;
  for (let node: HTMLElement | null = box; node; node = node.parentElement) {
    const colour = getComputedStyle(node).backgroundColor;
    layers.unshift(colour);
    if (paint([colour])[3] === 255) {
      solid = true;
      break;
    }
  }
  // Nothing solid all the way up: guess from the ink, light characters want a dark page
  if (!solid) layers.unshift(lightness(readColour(getComputedStyle(box).color)) > 0.5 ? "#111111" : "#ffffff");
  const [r, g, b] = paint(layers);
  return [r, g, b];
}

// Shrink the photo to one pixel per cell (cropped like object-fit: cover) and turn each pixel into a character
function sampleImage(image: HTMLImageElement, columns: number, rows: number, boxAspect: number, charset: string, denseIsBright: boolean): Sample {
  const imageAspect = image.naturalWidth / image.naturalHeight;
  let cropX = 0;
  let cropY = 0;
  let cropWidth = image.naturalWidth;
  let cropHeight = image.naturalHeight;
  if (imageAspect > boxAspect) {
    cropWidth = image.naturalHeight * boxAspect;
    cropX = (image.naturalWidth - cropWidth) / 2;
  } else {
    cropHeight = image.naturalWidth / boxAspect;
    cropY = (image.naturalHeight - cropHeight) / 2;
  }

  const canvas = document.createElement("canvas");
  canvas.width = columns;
  canvas.height = rows;
  const context = canvas.getContext("2d", { willReadFrequently: true })!;
  context.imageSmoothingQuality = "high";
  context.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, columns, rows);
  // Throws for a cross-origin photo served without CORS: the caller shows the plain photo then
  const { data } = context.getImageData(0, 0, columns, rows);

  const levels: number[] = [];
  const colours: string[] = [];
  for (let i = 0; i < columns * rows; i++) {
    const rgb: [number, number, number] = [data[i * 4]!, data[i * 4 + 1]!, data[i * 4 + 2]!];
    levels.push(lightness(rgb));
    colours.push(`rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`);
  }

  // Stretch the photo's own darkest..brightest range over the whole charset, so a grey photo still gets every character
  const darkest = Math.min(...levels);
  const range = Math.max(Math.max(...levels) - darkest, 0.001);
  const last = charset.length - 1;
  const chars = levels.map((level) => {
    const bright = (level - darkest) / range;
    return charset[Math.round((denseIsBright ? bright : 1 - bright) * last)] ?? " ";
  });
  return { chars, colours };
}

export function AsciiReveal({
  src,
  alt = "",
  columns = 25,
  rows = 22,
  charset = DEFAULT_CHARSET,
  scrambleChars = "#%@=+*",
  duration = 1.2,
  trigger = "hover",
  delay = 0,
  mosaic = true,
  invert = false,
  background = "#111111",
  color = "#d4d4d4",
  className = "",
}: AsciiRevealProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const imageRef = useRef<HTMLImageElement>(null);
  const cellsRef = useRef<HTMLCanvasElement>(null);
  const textRef = useRef<HTMLPreElement>(null);
  // Read when a tween starts, so a new duration doesn't rebuild anything
  const durationRef = useRef(duration);
  durationRef.current = duration;
  // The current run's resample, so a new background or colour only repaints instead of starting over
  const resampleRef = useRef<(() => void) | null>(null);

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const photo = imageRef.current;
      const cellsCanvas = cellsRef.current;
      const text = textRef.current;
      const cells = cellsCanvas?.getContext("2d");
      if (!box || !photo || !cellsCanvas || !text || !cells || !contextSafe) return;

      // Reduced motion: just the photo, still
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        gsap.set(photo, { opacity: 1 });
        gsap.set([cellsCanvas, text], { display: "none" });
        return;
      }

      const count = columns * rows;
      // An empty charset would draw nothing at all, so it falls back to the default one
      const chars = charset.length > 0 ? charset : DEFAULT_CHARSET;
      const flicker = scrambleChars.length > 0 ? scrambleChars : "#";
      // When each cell starts to change (0..1), random so the picture builds up in grain
      const order = Array.from({ length: count }, () => Math.random());
      const state = { progress: 0 };
      let sample: Sample | null = null;
      // The solid colour of a closed cell, worked out with each sample rather than every frame
      let fill = "rgb(17 17 17)";
      let alive = true;
      let tween: gsap.core.Tween | null = null;
      let hold: gsap.core.Tween | null = null;

      // Where a cell is in the current phase: 0 not started, 1 done
      const cellProgress = (phase: number, i: number) => gsap.utils.clamp(0, 1, (phase - order[i]! * SPREAD) / (1 - SPREAD));
      // A flickering character that changes about 20 times a second, different in every cell
      const scrambled = (i: number) => flicker[Math.floor(i * 7.31 + gsap.ticker.time * 20) % flicker.length]!;

      function draw() {
        if (!sample) return;
        const { progress } = state;
        const lines: string[] = [];
        let line = "";
        let lastFill = "";
        cells!.clearRect(0, 0, columns, rows);

        for (let i = 0; i < count; i++) {
          let char = " ";
          let cellFill: string | null = fill;

          if (progress <= 1) {
            // Scrambling in: nothing, then flicker, then the cell's own character
            const local = cellProgress(progress, i);
            char = local <= 0 ? " " : local >= 1 ? sample.chars[i]! : scrambled(i);
          } else {
            // Opening: the character, then flicker, then the colour of the photo, then the photo itself
            const local = cellProgress(progress - 1, i);
            if (local <= 0) char = sample.chars[i]!;
            else if (local < SCRAMBLE_SHARE) char = scrambled(i);
            else if (local < 1 && mosaic) cellFill = sample.colours[i]!;
            else cellFill = null;
          }

          if (cellFill) {
            // Setting fillStyle parses the colour, so only when it changes
            if (cellFill !== lastFill) cells!.fillStyle = lastFill = cellFill;
            cells!.fillRect(i % columns, Math.floor(i / columns), 1, 1);
          }
          line += char;
          if ((i + 1) % columns === 0) {
            lines.push(line);
            line = "";
          }
        }
        text!.textContent = lines.join("\n");
      }

      // Move `progress` to the target at a steady pace: the same speed whether it goes the whole way or half
      const goTo = contextSafe((target: number, wait = 0, then?: () => void) => {
        hold?.kill();
        tween?.kill();
        const distance = Math.abs(target - state.progress);
        if (distance === 0) return then?.();
        tween = gsap.to(state, {
          progress: target,
          duration: durationRef.current * distance,
          delay: wait,
          ease: "none",
          onUpdate: draw,
          onComplete: then,
        });
      });

      // The photo is cropped by the box's shape, so a new shape (or theme, which can flip the
      // characters' brightness) means a new sample
      const image = new Image();
      image.crossOrigin = "anonymous";
      image.decoding = "async";

      const resample = () => {
        if (!image.naturalWidth) return;
        const boxAspect = Math.max(box.clientWidth, 1) / Math.max(box.clientHeight, 1);
        const background = solidBackground(box);
        fill = `rgb(${background.join(" ")})`;
        // Light characters on a dark background draw the bright parts dense, ink on paper the dark parts
        const lightInk = lightness(readColour(getComputedStyle(box).color)) > lightness(background);
        sample = sampleImage(image, columns, rows, boxAspect, chars, lightInk !== invert);
        // Resizing a canvas clears it, so only here, right before the new sample is painted in the
        // same task: the old cells keep covering the photo until then
        cellsCanvas.width = columns;
        cellsCanvas.height = rows;
        draw();
      };

      // No ASCII possible (the photo won't load or has no CORS): show the photo as it is.
      // contextSafe, because it runs from async callbacks: the sets are then recorded in the GSAP
      // context and undone when the props change, so a working photo later gets the effect again.
      const showPhoto = contextSafe(() => {
        gsap.set(photo, { opacity: 1 });
        gsap.set([cellsCanvas, text], { display: "none" });
      });
      const tryResample = () => {
        try {
          resample();
        } catch {
          showPhoto();
        }
      };

      const reveal = contextSafe(() => goTo(2));
      const cover = contextSafe(() => goTo(1));
      // Hover and keyboard focus both hold it open: leaving one keeps it open while the other is still there
      const onPointerLeave = () => {
        if (!box.matches(":focus-visible")) cover();
      };
      const onBlur = () => {
        if (!box.matches(":hover")) cover();
      };
      const listeners: [string, () => void][] = [];
      const cleanups: (() => void)[] = [];

      // contextSafe too (it runs once the photo has decoded), so its set is undone on a prop change
      // and the photo goes back under the old cells instead of showing bare for a frame
      const start = contextSafe(() => {
        if (!alive) return;
        try {
          resample();
        } catch {
          showPhoto();
          return;
        }
        // The canvas now covers it, so the photo can be there underneath
        gsap.set(photo, { opacity: 1 });

        const hoverScreen = window.matchMedia("(hover: hover)").matches;
        if (trigger === "load") {
          goTo(1, delay, () => (hold = gsap.delayedCall(0.35, reveal)));
        } else if (trigger === "hover" && hoverScreen) {
          // Already under the pointer (say, after a prop change): open straight away
          if (box!.matches(":hover")) goTo(2);
          else goTo(1, delay);
          listeners.push(["pointerenter", reveal], ["pointerleave", onPointerLeave], ["focus", reveal], ["blur", onBlur]);
          listeners.forEach(([type, listener]) => box!.addEventListener(type, listener));
        } else {
          // inView, and hover on touch screens (a tap would only flash it): scramble in, then open,
          // once at least a third of it is on screen
          const watcher = new IntersectionObserver(
            ([entry]) => {
              if (!entry?.isIntersecting) return;
              watcher.disconnect();
              goTo(1, delay, () => (hold = gsap.delayedCall(0.35, reveal)));
            },
            { threshold: 0.35 },
          );
          watcher.observe(box!);
          cleanups.push(() => watcher.disconnect());
        }
      });

      const onResize = new ResizeObserver(tryResample);
      onResize.observe(box);

      resampleRef.current = tryResample;

      // A theme switch changes the colours (the canvas paints them itself) and maybe which end is dense.
      // Once now, and once more when a colour transition on the page would have finished.
      let settle: gsap.core.Tween | null = null;
      const onTheme = () => {
        tryResample();
        settle?.kill();
        settle = gsap.delayedCall(0.5, tryResample);
      };
      const themeWatcher = new MutationObserver(onTheme);
      themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] });
      const colourScheme = window.matchMedia("(prefers-color-scheme: dark)");
      colourScheme.addEventListener("change", onTheme);

      image.src = src;
      image
        .decode()
        .then(start)
        .catch(() => alive && showPhoto());

      return () => {
        alive = false;
        resampleRef.current = null;
        settle?.kill();
        image.src = "";
        hold?.kill();
        tween?.kill();
        onResize.disconnect();
        themeWatcher.disconnect();
        colourScheme.removeEventListener("change", onTheme);
        listeners.forEach(([type, listener]) => box.removeEventListener(type, listener));
        cleanups.forEach((cleanup) => cleanup());
      };
    },
    { scope: boxRef, dependencies: [src, columns, rows, charset, scrambleChars, trigger, delay, mosaic, invert], revertOnUpdate: true },
  );

  // The canvas paints the colours itself, so a new background or ink needs a repaint (after the
  // box has its new style, which is why this is an effect)
  useEffect(() => {
    resampleRef.current?.();
  }, [background, color]);

  // Cell size from the box's own size (container query units), so the characters fit any box with no measuring
  const cell = { width: `calc(100cqw / ${columns})`, height: `calc(100cqh / ${rows})` };

  return (
    <div
      ref={boxRef}
      tabIndex={trigger === "hover" ? 0 : undefined}
      className={`relative overflow-hidden outline-offset-4 focus-visible:outline-2 focus-visible:outline-current ${className}`}
      style={{ backgroundColor: background, color }}
    >
      {/* Hidden until the canvas covers it (or until we know there will be no ASCII) */}
      <img ref={imageRef} src={src} alt={alt} draggable={false} className="absolute inset-0 h-full w-full object-cover" style={{ opacity: 0 }} />
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ containerType: "size" }}>
        <canvas ref={cellsRef} className="absolute inset-0 h-full w-full" style={{ imageRendering: "pixelated" }} />
        <pre
          ref={textRef}
          className="absolute inset-0 m-0 overflow-hidden font-bold whitespace-pre select-none"
          style={{
            fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
            // A monospace character is about 0.6 of its size wide; shrink it if the rows are tight
            fontSize: `min(calc(${cell.width} / 0.6), calc(${cell.height} * 1.15))`,
            lineHeight: cell.height,
            // 1ch is one character's width in this font, so every character gets exactly one cell
            letterSpacing: `calc(${cell.width} - 1ch)`,
            paddingLeft: `calc((${cell.width} - 1ch) / 2)`,
          }}
        />
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { AsciiReveal, type AsciiRevealProps } from "@/components/dimaac/ascii-reveal";

// A small index of six portraits, each typed out in ASCII until you hover it.
// Photos from Lummi (Lummi License), credited in meta.ts.

const portraits = [
  { file: "portraits/mono-profile-4x5.jpg", title: "Mono profile", by: "West Kast", alt: "Black and white profile of a young woman in dramatic light" },
  { file: "portraits/neon-profile-4x5.jpg", title: "Neon pink", by: "Shaun Duval", alt: "A woman in neon pink sunglasses in profile against a bright blue background" },
  { file: "portraits/platinum-blonde-4x5.jpg", title: "Platinum", by: "studiomiklus", alt: "A young man with platinum blonde hair and a yellow jacket against an orange background" },
  { file: "portraits/warm-cool-profile-4x5.jpg", title: "Two lights", by: "Dynamic Wang", alt: "Profile of a woman lit by warm and cool light" },
  { file: "portraits/red-coat-4x5.jpg", title: "Red coat", by: "Lloyd Creates", alt: "A person in a red coat against a deep blue background" },
  { file: "portraits/lime-blazer-4x5.jpg", title: "Lime blazer", by: "Amino", alt: "A person with curly red hair in a lime green blazer against a bright pink background" },
];

export default function Demo(props: Partial<AsciiRevealProps>) {
  return (
    <div className="flex h-full w-full flex-col gap-4 bg-[#eeeae3] px-4 pt-5 pb-5 text-[#1b1a18] [--ink:#1b1a18] [--tile:#e3ded5] md:gap-6 md:px-10 md:pt-8 md:pb-8 lg:flex-row lg:gap-12 dark:bg-[#0b0b0b] dark:text-[#dcd8d0] dark:[--ink:#d9d5cd] dark:[--tile:#151515]">
      <header className="flex shrink-0 items-end justify-between gap-6 lg:w-[300px] lg:flex-col lg:items-start xl:w-[340px]">
        <div>
          <h1 className="text-[30px] leading-[0.95] font-medium tracking-[-0.04em] md:text-[52px] lg:text-[64px]">
            Faces, <br className="hidden lg:block" />
            typed out.
          </h1>
        </div>
        <div className="hidden font-mono text-[11px] leading-[1.6] tracking-[0.08em] uppercase sm:block lg:w-full">
          <p className="max-w-[26ch] text-right opacity-55 lg:text-left">Hover a portrait to scramble it into the photo</p>
          {/* The charset, emptiest to densest, as a little legend */}
          <p className="mt-5 hidden justify-between border-t border-current/15 pt-3 opacity-55 lg:flex" aria-hidden="true">
            {[..." .:-=+*#%@"].map((char, i) => (
              <span key={i} className="w-[1ch] text-center whitespace-pre">
                {char}
              </span>
            ))}
          </p>
        </div>
      </header>

      {/* The tiles take the biggest 4:5 size that fits: 2 x 3 on phones, 3 x 2 from tablets up */}
      <main className="min-h-0 min-w-0 flex-1" style={{ containerType: "size" }}>
        <ul className="mx-auto grid h-full w-fit grid-cols-[repeat(2,var(--tile-width))] content-center gap-x-3 gap-y-3 [--tile-width:min(calc((100cqw-12px)/2),calc(((100cqh-24px)/3-22px)*0.8))] md:grid-cols-[repeat(3,var(--tile-width))] md:gap-x-6 md:gap-y-5 md:[--tile-width:min(calc((100cqw-48px)/3),calc(((100cqh-20px)/2-26px)*0.8))] lg:mr-0">
          {portraits.map((portrait, i) => (
            <li key={portrait.file}>
              <figure>
                <AsciiReveal
                  src={media(portrait.file)}
                  alt={portrait.alt}
                  delay={0.15 + i * 0.12}
                  background="var(--tile)"
                  color="var(--ink)"
                  {...props}
                  className={`aspect-[4/5] w-full ${props.className ?? ""}`}
                />
                <figcaption className="mt-2 flex justify-between gap-3 font-mono text-[10px] leading-none tracking-[0.1em] whitespace-nowrap uppercase md:text-[11px]">
                  <span>
                    {portrait.title}
                  </span>
                  <span className="hidden opacity-45 md:inline">No. {String(i + 1).padStart(2, "0")}</span>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </main>
    </div>
  );
}
```

## Agent prompt

```text
Add the ASCII Reveal component (<AsciiReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/ascii-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/ascii-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/ascii-reveal.json

Then use it like this:
import { AsciiReveal } from "@/components/dimaac/ascii-reveal";
<AsciiReveal src={src} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/ascii-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Aurora Background
description: Soft colour blobs rising from an edge and drifting behind your content.
category: Backgrounds
tier: free
url: https://ui.dimaac.com/components/aurora-background
registry: https://ui.dimaac.com/r/aurora-background.json
---

# Aurora Background

Four soft colour blobs (teal, mint, violet and pink by default) rise from the bottom, top or middle of a box and drift slowly behind your content, with an optional giant faded wordmark. It is the glow from the DiMaac footer, made into a component. It is built from CSS gradients, one blur per blob and transform-only motion, so it stays light on the GPU, and it pauses while it is off screen. It is tuned separately for light and dark pages (it follows data-theme, a dark class or the system setting, or you can force one), holds still when the visitor prefers reduced motion, and takes your own four colours, a speed, a blur, an intensity and a touch of film grain.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/aurora-background.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/aurora-background.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/aurora-background.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/aurora-background`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/aurora-background.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { AuroraBackground } from "@/components/dimaac/aurora-background" and give its parent a size: it fills it.

## Usage

```tsx
import { AuroraBackground } from "@/components/dimaac/aurora-background";

<AuroraBackground />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors` | `string[]` | - | Four CSS colours for the four blobs, left to right. Overrides `palette`. Fewer than four repeat. |
| `palette` | `AuroraPalette` | `"dimaac"` | A ready-made set of four colours. "dimaac" is teal, mint, violet and pink. |
| `intensity` | `number` | `0.85` | How strong the whole glow is, from 0 (gone) to 1 (full). |
| `blur` | `number` | `48` | How soft the blobs are, as a blur in pixels. More blur melts the colours together. |
| `speed` | `number` | `1` | How fast the blobs drift. 1 is slow and calm, 0 holds them still, 3 is lively. |
| `position` | `"bottom" \| "top" \| "center"` | `"bottom"` | The edge the glow rises from. |
| `wordmark` | `string \| null` | `null` | A giant faded word sitting in the glow. Leave it out (or null) for none. |
| `grain` | `number` | `0` | Film grain over the glow, from 0 (none) to 0.2 (heavy). It hides colour banding. |
| `theme` | `"auto" \| "light" \| "dark"` | `"auto"` | Which tuning to use. "auto" follows the page: data-theme, a dark class, or the system setting. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size, or let your content size it. |
| `children` | `ReactNode` | - | Your content. It sits above the glow. |

## Source

### aurora-background.tsx → `components/dimaac/aurora-background.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// Soft colour blobs that rise from an edge and drift slowly behind your content, with an optional
// giant faded word in the glow. It is the DiMaac footer aurora, made into a component.
// It stays cheap: every blob is one div with a gradient and a blur, and the drift only moves
// transforms, so the browser's GPU does the work. It pauses when it scrolls out of view.

export type AuroraPalette = "dimaac" | "sunset" | "ocean" | "ember" | "mono";

export type AuroraBackgroundProps = {
  /** Four CSS colours for the four blobs, left to right. Overrides `palette`. Fewer than four repeat. */
  colors?: string[];
  /** A ready-made set of four colours. "dimaac" is teal, mint, violet and pink. */
  palette?: AuroraPalette;
  /** How strong the whole glow is, from 0 (gone) to 1 (full). */
  intensity?: number;
  /** How soft the blobs are, as a blur in pixels. More blur melts the colours together. */
  blur?: number;
  /** How fast the blobs drift. 1 is slow and calm, 0 holds them still, 3 is lively. */
  speed?: number;
  /** The edge the glow rises from. */
  position?: "bottom" | "top" | "center";
  /** A giant faded word sitting in the glow. Leave it out (or null) for none. */
  wordmark?: string | null;
  /** Film grain over the glow, from 0 (none) to 0.2 (heavy). It hides colour banding. */
  grain?: number;
  /** Which tuning to use. "auto" follows the page: data-theme, a dark class, or the system setting. */
  theme?: "auto" | "light" | "dark";
  /** Extra classes for the outer box. Give it a size, or let your content size it. */
  className?: string;
  /** Your content. It sits above the glow. */
  children?: ReactNode;
};

// ---------- colours, tuned once for light and once for dark ----------
// On paper the glow can be strong and the colours deep. On near black the same colours would
// glare, so the mint is turned down and the teal and violet carry the light instead.

const palettes: Record<AuroraPalette, { light: string[]; dark: string[] }> = {
  dimaac: { light: ["#0f766e", "#99f6e4", "#7c3aed", "#f9a8d4"], dark: ["#14b8a6", "#99f6e4", "#7c3aed", "#f9a8d4"] },
  sunset: { light: ["#f97316", "#fde68a", "#e11d48", "#a855f7"], dark: ["#fb923c", "#fde68a", "#e11d48", "#a855f7"] },
  ocean: { light: ["#0284c7", "#a5f3fc", "#4f46e5", "#67e8f9"], dark: ["#38bdf8", "#a5f3fc", "#4f46e5", "#67e8f9"] },
  ember: { light: ["#dc2626", "#fdba74", "#9f1239", "#fb7185"], dark: ["#ef4444", "#fdba74", "#be123c", "#fb7185"] },
  mono: { light: ["#525252", "#d4d4d4", "#262626", "#a3a3a3"], dark: ["#d4d4d4", "#737373", "#a3a3a3", "#525252"] },
};

// How strong each of the four blobs is
const strength = {
  light: [0.55, 0.9, 0.45, 0.55],
  dark: [0.8, 0.38, 0.62, 0.34],
};

const wordmarkInk = {
  light: ["rgba(10,10,10,0.16)", "rgba(10,10,10,0.06)", "rgba(255,255,255,0.35)"],
  dark: ["rgba(255,255,255,0.12)", "rgba(255,255,255,0.04)", "rgba(255,255,255,0.22)"],
};

// Each blob: where it sits across the width (%), how wide and tall it is (% of the glow layer),
// and how it drifts: sideways and up/down in % of its own size, the scale it breathes between,
// the seconds one swing takes, and how far into the swing it starts so they never move together.
const blobs = [
  { x: 18, width: 80, height: 120, driftX: 10, driftY: 9, scale: 1.12, seconds: 9, start: 0 },
  { x: 42, width: 84, height: 140, driftX: -8, driftY: 12, scale: 1.08, seconds: 12, start: 0.35 },
  { x: 66, width: 80, height: 130, driftX: 9, driftY: 8, scale: 1.14, seconds: 10, start: 0.7 },
  { x: 88, width: 60, height: 100, driftX: -11, driftY: 11, scale: 1.1, seconds: 14, start: 0.15 },
] as const;

// Grain: a small tile of grey noise, drawn once as an SVG. Overlay blending keeps it neutral on
// both light and dark pages.
const noise =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1.6 -0.3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\")";

// ---------- "is the page dark?" without any site context ----------

function pageIsDark(): boolean {
  const root = document.documentElement;
  const picked = root.getAttribute("data-theme");
  if (picked === "dark") return true;
  if (picked === "light") return false;
  if (root.classList.contains("dark")) return true;
  if (root.classList.contains("light")) return false;
  return window.matchMedia("(prefers-color-scheme: dark)").matches;
}

function watchPageTheme(onChange: () => void): () => void {
  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
  const query = window.matchMedia("(prefers-color-scheme: dark)");
  query.addEventListener("change", onChange);
  return () => {
    observer.disconnect();
    query.removeEventListener("change", onChange);
  };
}

export function AuroraBackground({
  colors,
  palette = "dimaac",
  intensity = 0.85,
  blur = 48,
  speed = 1,
  position = "bottom",
  wordmark = null,
  grain = 0,
  theme = "auto",
  className = "",
  children,
}: AuroraBackgroundProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const blobRefs = useRef<(HTMLDivElement | null)[]>([]);
  const swings = useRef<gsap.core.Tween[]>([]);
  // Read by the drift so a new speed never rebuilds it
  const speedRef = useRef(speed);
  speedRef.current = speed;

  // On a narrow box (a phone) the blobs would shrink into thin streaks, so they are drawn wider
  const [compact, setCompact] = useState(false);
  useEffect(() => {
    const box = boxRef.current;
    if (!box) return;
    const watch = new ResizeObserver(([entry]) => setCompact((entry?.contentRect.width ?? 1000) < 640));
    watch.observe(box);
    return () => watch.disconnect();
  }, []);

  // The server renders the light tuning; the browser switches to dark right after if it should
  const pageDark = useSyncExternalStore(watchPageTheme, pageIsDark, () => false);
  const mode = theme === "auto" ? (pageDark ? "dark" : "light") : theme;

  // ---------- the drift ----------
  useGSAP(
    () => {
      const box = boxRef.current;
      const elements = blobRefs.current.filter((el): el is HTMLDivElement => el !== null);
      if (!box || elements.length === 0) return;

      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        // One endless back-and-forth swing per blob; yoyo makes it ease out and back with no jump
        swings.current = elements.map((el, i) => {
          const blob = blobs[i]!;
          return gsap.to(el, {
            xPercent: blob.driftX,
            yPercent: -blob.driftY,
            scale: blob.scale,
            duration: blob.seconds,
            ease: "sine.inOut",
            repeat: -1,
            yoyo: true,
            force3D: true,
          });
        });
        swings.current.forEach((swing, i) => {
          swing.progress(blobs[i]!.start);
          swing.timeScale(speedRef.current);
        });

        // Nothing moves while the glow is off screen
        const visibility = new IntersectionObserver(([entry]) => {
          const on = entry?.isIntersecting ?? true;
          swings.current.forEach((swing) => (on ? swing.resume() : swing.pause()));
        });
        visibility.observe(box);

        return () => {
          visibility.disconnect();
          swings.current = [];
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [position], revertOnUpdate: true },
  );

  // A new speed only changes how fast the swings play
  useEffect(() => {
    swings.current.forEach((swing) => swing.timeScale(Math.max(0, speed)));
  }, [speed]);

  // ---------- what to draw ----------
  const picked = colors && colors.length > 0 ? colors : palettes[palette][mode];
  const colourFor = (i: number) => picked[i % picked.length]!;
  const alphas = strength[mode];
  // Where the blobs are centred, up and down the glow layer
  const centreY = position === "bottom" ? 100 : position === "top" ? 0 : 50;
  // The glow layer runs 30% past the edge it rises from, so the blobs' round bottoms stay off screen
  const layer: CSSProperties =
    position === "bottom" ? { top: 0, height: "130%" } : position === "top" ? { top: "-30%", height: "130%" } : { top: "-15%", height: "130%" };

  const ink = wordmarkInk[mode];
  const wordPlace: CSSProperties =
    position === "bottom"
      ? { bottom: "-0.2em", backgroundImage: `linear-gradient(180deg, ${ink[0]} 0%, ${ink[1]} 55%, ${ink[2]} 100%)` }
      : position === "top"
        ? { top: "-0.2em", backgroundImage: `linear-gradient(0deg, ${ink[0]} 0%, ${ink[1]} 55%, ${ink[2]} 100%)` }
        : { top: "50%", transform: "translateY(-50%)", backgroundImage: `linear-gradient(180deg, ${ink[1]} 0%, ${ink[0]} 50%, ${ink[1]} 100%)` };

  const widen = compact ? 1.7 : 1;
  const safeBlur = Math.max(0, blur);

  return (
    <div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`} style={{ containerType: "inline-size" }}>
      <div aria-hidden="true" className="pointer-events-none absolute" style={{ left: "-10%", right: "-10%", opacity: Math.min(1, Math.max(0, intensity)), ...layer }}>
        {blobs.map((blob, i) => (
          <div
            key={i}
            ref={(el) => {
              blobRefs.current[i] = el;
            }}
            className="absolute"
            style={{
              left: `${blob.x - (blob.width * widen) / 2}%`,
              top: `${centreY - blob.height / 2}%`,
              width: `${blob.width * widen}%`,
              height: `${blob.height}%`,
              // Fades from the colour at the heart to nothing at 70% of the way out, like the footer
              backgroundImage: `radial-gradient(closest-side, color-mix(in srgb, ${colourFor(i)} ${alphas[i]! * 100}%, transparent), transparent 70%)`,
              filter: safeBlur > 0 ? `blur(${safeBlur}px)` : undefined,
              willChange: "transform",
            }}
          />
        ))}
      </div>

      {wordmark ? (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute right-0 left-0 text-center leading-none font-medium whitespace-nowrap select-none"
          style={{
            fontSize: "clamp(96px, 25cqw, 380px)",
            letterSpacing: "-0.07em",
            color: "transparent",
            WebkitBackgroundClip: "text",
            backgroundClip: "text",
            ...wordPlace,
          }}
        >
          {wordmark}
        </div>
      ) : null}

      {grain > 0 ? (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
          style={{ backgroundImage: noise, backgroundSize: "160px 160px", mixBlendMode: "overlay", opacity: Math.min(1, grain * 5) }}
        />
      ) : null}

      <div className="relative h-full w-full">{children}</div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { AuroraBackground, type AuroraBackgroundProps } from "@/components/dimaac/aurora-background";

// A quiet landing page: paper, ink, and the glow rising from the bottom edge.
// The playground's values arrive as props and go straight to the component.
export default function Demo(props: Partial<AuroraBackgroundProps>) {
  return (
    <div className="h-full w-full bg-[#fafafa] text-[#0a0a0a] dark:bg-[#0b0b0c] dark:text-[#ededed]">
      <AuroraBackground {...props} className={props.className ?? ""}>
        <div className="flex h-full flex-col px-6 py-6 sm:px-10">
          <span className="text-[13px] font-medium tracking-tight">DiMaac</span>

          <div className="flex flex-1 flex-col items-center justify-start pt-[16vh] text-center">
            <h1 className="max-w-[14ch] text-[clamp(44px,8vw,112px)] leading-[0.95] font-medium tracking-[-0.045em]">Light that rises slowly</h1>
            <p className="mt-6 max-w-[38ch] text-[15px] leading-relaxed opacity-60">A calm backdrop made from nothing but gradients. Switch the site theme and watch it retune itself.</p>
            <div className="mt-8 flex gap-3">
              <button type="button" className="rounded-full bg-[#0a0a0a] px-5 py-2.5 text-[13px] font-medium text-[#fafafa] dark:bg-[#ededed] dark:text-[#0b0b0c]">
                Get started
              </button>
              <button type="button" className="rounded-full border border-current/20 px-5 py-2.5 text-[13px] font-medium">
                Browse components
              </button>
            </div>
          </div>
        </div>
      </AuroraBackground>
    </div>
  );
}
```

## Agent prompt

```text
Add the Aurora Background component (<AuroraBackground />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/aurora-background
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/aurora-background.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/aurora-background.json

Then use it like this:
import { AuroraBackground } from "@/components/dimaac/aurora-background";
<AuroraBackground />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/aurora-background.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Clip-Path Slider
description: Full-bleed slides that open through a clip-path while the old one zooms back.
category: Gallery
tier: free
url: https://ui.dimaac.com/components/clip-slider
registry: https://ui.dimaac.com/r/clip-slider.json
---

# Clip-Path Slider

A full-bleed image slider for an agency's selected work. Each change opens the next photo through a clip-path from the bottom while the old photo zooms back under it, a framed copy in the middle opens from the opposite edge with a little parallax, and the counter and title roll to the new slide. It moves on the wheel (one change per gesture, so trackpads don't skip slides), swipes on touch, arrow buttons and the arrow keys, loops at both ends, can play by itself with a pause button, and swaps with a short cross-fade when the visitor prefers reduced motion. Only clip-paths and transforms are animated, with GSAP and its Observer plugin.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/clip-slider.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/clip-slider.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/clip-slider.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/clip-slider`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/clip-slider.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ClipSlider } from "@/components/dimaac/clip-slider" and give its parent a size: it fills it.

## Usage

```tsx
import { ClipSlider } from "@/components/dimaac/clip-slider";

<ClipSlider slides={slides} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `slides` (required) | `ClipSliderSlide[]` | - | The slides, in order. Two or more to slide; it loops at both ends. |
| `duration` | `number` | `1.25` | How long one change takes, in seconds. |
| `zoom` | `number` | `1.35` | How far the incoming photo is zoomed in before it settles to 1. The old photo zooms half as far. |
| `ease` | `string` | `"power3.inOut"` | The GSAP ease of a change. |
| `direction` | `"vertical" \| "horizontal"` | `"vertical"` | Which way the photos open: up and down, or sideways. |
| `autoplay` | `number` | `0` | Seconds between automatic changes. 0 turns it off (it's always off with reduced motion). |
| `showCounter` | `boolean` | `true` | Show the rolling "01 / 06" counter. |
| `showFrame` | `boolean` | `true` | Show the framed copy of the photo in the middle. |
| `controls` | `"wheel" \| "buttons" \| "both"` | `"both"` | How visitors change slides: the wheel (and swipes on touch), arrow buttons, or both. Arrow keys always work. |
| `label` | `string` | `"Image slider"` | Names the slider for screen readers. |
| `onChange` | `(index: number) => void` | - | Called with the new slide's index when a change starts. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `children` | `ReactNode` | - | Content laid over the slides (a logo, a nav). Its own links and buttons stay clickable. |

## Credits

- Alpine Dawn Serenity: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/an-empty-snowy-mountain-with-pink-sunrise-on-the-hori-270fdea6-887e-4d89-8205-3eab907f3fb0-HgoCN), Lummi License
- Desert Dunes and Mountain Range: Pablo Stanley, Lummi (https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd), Lummi License
- Serene Minimalist Scene with Palm Trees: Binks AI, Lummi (https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c), Lummi License
- Serene Ocean Aerial View: Mikiwa, Lummi (https://www.lummi.ai/photo/serene-ocean-aerial-view-kjab6), Lummi License
- Solitary Tree in Desert: alexaperios, Lummi (https://www.lummi.ai/photo/solitary-tree-in-desert-wsyns), Lummi License
- Minimalist Urban Design: Ricardo Matos, Lummi (https://www.lummi.ai/photo/minimalist-urban-design-abiri), Lummi License

## Source

### clip-slider.tsx → `components/dimaac/clip-slider.tsx`

```tsx
"use client";

import { useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { gsap } from "gsap";
import { Observer } from "gsap/Observer";
import { useGSAP } from "@gsap/react";

// A full-bleed image slider. The next photo opens over the old one through a clip-path while the
// old one slowly zooms back, a framed copy of the photo in the middle opens from the opposite edge,
// and the counter and title roll to the new slide. Every layer is in the page from the start; the
// slider only moves clip-paths and transforms, so a change never waits for React or an image.

if (typeof window !== "undefined") gsap.registerPlugin(Observer, useGSAP);

export type ClipSliderSlide = {
  /** The photo, shown full-bleed and in the frame */
  src: string;
  /** Rolled in at the bottom left. Leave every title out to hide the line. */
  title?: string;
  /** Describes the photo for screen readers. Empty means the photo is decoration. */
  alt?: string;
};

export type ClipSliderProps = {
  /** The slides, in order. Two or more to slide; it loops at both ends. */
  slides: ClipSliderSlide[];
  /** How long one change takes, in seconds. */
  duration?: number;
  /** How far the incoming photo is zoomed in before it settles to 1. The old photo zooms half as far. */
  zoom?: number;
  /** The GSAP ease of a change. */
  ease?: string;
  /** Which way the photos open: up and down, or sideways. */
  direction?: "vertical" | "horizontal";
  /** Seconds between automatic changes. 0 turns it off (it's always off with reduced motion). */
  autoplay?: number;
  /** Show the rolling "01 / 06" counter. */
  showCounter?: boolean;
  /** Show the framed copy of the photo in the middle. */
  showFrame?: boolean;
  /** How visitors change slides: the wheel (and swipes on touch), arrow buttons, or both. Arrow keys always work. */
  controls?: "wheel" | "buttons" | "both";
  /** Names the slider for screen readers. */
  label?: string;
  /** Called with the new slide's index when a change starts. */
  onChange?: (index: number) => void;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Content laid over the slides (a logo, a nav). Its own links and buttons stay clickable. */
  children?: ReactNode;
};

// A clip-path that hides the layer by squashing it against one edge, so opening it to OPEN
// sweeps the photo in from that edge
const OPEN = "inset(0% 0% 0% 0%)";
const closedAt = { top: "inset(0% 0% 100% 0%)", bottom: "inset(100% 0% 0% 0%)", left: "inset(0% 100% 0% 0%)", right: "inset(0% 0% 0% 100%)" } as const;

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// The layers that exist (the frame's are missing when it's hidden)
const present = (...elements: (HTMLElement | undefined)[]) => elements.filter((element): element is HTMLElement => element !== undefined);

const pad = (n: number) => String(n).padStart(2, "0");

type Step = 1 | -1;
type SliderApi = { go: (step: Step) => void; togglePause: () => void };

export function ClipSlider({
  slides,
  duration = 1.25,
  zoom = 1.35,
  ease = "power3.inOut",
  direction = "vertical",
  autoplay = 0,
  showCounter = true,
  showFrame = true,
  controls = "both",
  label = "Image slider",
  onChange,
  className = "",
  children,
}: ClipSliderProps) {
  const boxRef = useRef<HTMLElement>(null);
  const progressRef = useRef<HTMLSpanElement>(null);
  const api = useRef<SliderApi | null>(null);
  // Kept across rebuilds (new props), so the slider stays on the slide it's showing
  const currentRef = useRef(0);
  const [index, setIndex] = useState(0);
  const [paused, setPaused] = useState(false);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);

  // Read when a change starts, so tweaking them never rebuilds the slider
  const settings = useRef({ duration, zoom, ease, direction, onChange });
  settings.current = { duration, zoom, ease, direction, onChange };

  const count = slides.length;
  const slideKey = slides.map((slide) => slide.src).join("|");
  const hasTitles = slides.some((slide) => slide.title);
  const autoplaying = autoplay > 0 && !reducedMotion && count > 1;

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      if (!box || !contextSafe || count === 0) return;

      const all = (name: string) => Array.from(box.querySelectorAll<HTMLElement>(`[data-clip-${name}]`));
      const layers = all("slide");
      const photos = all("photo");
      const frames = all("frame");
      const framePhotos = all("frame-photo");
      const numbers = all("number");
      const titles = all("title");

      let current = Math.min(currentRef.current, count - 1);
      currentRef.current = current;
      setIndex(current);
      let busy = false;
      let gestureReady = true;
      let inView = true;
      let userPaused = false;
      let top = 1;
      let timer: gsap.core.Tween | null = null;

      // Lay out the slide we're on: open and on top. Every other layer waits, hidden.
      layers.forEach((layer, i) => gsap.set(layer, { clipPath: i === current ? OPEN : closedAt.bottom, visibility: i === current ? "visible" : "hidden", zIndex: i === current ? 1 : 0, opacity: 1 }));
      frames.forEach((frame, i) => gsap.set(frame, { clipPath: i === current ? OPEN : closedAt.top, visibility: i === current ? "visible" : "hidden", zIndex: i === current ? 1 : 0, opacity: 1 }));
      gsap.set(photos, { scale: 1 });
      if (framePhotos.length) gsap.set(framePhotos, { xPercent: 0, yPercent: 0 });
      // The counter and the title roll: the current one in its box, the rest waiting below it
      const rolling = [numbers, titles].filter((list) => list.length === count);
      rolling.forEach((list) => gsap.set(list, { y: 0, yPercent: (i: number) => (i === current ? 0 : 110) }));

      // The autoplay clock is a tween on the progress line: when the line is full, the slider moves on
      const startClock = contextSafe(() => {
        timer?.kill();
        timer = null;
        if (!autoplaying) return;
        timer = gsap.fromTo(progressRef.current ?? {}, { scaleX: 0 }, { scaleX: 1, duration: autoplay, ease: "none", onComplete: () => go(1) });
        if (userPaused || !inView) timer.pause();
      });

      const go = contextSafe((step: Step) => {
        if (busy || count < 2) return;
        busy = true;
        timer?.kill();

        const from = current;
        const to = (current + step + count) % count;
        current = to;
        currentRef.current = to;
        setIndex(to);
        settings.current.onChange?.(to);

        const { duration: time, zoom: zoomIn, ease: easing, direction: way } = settings.current;
        const vertical = way === "vertical";
        // Next opens the photo from the bottom (or right) and the frame from the top (or left); back is the mirror
        const photoEdge = vertical ? (step === 1 ? "bottom" : "top") : step === 1 ? "right" : "left";
        const frameEdge = vertical ? (step === 1 ? "top" : "bottom") : step === 1 ? "left" : "right";
        const shift = vertical ? "yPercent" : "xPercent";
        top += 1;

        const finish = () => {
          gsap.set(present(layers[from], frames[from]), { visibility: "hidden" });
          gsap.set(photos[from]!, { scale: 1 });
          if (framePhotos[from]) gsap.set(framePhotos[from], { xPercent: 0, yPercent: 0 });
          busy = false;
          startClock();
        };

        // Reduced motion: no sweeping or zooming, a short cross-fade and the numbers swap in place
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
          gsap.set(present(layers[to], frames[to]), { zIndex: top, visibility: "visible", clipPath: OPEN, opacity: 0 });
          gsap.set(photos[to]!, { scale: 1 });
          rolling.forEach((list) => gsap.set(list, { yPercent: (i: number) => (i === to ? 0 : 110) }));
          gsap.to(present(layers[to], frames[to]), { opacity: 1, duration: 0.35, ease: "power1.out", onComplete: finish });
          return;
        }

        gsap.set(layers[to]!, { zIndex: top, visibility: "visible", clipPath: closedAt[photoEdge] });
        gsap.set(photos[to]!, { scale: zoomIn });
        if (frames[to]) {
          gsap.set(frames[to], { zIndex: top, visibility: "visible", clipPath: closedAt[frameEdge] });
          gsap.set(framePhotos[to]!, { xPercent: 0, yPercent: 0, [shift]: -step * 50 });
        }
        rolling.forEach((list) => gsap.set(list[to]!, { yPercent: step * 110 }));

        const timeline = gsap.timeline({ defaults: { duration: time, ease: easing }, onComplete: finish });
        timeline
          .to(layers[to]!, { clipPath: OPEN }, 0)
          .to(photos[to]!, { scale: 1 }, 0)
          // The old photo drifts further in while it's covered, so the new one seems to push past it
          .to(photos[from]!, { scale: 1 + (zoomIn - 1) / 2 }, 0);
        if (frames[to] && frames[from]) {
          timeline
            .to(frames[to], { clipPath: OPEN }, 0)
            .to(framePhotos[to]!, { [shift]: 0 }, 0)
            .to(framePhotos[from]!, { [shift]: step * 50 }, 0);
        }
        rolling.forEach((list) => timeline.to(list[from]!, { yPercent: -step * 110 }, 0).to(list[to]!, { yPercent: 0 }, 0));
      });

      const togglePause = contextSafe(() => {
        userPaused = !userPaused;
        setPaused(userPaused);
        if (userPaused) timer?.pause();
        else if (inView) timer?.resume();
      });

      api.current = { go, togglePause };
      startClock();

      // The wheel and touch swipes. wheelSpeed -1 makes wheel-down and swipe-up both "up" (next).
      // One change per gesture: a trackpad keeps sending wheel events long after the flick, so the
      // next change waits until the wheel has been still for a moment.
      let observer: Observer | null = null;
      if (controls !== "buttons" && count > 1) {
        const fromGesture = (step: Step) => {
          if (!gestureReady || busy) return;
          gestureReady = false;
          go(step);
        };
        observer = Observer.create({
          target: box,
          type: "wheel,touch",
          wheelSpeed: -1,
          tolerance: 10,
          preventDefault: true,
          ignore: box.querySelectorAll("[data-clip-ui]"),
          onUp: () => fromGesture(1),
          onDown: () => fromGesture(-1),
          onLeft: () => fromGesture(1),
          onRight: () => fromGesture(-1),
          onStopDelay: 0.18,
          onStop: () => {
            gestureReady = true;
          },
          onRelease: () => {
            gestureReady = true;
          },
        });
      }

      // Arrow keys work when focus is inside the slider, or on the page itself while the slider fills
      // most of the screen (so a full-screen slider answers the keyboard straight away). From the page,
      // PageUp/PageDown are left alone, and on the first or last slide the arrows hand back to the page
      // when it can scroll, so a keyboard user is never stuck on a hero slider that loops forever.
      const onKeyDown = (event: KeyboardEvent) => {
        if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
        const target = event.target instanceof Element ? event.target : null;
        const inside = target !== null && box.contains(target);
        const onPage = target === null || target === document.body || target === document.documentElement;
        if (!inside && !(onPage && inView)) return;
        if (target?.closest("input, textarea, select, [contenteditable]")) return;
        const forward = inside ? ["ArrowDown", "ArrowRight", "PageDown"] : ["ArrowDown", "ArrowRight"];
        const back = inside ? ["ArrowUp", "ArrowLeft", "PageUp"] : ["ArrowUp", "ArrowLeft"];
        const step: Step | 0 = forward.includes(event.key) ? 1 : back.includes(event.key) ? -1 : 0;
        if (!step) return;
        if (!inside) {
          const atEnd = step === 1 ? current === count - 1 : current === 0;
          const pageScrolls = document.documentElement.scrollHeight > window.innerHeight + 1;
          if (atEnd && pageScrolls) return;
        }
        event.preventDefault();
        go(step);
      };
      window.addEventListener("keydown", onKeyDown);

      // Off screen: the autoplay clock stops, and the keys stop answering
      const visibility = new IntersectionObserver(
        ([entry]) => {
          inView = (entry?.intersectionRatio ?? 1) >= 0.5;
          if (!inView) timer?.pause();
          else if (!userPaused) timer?.resume();
        },
        { threshold: [0, 0.5, 1] },
      );
      visibility.observe(box);

      return () => {
        observer?.kill();
        window.removeEventListener("keydown", onKeyDown);
        visibility.disconnect();
        timer?.kill();
        api.current = null;
      };
    },
    { scope: boxRef, dependencies: [slideKey, count, controls, autoplay, autoplaying, showFrame, showCounter, hasTitles], revertOnUpdate: true },
  );

  const showButtons = controls !== "wheel" && count > 1;
  const vertical = direction === "vertical";
  const current = slides[index];

  return (
    <section
      ref={boxRef}
      aria-roledescription="carousel"
      aria-label={label}
      className={`relative overflow-hidden bg-neutral-950 text-white select-none ${className}`}
      style={{ containerType: "inline-size", touchAction: controls === "buttons" ? "auto" : "none" }}
    >
      {/* isolate keeps the slides' growing z-index inside this layer, under the frame and the text */}
      <div className="absolute inset-0 isolate">
        {slides.map((slide, i) => (
        <div
          key={`${i}-${slide.src}`}
          data-clip-slide=""
          role="group"
          aria-roledescription="slide"
          aria-label={`${i + 1} of ${count}`}
          aria-hidden={i !== index}
          className="absolute inset-0 overflow-hidden"
          style={{ clipPath: i === 0 ? OPEN : closedAt.bottom, visibility: i === 0 ? "visible" : "hidden", willChange: "clip-path" }}
        >
          <img data-clip-photo="" src={slide.src} alt={slide.alt ?? ""} className="absolute inset-0 block h-full w-full object-cover" style={{ willChange: "transform" }} draggable={false} decoding="async" />
        </div>
        ))}
      </div>

      {/* Shade on the top, left and bottom so overlaid text reads on any photo, even a pale sky */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-[1]"
        style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.32), rgba(0,0,0,0) 22%), linear-gradient(to right, rgba(0,0,0,0.45), rgba(0,0,0,0) 55%), linear-gradient(to top, rgba(0,0,0,0.5), rgba(0,0,0,0) 34%)" }}
      />

      {showFrame && count > 0 ? (
        // The framed copy is decoration: the full-bleed photo already carries the alt text
        <div
          aria-hidden="true"
          className="pointer-events-none absolute top-[46%] left-1/2 z-[2] -translate-x-1/2 -translate-y-1/2 shadow-[0_40px_90px_rgba(0,0,0,0.35)]"
          style={{ height: "min(max(225px, 27.5cqw), 64%)", aspectRatio: "4 / 5" }}
        >
          {slides.map((slide, i) => (
            <div
              key={`${i}-${slide.src}`}
              data-clip-frame=""
              className="absolute inset-0 overflow-hidden"
              style={{ clipPath: i === 0 ? OPEN : closedAt.top, visibility: i === 0 ? "visible" : "hidden", willChange: "clip-path" }}
            >
              <img data-clip-frame-photo="" src={slide.src} alt="" className="absolute inset-0 block h-full w-full object-cover" style={{ willChange: "transform" }} draggable={false} decoding="async" />
            </div>
          ))}
        </div>
      ) : null}

      {children ? <div className="pointer-events-none absolute inset-0 z-[3] [&_a]:pointer-events-auto [&_button]:pointer-events-auto">{children}</div> : null}

      <div className="pointer-events-none absolute inset-x-0 bottom-0 z-[4] flex items-end justify-between gap-6 px-[clamp(20px,2.2cqw,32px)] pb-[clamp(20px,1.9cqw,28px)]">
        {/* Every title sits in the same grid cell, so the line is as wide as the longest one */}
        {hasTitles ? (
          <p aria-hidden="true" className="grid min-w-0 overflow-hidden text-[clamp(26px,3.4cqw,52px)] leading-[1.08] font-medium tracking-[-0.035em]">
            {slides.map((slide, i) => (
              <span key={`${i}-${slide.src}`} data-clip-title="" className="truncate [grid-area:1/1]" style={{ willChange: "transform" }}>
                {slide.title ?? ""}
              </span>
            ))}
          </p>
        ) : (
          <span />
        )}

        <div className="flex shrink-0 items-center gap-4">
          {showCounter && count > 0 ? (
            <p aria-hidden="true" className="flex items-baseline gap-[0.18em] text-[14px] leading-4 tracking-[0.05em] tabular-nums [text-shadow:0_1px_8px_rgba(0,0,0,0.35)]">
              <span className="grid h-4 overflow-hidden">
                {slides.map((slide, i) => (
                  <span key={`${i}-${slide.src}`} data-clip-number="" className="[grid-area:1/1]" style={{ willChange: "transform" }}>
                    {pad(i + 1)}
                  </span>
                ))}
              </span>
              <span className="opacity-60">/</span>
              <span className="opacity-60">{pad(count)}</span>
            </p>
          ) : null}

          {autoplaying ? (
            <button
              type="button"
              data-clip-ui=""
              onClick={() => api.current?.togglePause()}
              aria-label={paused ? "Play slideshow" : "Pause slideshow"}
              className="pointer-events-auto relative flex h-10 items-center gap-2 rounded-full px-1 text-[12px] tracking-[0.05em] uppercase outline-none focus-visible:ring-2 focus-visible:ring-white"
            >
              <span className="relative h-px w-12 overflow-hidden bg-white/30">
                <span ref={progressRef} className="absolute inset-0 origin-left scale-x-0 bg-white" />
              </span>
              <span className="w-[3.2em] text-left opacity-80">{paused ? "Play" : "Pause"}</span>
            </button>
          ) : null}

          {count > 1 ? (
            // With wheel-only controls the buttons stay hidden until a keyboard user tabs to them
            <div data-clip-ui="" className={`flex gap-2 ${showButtons ? "" : "opacity-0 focus-within:opacity-100"}`}>
              {([-1, 1] as const).map((step) => (
                <button
                  key={step}
                  type="button"
                  onClick={() => api.current?.go(step)}
                  aria-label={step === 1 ? "Next slide" : "Previous slide"}
                  className={`flex h-10 w-10 items-center justify-center rounded-full border border-white/35 bg-white/0 backdrop-blur-[2px] transition-colors duration-300 outline-none hover:border-white hover:bg-white hover:text-neutral-950 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 ${showButtons ? "pointer-events-auto" : "pointer-events-none focus:pointer-events-auto"}`}
                >
                  <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true" style={{ transform: `rotate(${(vertical ? 90 : 0) + (step === 1 ? 0 : 180)}deg)` }}>
                    <path d="M2 8h11M9 4l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
              ))}
            </div>
          ) : null}
        </div>
      </div>

      <p className="sr-only" aria-live={autoplaying && !paused ? "off" : "polite"} aria-atomic="true">
        {current ? `Slide ${index + 1} of ${count}${current.title ? `: ${current.title}` : ""}` : ""}
      </p>
    </section>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { ClipSlider, type ClipSliderProps, type ClipSliderSlide } from "@/components/dimaac/clip-slider";

// The agency "selected work" intro from the video: one project per slide, wheel or arrows to move.
// Photos from Lummi (Lummi License).

const slides: ClipSliderSlide[] = [
  { src: "https://ui.dimaac.com/media/nature/alpine-dawn-16x9.jpg", title: "Morning Ridge", alt: "Snow-covered mountains at sunrise under a pink and orange sky" },
  { src: "https://ui.dimaac.com/media/nature/golden-dunes-3x2.jpg", title: "Dune Studies", alt: "Golden sand dunes and distant mountains under a clear blue sky" },
  { src: "https://ui.dimaac.com/media/architecture/palm-pool-3x2.jpg", title: "Pool House", alt: "Minimal white architecture with two palm trees on a still water platform" },
  { src: "https://ui.dimaac.com/media/nature/turquoise-shallows-3x2.jpg", title: "Shallow Water", alt: "Aerial view of turquoise ocean over sandy seabed patterns" },
  { src: "https://ui.dimaac.com/media/nature/lone-tree-3x2.jpg", title: "Still Standing", alt: "A lone green tree on a golden dune under a blue sky" },
  { src: "https://ui.dimaac.com/media/architecture/concrete-corner-3x2.jpg", title: "Concrete Hours", alt: "A minimal concrete corner with a black-framed window and gravel floor" },
];

export default function Demo(props: Partial<ClipSliderProps>) {
  return (
    <ClipSlider slides={slides} label="Photo gallery" {...props} className={`h-full w-full font-sans ${props.className ?? ""}`}>
      <header className="absolute inset-x-0 top-0 flex items-start justify-between [text-shadow:0_1px_10px_rgba(0,0,0,0.28)] px-[clamp(20px,2.2cqw,32px)] pt-[clamp(18px,1.8cqw,26px)] text-[13px] leading-[1.35] tracking-[0.01em]">
        <a href="#" aria-label="DiMaac, home" className="text-[17px] font-semibold tracking-[-0.03em]" onClick={(event) => event.preventDefault()}>
          DiMaac
        </a>
        <nav aria-label="Main" className="flex gap-5 opacity-90">
          {["Work", "About", "Contact"].map((item) => (
            <a key={item} href="#" className="transition-opacity duration-300 hover:opacity-60" onClick={(event) => event.preventDefault()}>
              {item}
            </a>
          ))}
        </nav>
      </header>
    </ClipSlider>
  );
}
```

## Agent prompt

```text
Add the Clip-Path Slider component (<ClipSlider />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/clip-slider
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/clip-slider.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/clip-slider.json

Then use it like this:
import { ClipSlider } from "@/components/dimaac/clip-slider";
<ClipSlider slides={slides} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/clip-slider.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Colour Trail
description: A greyscale photo the cursor paints back into colour.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/colour-trail
registry: https://ui.dimaac.com/r/colour-trail.json
---

# Colour Trail

A photo shown in greyscale until the cursor paints over it. The paint has a soft, wobbling edge, fades on its own and only lights up the bright, colourful parts of the picture, so a UV-paint portrait glows where you touch it. Two tiny WebGL shaders, no libraries. It paints at a fixed 60 steps a second on any screen, plays a short stroke by itself on touch screens, and shows the photo in full colour, still, when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/colour-trail.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/colour-trail`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/colour-trail.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ColourTrail } from "@/components/dimaac/colour-trail" and give its parent a size: it fills it.

## Usage

```tsx
import { ColourTrail } from "@/components/dimaac/colour-trail";

<ColourTrail src={src} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` | - | The photo to paint. Remote images need CORS (WebGL reads their pixels). |
| `alt` | `string` | `""` | Describes the photo for screen readers. Empty means the photo is decoration. |
| `brushSize` | `number` | `0.13` | Radius of the brush, as a share of the height. |
| `fade` | `number` | `0.01` | How fast the paint fades, per step (60 steps a second). 0 keeps it forever. |
| `wobble` | `number` | `0.06` | How much the paint's edge wobbles. 0 is a clean circle. |
| `intensity` | `number` | `1` | How much paint a stroke lays down at a given speed. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `children` | `ReactNode` | - | Content shown over the photo. The brush keeps painting under it. |

## Credits

- Portrait: Sandra L. Sarango, Unsplash (https://unsplash.com/photos/toM-BH2nOHg), Unsplash License

## Source

### colour-trail.tsx → `components/dimaac/colour-trail.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";

// A greyscale photo that the cursor paints back into colour. Two small WebGL1 shaders:
// one keeps a "trail" texture (where the brush has been, fading a little every step), the
// other draws the photo in grey and mixes the colour back in wherever the trail is. Noise wobbles
// the edge of the paint, and a glow mask lets only the bright, colourful pixels take colour.

export type ColourTrailProps = {
  /** The photo to paint. Remote images need CORS (WebGL reads their pixels). */
  src: string;
  /** Describes the photo for screen readers. Empty means the photo is decoration. */
  alt?: string;
  /** Radius of the brush, as a share of the height. */
  brushSize?: number;
  /** How fast the paint fades, per step (60 steps a second). 0 keeps it forever. */
  fade?: number;
  /** How much the paint's edge wobbles. 0 is a clean circle. */
  wobble?: number;
  /** How much paint a stroke lays down at a given speed. */
  intensity?: number;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Content shown over the photo. The brush keeps painting under it. */
  children?: ReactNode;
};

const vertexShader = `
attribute vec2 position;
varying vec2 uv;
void main() {
  uv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

// Last step's trail, a little faded, plus a soft line from the brush's last spot to its new one
const trailShader = `
precision highp float;
varying vec2 uv;
uniform sampler2D trail;
uniform vec2 from;
uniform vec2 to;
uniform float aspect;
uniform float size;
uniform float strength;
uniform float fade;
void main() {
  float paint = texture2D(trail, uv).r - fade;
  vec2 point = (uv - from) * vec2(aspect, 1.0);
  vec2 line = (to - from) * vec2(aspect, 1.0);
  float along = clamp(dot(point, line) / max(dot(line, line), 0.00001), 0.0, 1.0);
  float stroke = 1.0 - smoothstep(0.0, size, length(point - line * along));
  gl_FragColor = vec4(paint + stroke * strength, 0.0, 0.0, 1.0);
}`;

// The photo in grey, with its colour mixed back in where the (wobbled) trail is
const photoShader = `
precision highp float;
varying vec2 uv;
uniform sampler2D photo;
uniform sampler2D trail;
uniform vec2 scale;
uniform vec2 offset;
uniform float aspect;
uniform float time;
uniform float wobble;
float random(vec2 point) {
  return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
}
float noise(vec2 point) {
  vec2 cell = floor(point);
  vec2 blend = smoothstep(0.0, 1.0, fract(point));
  float a = random(cell);
  float b = random(cell + vec2(1.0, 0.0));
  float c = random(cell + vec2(0.0, 1.0));
  float d = random(cell + vec2(1.0, 1.0));
  return mix(mix(a, b, blend.x), mix(c, d, blend.x), blend.y);
}
void main() {
  vec3 color = texture2D(photo, uv * scale + offset).rgb;
  vec3 gray = vec3(dot(color, vec3(0.299, 0.587, 0.114)));
  vec2 point = uv * vec2(aspect, 1.0) * 6.0;
  vec2 shift = vec2(noise(point + time * 0.4), noise(point - time * 0.4 + 9.0)) - 0.5;
  float reveal = smoothstep(0.4, 0.6, texture2D(trail, uv + shift * wobble).r);
  float glow = smoothstep(0.03, 0.12, max(color.g, color.b));
  gl_FragColor = vec4(mix(gray, color, reveal * glow), 1.0);
}`;

// The path the brush paints by itself once on touch screens, so people see what it does
const hintPath = [
  [0.2, 0.6],
  [0.4, 0.7],
  [0.6, 0.66],
  [0.8, 0.62],
  [0.88, 0.5],
] as const;

function pointOnHint(progress: number): [number, number] {
  const eased = -(Math.cos(Math.PI * progress) - 1) / 2;
  const segments = hintPath.length - 1;
  const index = Math.min(Math.floor(eased * segments), segments - 1);
  const local = eased * segments - index;
  const [ax, ay] = hintPath[index]!;
  const [bx, by] = hintPath[index + 1]!;
  return [ax + (bx - ax) * local, ay + (by - ay) * local];
}

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

type Trail = { texture: WebGLTexture; framebuffer: WebGLFramebuffer; width: number; height: number };

export function ColourTrail({ src, alt = "", brushSize = 0.13, fade = 0.01, wobble = 0.06, intensity = 1, className = "", children }: ColourTrailProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const hostRef = useRef<HTMLDivElement>(null);
  const settings = useRef({ brushSize, fade, wobble, intensity });
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
  // The src that failed (no WebGL, or the image won't load); a new src tries again
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
  const failed = failedSrc === src;
  const [ready, setReady] = useState(false);

  // The render loop reads these every frame, so changing a prop never rebuilds WebGL
  useEffect(() => {
    settings.current = { brushSize, fade, wobble, intensity };
  }, [brushSize, fade, wobble, intensity]);

  useEffect(() => {
    const box = boxRef.current;
    const host = hostRef.current;
    if (!box || !host || reducedMotion || failed) return;

    // A fresh canvas every time: a canvas whose context was released can't make a new one
    const canvas = document.createElement("canvas");
    canvas.setAttribute("aria-hidden", "true");
    canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block";
    host.appendChild(canvas);

    const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false });
    if (!gl) {
      canvas.remove();
      setFailedSrc(src);
      return;
    }
    const context = gl;

    let alive = true;
    let frame = 0;
    let visible = true;
    let trails: Trail[] = [];
    let trailProgram: WebGLProgram | null = null;
    let photoProgram: WebGLProgram | null = null;
    let photoTexture: WebGLTexture | null = null;
    let buffer: WebGLBuffer | null = null;
    let width = 1;
    let height = 1;
    let lastTime = performance.now();
    let leftover = 0;

    const pointer = { x: 0.5, y: 0.5 };
    const brush = { x: 0.5, y: 0.5 };
    let brushPlaced = false;
    let touched = false;
    let hintStart = -1;

    const image = new Image();
    image.crossOrigin = "anonymous";
    image.decoding = "async";
    image.src = src;

    function createProgram(fragmentShader: string) {
      const program = context.createProgram()!;
      for (const [type, source] of [
        [context.VERTEX_SHADER, vertexShader],
        [context.FRAGMENT_SHADER, fragmentShader],
      ] as const) {
        const shader = context.createShader(type)!;
        context.shaderSource(shader, source);
        context.compileShader(shader);
        context.attachShader(program, shader);
        context.deleteShader(shader);
      }
      context.bindAttribLocation(program, 0, "position");
      context.linkProgram(program);
      return program;
    }

    function createTexture(w: number, h: number, source?: HTMLImageElement) {
      const texture = context.createTexture()!;
      context.bindTexture(context.TEXTURE_2D, texture);
      if (source) context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, context.RGBA, context.UNSIGNED_BYTE, source);
      else context.texImage2D(context.TEXTURE_2D, 0, context.RGBA, w, h, 0, context.RGBA, context.UNSIGNED_BYTE, null);
      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.LINEAR);
      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.LINEAR);
      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE);
      context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE);
      return texture;
    }

    function createTrail(w: number, h: number): Trail {
      const texture = createTexture(w, h);
      const framebuffer = context.createFramebuffer()!;
      context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
      context.framebufferTexture2D(context.FRAMEBUFFER, context.COLOR_ATTACHMENT0, context.TEXTURE_2D, texture, 0);
      return { texture, framebuffer, width: w, height: h };
    }

    function deleteTrails() {
      for (const trail of trails) {
        context.deleteTexture(trail.texture);
        context.deleteFramebuffer(trail.framebuffer);
      }
      trails = [];
    }

    function draw(program: WebGLProgram, uniforms: Record<string, number | number[]>, textures: Record<string, WebGLTexture>, target?: Trail) {
      context.useProgram(program);
      for (const [name, value] of Object.entries(uniforms)) {
        const location = context.getUniformLocation(program, name);
        if (Array.isArray(value)) context.uniform2fv(location, value);
        else context.uniform1f(location, value);
      }
      Object.entries(textures).forEach(([name, texture], unit) => {
        context.activeTexture(context.TEXTURE0 + unit);
        context.bindTexture(context.TEXTURE_2D, texture);
        context.uniform1i(context.getUniformLocation(program, name), unit);
      });
      context.bindFramebuffer(context.FRAMEBUFFER, target ? target.framebuffer : null);
      context.viewport(0, 0, target ? target.width : canvas.width, target ? target.height : canvas.height);
      context.drawArrays(context.TRIANGLES, 0, 3);
    }

    // The canvas matches the box; the trail runs at half size (it's soft anyway)
    function resize() {
      width = Math.max(1, box!.clientWidth);
      height = Math.max(1, box!.clientHeight);
      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(width * pixelRatio);
      canvas.height = Math.round(height * pixelRatio);
      if (!trailProgram) return;
      deleteTrails();
      const w = Math.max(1, Math.round(width / 2));
      const h = Math.max(1, Math.round(height / 2));
      trails = [createTrail(w, h), createTrail(w, h)];
    }

    // object-fit: cover for the photo
    function cover() {
      const boxAspect = width / height;
      const photoAspect = image.width / image.height;
      const scale = boxAspect > photoAspect ? [1, photoAspect / boxAspect] : [boxAspect / photoAspect, 1];
      return { scale, offset: [(1 - scale[0]!) / 2, (1 - scale[1]!) / 2] };
    }

    // One fixed step: the brush eases toward the pointer and paints the line it travelled
    function paintTrail() {
      const from = [brush.x, brush.y];
      brush.x += (pointer.x - brush.x) * 0.25;
      brush.y += (pointer.y - brush.y) * 0.25;
      const speed = Math.hypot(brush.x - from[0]!, brush.y - from[1]!);
      const { brushSize: size, fade: fading, intensity: amount } = settings.current;

      draw(
        trailProgram!,
        { from, to: [brush.x, brush.y], aspect: width / height, size, strength: Math.min(speed * 60 * amount, 1), fade: fading },
        { trail: trails[0]!.texture },
        trails[1],
      );
      trails.reverse();
    }

    function render(now: number) {
      if (!alive) return;
      frame = requestAnimationFrame(render);
      if (!visible) {
        lastTime = now;
        return;
      }

      // The brush on touch screens paints its hint path until the first touch
      if (hintStart > 0 && !touched && now >= hintStart) {
        const progress = Math.min((now - hintStart) / 2000, 1);
        const [x, y] = pointOnHint(progress);
        pointer.x = x;
        pointer.y = y;
        if (!brushPlaced) {
          brush.x = x;
          brush.y = y;
          brushPlaced = true;
        }
        if (progress === 1) hintStart = -1;
      }

      // 60 trail steps a second whatever the screen's refresh rate, so the look is the same at 120 Hz
      leftover = Math.min(leftover + now - lastTime, 100);
      lastTime = now;
      while (leftover >= 1000 / 60) {
        leftover -= 1000 / 60;
        paintTrail();
      }

      const { scale, offset } = cover();
      draw(photoProgram!, { scale, offset, aspect: width / height, time: now / 1000, wobble: settings.current.wobble }, { photo: photoTexture!, trail: trails[0]!.texture });
    }

    function place(event: PointerEvent) {
      const rect = box!.getBoundingClientRect();
      pointer.x = (event.clientX - rect.left) / rect.width;
      pointer.y = 1 - (event.clientY - rect.top) / rect.height;
      if (!brushPlaced) {
        brush.x = pointer.x;
        brush.y = pointer.y;
        brushPlaced = true;
      }
    }

    function onPointerMove(event: PointerEvent) {
      if (!event.isPrimary) return;
      touched = true;
      place(event);
    }

    // A new touch starts a new stroke instead of a line from the last spot
    function onPointerDown(event: PointerEvent) {
      if (!event.isPrimary) return;
      touched = true;
      brushPlaced = false;
      place(event);
    }

    function onPointerLeave() {
      brushPlaced = false;
    }

    function onContextLost(event: Event) {
      event.preventDefault();
      cancelAnimationFrame(frame);
      if (alive) setFailedSrc(src);
    }

    const resizeObserver = new ResizeObserver(resize);
    // Nothing to draw while the box is off screen
    const visibility = new IntersectionObserver(([entry]) => {
      visible = entry?.isIntersecting ?? true;
    });

    function start() {
      if (!alive) return;
      trailProgram = createProgram(trailShader);
      photoProgram = createProgram(photoShader);
      buffer = context.createBuffer();
      context.bindBuffer(context.ARRAY_BUFFER, buffer);
      context.bufferData(context.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), context.STATIC_DRAW);
      context.enableVertexAttribArray(0);
      context.vertexAttribPointer(0, 2, context.FLOAT, false, 0, 0);
      context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);
      photoTexture = createTexture(image.width, image.height, image);

      resize();
      resizeObserver.observe(box!);
      visibility.observe(box!);
      box!.addEventListener("pointermove", onPointerMove);
      box!.addEventListener("pointerdown", onPointerDown);
      box!.addEventListener("pointerleave", onPointerLeave);
      canvas.addEventListener("webglcontextlost", onContextLost);

      if (window.matchMedia("(hover: none)").matches) hintStart = performance.now() + 1200;
      lastTime = performance.now();
      frame = requestAnimationFrame(render);
      setReady(true);
    }

    image
      .decode()
      .then(start)
      .catch(() => alive && setFailedSrc(src));

    return () => {
      alive = false;
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibility.disconnect();
      box.removeEventListener("pointermove", onPointerMove);
      box.removeEventListener("pointerdown", onPointerDown);
      box.removeEventListener("pointerleave", onPointerLeave);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      image.src = "";
      deleteTrails();
      if (photoTexture) context.deleteTexture(photoTexture);
      if (trailProgram) context.deleteProgram(trailProgram);
      if (photoProgram) context.deleteProgram(photoProgram);
      if (buffer) context.deleteBuffer(buffer);
      // Hand the context back now: browsers only allow a few at a time
      context.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
      setReady(false);
    };
  }, [src, reducedMotion, failed]);

  // Reduced motion, no WebGL or an image that won't load: the photo in full colour, still
  const still = reducedMotion || failed;

  return (
    <div ref={boxRef} className={`relative overflow-hidden bg-neutral-950 ${className}`} style={{ touchAction: "pan-y" }}>
      {still ? (
        <img src={src} alt={alt} className="absolute inset-0 h-full w-full object-cover" draggable={false} />
      ) : (
        <div
          ref={hostRef}
          role={alt ? "img" : undefined}
          aria-label={alt || undefined}
          className="absolute inset-0"
          style={{ opacity: ready ? 1 : 0, transform: ready ? "none" : "scale(1.06)", transition: "opacity 2.4s cubic-bezier(0.2, 0.7, 0.2, 1), transform 2.4s cubic-bezier(0.2, 0.7, 0.2, 1)" }}
        />
      )}
      {/* Pointer events from here bubble up to the box, so the brush keeps painting under the text */}
      {children ? <div className="relative h-full w-full">{children}</div> : null}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { ColourTrail, type ColourTrailProps } from "@/components/dimaac/colour-trail";

// A UV-paint portrait with a headline over it: the photo is grey until you paint it.
// Photo: Sandra L. Sarango on Unsplash (Unsplash License).

export default function Demo(props: Partial<ColourTrailProps>) {
  // The text rises in once the page is up (only when motion is allowed)
  const [shown, setShown] = useState(false);
  useEffect(() => {
    const id = requestAnimationFrame(() => setShown(true));
    return () => cancelAnimationFrame(id);
  }, []);
  const rise = `motion-safe:transition-[transform,opacity] motion-safe:duration-[1400ms] motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] ${shown ? "translate-y-0 opacity-100" : "motion-safe:translate-y-6 motion-safe:opacity-0"}`;

  return (
    <>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&display=swap" precedence="default" />
      <ColourTrail src="https://ui.dimaac.com/media/portraits/luz.jpg" alt="A woman with glowing ultraviolet paint on her face" {...props} className={`h-full w-full ${props.className ?? ""}`}>
        <div className="pointer-events-none absolute inset-0 grid grid-cols-3 bg-gradient-to-b from-transparent from-55% to-black/60 md:grid-cols-7">
          {Array.from({ length: 7 }, (_, i) => (
            <span key={i} className={`${i > 0 ? "border-l border-white/15" : ""} ${i > 2 ? "hidden md:block" : ""}`} />
          ))}
        </div>

        <main
          className="pointer-events-none absolute inset-7 grid grid-cols-1 grid-rows-[1fr_auto] text-[15px] leading-[1.4] text-white select-none md:inset-x-[84px] md:inset-y-[58px] md:grid-cols-2"
          style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
        >
          <div className="max-w-[360px] self-end">
            <h1 className={`text-[40px] leading-none uppercase md:text-[56px] ${rise}`} style={{ fontFamily: "Anton, Impact, sans-serif", transitionDelay: "600ms" }}>
              Paint with light
            </h1>
            <p className={`mt-4 text-white/75 ${rise}`} style={{ transitionDelay: "680ms" }}>
              Under ultraviolet every brushstroke wakes up. Here the colour only returns where you touch.
            </p>
          </div>

          <p className={`mt-5 self-end text-left text-[13px] tracking-[0.14em] text-white/75 uppercase md:mt-0 md:justify-self-end md:text-right ${rise}`} style={{ transitionDelay: "760ms" }}>
            Move to paint
          </p>
        </main>
      </ColourTrail>
    </>
  );
}
```

## Agent prompt

```text
Add the Colour Trail component (<ColourTrail />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/colour-trail
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/colour-trail.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/colour-trail.json

Then use it like this:
import { ColourTrail } from "@/components/dimaac/colour-trail";
<ColourTrail src={src} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/colour-trail.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Context Menu
description: A right-click menu that pops open from the cursor.
category: Navigation
tier: free
url: https://ui.dimaac.com/components/context-menu
registry: https://ui.dimaac.com/r/context-menu.json
---

# Context Menu

Wrap anything in it, hand it a list of items, and right-click: a menu pops open from the cursor with a quick scale and a stagger on the rows, flips or slides in so it never leaves the screen, and one soft highlight glides between the rows. It is a real menu for keyboards and screen readers (role=menu and menuitem, arrow keys, Home and End, type a letter to jump, Enter to choose, Escape to close with focus returned, and the Menu key or Shift + F10 to open it), a long press opens it on touch screens, menus can be nested (the innermost one opens), and it listens to the document only while it is open. It follows prefers-reduced-motion by appearing without movement. Ported from the first DiMaac UI and rebuilt: the always-on scroll listeners and the missing keyboard support are gone.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/context-menu.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/context-menu.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/context-menu.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/context-menu`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/context-menu.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ContextMenu } from "@/components/dimaac/context-menu" and give its parent a size: it fills it.

## Usage

```tsx
import { ContextMenu } from "@/components/dimaac/context-menu";

<ContextMenu children={children} items={items} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | - | What the menu opens on. Right-click (or long-press on touch) anywhere inside it. |
| `items` (required) | `ContextMenuItem[]` | - | The rows of the menu. |
| `onItemSelect` | `(item: ContextMenuItem) => void` | - | Called with the row that was chosen (after the row's own onSelect). |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the menu opens (true) and starts closing (false). |
| `width` | `number` | `232` | Width of the menu, in pixels. |
| `openDuration` | `number` | `0.26` | How long the menu takes to pop open, in seconds. |
| `stagger` | `number` | `0.022` | Delay between one row appearing and the next, in seconds. |
| `startScale` | `number` | `0.9` | How small the menu starts, from 0.5 to 1. It grows from the cursor. |
| `ease` | `string` | `"power3.out"` | The GSAP ease of the pop, e.g. "power3.out" or "back.out(1.7)". |
| `accent` | `string` | `"#0f766e"` | The colour of the highlight and the focus. |
| `glass` | `boolean` | `true` | A frosted-glass menu (blurred, see-through) instead of a solid one. |
| `disabled` | `boolean` | `false` | Turn the custom menu off, so the browser's own menu shows. |
| `longPress` | `number` | `480` | How long a finger must stay down on touch screens to open the menu, in milliseconds. |
| `className` | `string` | `""` | Extra classes for the box around the children. |
| `menuClassName` | `string` | `""` | Extra classes for the menu itself, e.g. a different radius. |

## Credits

- Photo: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License
- Photo: Bernardo Lickfold, Lummi (https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i), Lummi License
- Photo: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr), Lummi License
- Photo: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License

## Source

### context-menu.tsx → `components/dimaac/context-menu.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, type RefObject, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A right-click menu that opens where the cursor is. Wrap anything in it and give it a list of items.
// It opens with a quick pop from the cursor and a stagger on the rows, one soft highlight glides
// between the rows, and it is a real menu for keyboards and screen readers (arrow keys, Home and End,
// type a letter to jump, Enter to choose, Escape to close and give focus back). On touch screens a
// long press opens it. The document listeners exist only while the menu is open.

export type ContextMenuItem = {
  /** A unique id for the row. */
  id: string;
  /** The text of the row. */
  label: string;
  /** An icon shown before the label, e.g. a lucide-react icon: <Heart size={16} />. */
  icon?: ReactNode;
  /** A shortcut hint shown on the right, e.g. "⌘ D". It is only a label: the shortcut itself is yours to handle. */
  shortcut?: string;
  /** Called when the row is chosen. */
  onSelect?: (item: ContextMenuItem) => void;
  /** Draw a divider line after this row. */
  separator?: boolean;
  /** Show the row greyed out and skip it. */
  disabled?: boolean;
  /** Colour the row red, for things like Delete. */
  danger?: boolean;
};

export type ContextMenuProps = {
  /** What the menu opens on. Right-click (or long-press on touch) anywhere inside it. */
  children: ReactNode;
  /** The rows of the menu. */
  items: ContextMenuItem[];
  /** Called with the row that was chosen (after the row's own onSelect). */
  onItemSelect?: (item: ContextMenuItem) => void;
  /** Called when the menu opens (true) and starts closing (false). */
  onOpenChange?: (open: boolean) => void;
  /** Width of the menu, in pixels. */
  width?: number;
  /** How long the menu takes to pop open, in seconds. */
  openDuration?: number;
  /** Delay between one row appearing and the next, in seconds. */
  stagger?: number;
  /** How small the menu starts, from 0.5 to 1. It grows from the cursor. */
  startScale?: number;
  /** The GSAP ease of the pop, e.g. "power3.out" or "back.out(1.7)". */
  ease?: string;
  /** The colour of the highlight and the focus. */
  accent?: string;
  /** A frosted-glass menu (blurred, see-through) instead of a solid one. */
  glass?: boolean;
  /** Turn the custom menu off, so the browser's own menu shows. */
  disabled?: boolean;
  /** How long a finger must stay down on touch screens to open the menu, in milliseconds. */
  longPress?: number;
  /** Extra classes for the box around the children. */
  className?: string;
  /** Extra classes for the menu itself, e.g. a different radius. */
  menuClassName?: string;
};

type Opening = { x: number; y: number; key: number; keyboard: boolean };

type MenuSettings = Required<Pick<ContextMenuProps, "width" | "openDuration" | "stagger" | "startScale" | "ease" | "accent" | "glass" | "menuClassName">>;

// Menus can sit inside each other (a card inside a board). The innermost one opens, and marks the
// event here so the ones around it leave it alone.
const claimed = new WeakSet<Event>();

export function ContextMenu({
  children,
  items,
  onItemSelect,
  onOpenChange,
  width = 232,
  openDuration = 0.26,
  stagger = 0.022,
  startScale = 0.9,
  ease = "power3.out",
  accent = "#0f766e",
  glass = true,
  disabled = false,
  longPress = 480,
  className = "",
  menuClassName = "",
}: ContextMenuProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const [opening, setOpening] = useState<Opening | null>(null);
  const [closing, setClosing] = useState(false);
  const counter = useRef(0);
  const lastOpenAt = useRef(0);
  const lastLongPressAt = useRef(0);
  const pressTimer = useRef<number | null>(null);
  const pressStart = useRef({ x: 0, y: 0 });
  // The callback can change on every render: keep the newest one without re-running effects
  const onOpenChangeRef = useRef(onOpenChange);
  onOpenChangeRef.current = onOpenChange;
  const reported = useRef(false);

  const open = opening !== null && !closing;
  useEffect(() => {
    if (reported.current === open) return;
    reported.current = open;
    onOpenChangeRef.current?.(open);
  }, [open]);

  const openAt = (x: number, y: number, keyboard: boolean) => {
    counter.current += 1;
    lastOpenAt.current = performance.now();
    setClosing(false);
    // A new key builds a fresh menu, so opening it somewhere else plays the pop again
    setOpening({ x, y, key: counter.current, keyboard });
  };

  const clearPress = () => {
    if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);
    pressTimer.current = null;
  };
  useEffect(() => clearPress, []);

  const onContextMenu = (event: ReactMouseEvent) => {
    if (disabled || items.length === 0 || claimed.has(event.nativeEvent)) return;
    claimed.add(event.nativeEvent);
    event.preventDefault();
    // A long press on Android fires this a moment after our own timer already opened the menu
    if (performance.now() - lastOpenAt.current < 900) return;
    openAt(event.clientX, event.clientY, false);
  };

  // Touch screens: a long press opens the menu (iOS never sends a contextmenu event)
  const onPointerDown = (event: ReactPointerEvent) => {
    if (disabled || items.length === 0 || event.pointerType !== "touch" || !event.isPrimary || claimed.has(event.nativeEvent)) return;
    claimed.add(event.nativeEvent);
    clearPress();
    const { clientX, clientY } = event;
    pressStart.current = { x: clientX, y: clientY };
    pressTimer.current = window.setTimeout(() => {
      pressTimer.current = null;
      lastLongPressAt.current = performance.now();
      openAt(clientX, clientY, false);
    }, longPress);
  };
  const onPointerMove = (event: ReactPointerEvent) => {
    // A finger that moves is scrolling or dragging, not pressing
    const start = pressStart.current;
    if (pressTimer.current !== null && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10) clearPress();
  };
  // The tap that ends a long press shouldn't also click what is under the finger
  const swallowClick = (event: ReactMouseEvent) => {
    if (performance.now() - lastLongPressAt.current < 600) {
      event.preventDefault();
      event.stopPropagation();
    }
  };

  // The keyboard way in: the Menu key, or Shift + F10, opens it beside the focused element
  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (disabled || items.length === 0 || claimed.has(event.nativeEvent)) return;
    if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
    claimed.add(event.nativeEvent);
    event.preventDefault();
    const box = boxRef.current;
    const focused = document.activeElement;
    const target = focused instanceof HTMLElement && box?.contains(focused) ? focused : box;
    if (!target) return;
    const rect = target.getBoundingClientRect();
    openAt(rect.left + Math.min(rect.width / 2, 48), rect.top + Math.min(rect.height / 2, 32), true);
  };

  return (
    <div
      ref={boxRef}
      className={className}
      onContextMenu={onContextMenu}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={clearPress}
      onPointerCancel={clearPress}
      onClickCapture={swallowClick}
      onKeyDown={onKeyDown}
      style={{ WebkitTouchCallout: "none" }}
    >
      {children}
      {opening && !disabled
        ? createPortal(
            <Menu
              key={opening.key}
              opening={opening}
              items={items}
              settings={{ width, openDuration, stagger, startScale, ease, accent, glass, menuClassName }}
              onItemSelect={onItemSelect}
              onClosing={() => setClosing(true)}
              onDone={() => setOpening((current) => (current && current.key === opening.key ? null : current))}
              fallbackFocus={boxRef}
            />,
            document.body,
          )
        : null}
    </div>
  );
}

type MenuProps = {
  opening: Opening;
  items: ContextMenuItem[];
  settings: MenuSettings;
  onItemSelect?: (item: ContextMenuItem) => void;
  onClosing: () => void;
  onDone: () => void;
  fallbackFocus: RefObject<HTMLElement | null>;
};

// The menu itself exists only while it is open, so everything it listens to goes away with it.
function Menu({ opening, items, settings, onItemSelect, onClosing, onDone, fallbackFocus }: MenuProps) {
  const menuRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const pillRef = useRef<HTMLDivElement>(null);
  const [leaving, setLeaving] = useState(false);
  const leavingRef = useRef(false);
  const exitRef = useRef<() => void>(() => undefined);
  const previousFocus = useRef<Element | null>(null);
  const typed = useRef({ text: "", timer: 0 });
  // Read when the menu opens or closes, so a changing control never rebuilds an open menu
  const settingsRef = useRef(settings);
  settingsRef.current = settings;
  const onDoneRef = useRef(onDone);
  onDoneRef.current = onDone;

  // Where the menu goes, how it pops open, and the highlight that glides between rows
  useGSAP(
    (_context, contextSafe) => {
      const menu = menuRef.current;
      const list = listRef.current;
      const pill = pillRef.current;
      if (!menu || !list || !pill || !contextSafe) return;
      // Remember where focus was, once (React StrictMode runs this twice, and by then the menu has focus)
      if (previousFocus.current === null) previousFocus.current = document.activeElement;

      // Put the menu at the cursor, flipped or pushed in when it would leave the screen
      const margin = 8;
      const w = menu.offsetWidth;
      const h = menu.offsetHeight;
      const maxLeft = Math.max(margin, window.innerWidth - w - margin);
      const maxTop = Math.max(margin, window.innerHeight - h - margin);
      const left = Math.min(Math.max(opening.x + w > window.innerWidth - margin ? opening.x - w : opening.x, margin), maxLeft);
      const top = Math.min(Math.max(opening.y + h > window.innerHeight - margin ? opening.y - h : opening.y, margin), maxTop);
      menu.style.left = `${left}px`;
      menu.style.top = `${top}px`;
      // The pop grows from the cursor, wherever the menu ended up
      const originX = Math.min(Math.max(opening.x - left, 0), w);
      const originY = Math.min(Math.max(opening.y - top, 0), h);
      gsap.set(menu, { transformOrigin: `${originX}px ${originY}px` });

      const rows = Array.from(list.querySelectorAll<HTMLElement>('[role="menuitem"]'));
      const dividers = Array.from(list.querySelectorAll<HTMLElement>('[role="separator"]'));
      const mm = gsap.matchMedia();
      let glideY: ((value: number) => gsap.core.Tween) | null = null;
      let glideH: ((value: number) => gsap.core.Tween) | null = null;
      let animated = true;

      mm.add(
        { reduce: "(prefers-reduced-motion: reduce)", motion: "(prefers-reduced-motion: no-preference)" },
        (context) => {
          animated = !context.conditions?.reduce;
          const { openDuration, stagger, startScale, ease } = settingsRef.current;
          if (!animated) {
            // No movement: the menu is simply there
            gsap.set([menu, ...rows, ...dividers], { opacity: 1, clearProps: "transform" });
            return;
          }
          gsap.set(pill, { opacity: 0 });
          glideY = gsap.quickTo(pill, "y", { duration: 0.22, ease: "power3.out" });
          glideH = gsap.quickTo(pill, "height", { duration: 0.22, ease: "power3.out" });
          gsap
            .timeline({ defaults: { overwrite: "auto" } })
            .fromTo(menu, { opacity: 0, scale: startScale, y: -6 }, { opacity: 1, scale: 1, y: 0, duration: openDuration, ease, clearProps: "transform" }, 0)
            .fromTo([...rows, ...dividers], { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: openDuration * 0.9, ease: "power2.out", stagger, clearProps: "transform" }, 0.04);
        },
      );

      // The highlight follows whichever row has focus (the mouse moves focus too)
      let lit = false;
      const moveHighlight = contextSafe((row: HTMLElement) => {
        if (animated && glideY && glideH) {
          if (!lit) gsap.set(pill, { y: row.offsetTop, height: row.offsetHeight });
          else {
            glideY(row.offsetTop);
            glideH(row.offsetHeight);
          }
          gsap.to(pill, { opacity: 1, duration: 0.15, overwrite: "auto" });
        } else {
          gsap.set(pill, { y: row.offsetTop, height: row.offsetHeight, opacity: 1 });
        }
        lit = true;
      });
      const onFocusIn = (event: FocusEvent) => {
        const row = (event.target as HTMLElement).closest<HTMLElement>('[role="menuitem"]');
        if (row) moveHighlight(row);
      };
      const onFocusOut = contextSafe((event: FocusEvent) => {
        if ((event.relatedTarget as HTMLElement | null)?.closest?.('[role="menuitem"]')) return;
        lit = false;
        gsap.to(pill, { opacity: 0, duration: 0.15, overwrite: "auto" });
      });
      list.addEventListener("focusin", onFocusIn);
      list.addEventListener("focusout", onFocusOut);

      // Closing plays a short fade, then the parent removes the menu
      exitRef.current = contextSafe(() => {
        list.removeEventListener("focusin", onFocusIn);
        list.removeEventListener("focusout", onFocusOut);
        if (!animated) {
          onDoneRef.current();
          return;
        }
        gsap.to(menu, { opacity: 0, scale: 0.96, duration: 0.14, ease: "power2.in", overwrite: true, onComplete: () => onDoneRef.current() });
      });

      // Keyboard-opened menus start on the first row, mouse-opened ones on the menu itself
      const first = rows.find((row) => row.getAttribute("aria-disabled") !== "true");
      (opening.keyboard && first ? first : menu).focus({ preventScroll: true });

      return () => {
        list.removeEventListener("focusin", onFocusIn);
        list.removeEventListener("focusout", onFocusOut);
        mm.revert();
      };
    },
    { scope: menuRef, dependencies: [] },
  );

  // Start closing. Keyboard and row choices give focus back to where it was.
  const close = (restoreFocus: boolean) => {
    if (leavingRef.current) return;
    leavingRef.current = true;
    setLeaving(true);
    onClosing();
    if (restoreFocus) {
      const back = previousFocus.current;
      const hadFocus = back instanceof HTMLElement && back !== document.body && back.isConnected;
      // Opened with the keyboard from something unfocusable: land on the box around it
      const target = hadFocus ? back : opening.keyboard ? fallbackFocus.current : null;
      if (target) {
        if (!target.hasAttribute("tabindex") && !(target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement || target instanceof HTMLInputElement)) target.tabIndex = -1;
        target.focus({ preventScroll: true });
      }
    }
    exitRef.current();
  };

  // Listeners that exist only while the menu is open: a press elsewhere, scrolling, a resized window
  useEffect(() => {
    if (leaving) return;
    const menu = menuRef.current;
    const onPressOutside = (event: PointerEvent) => {
      if (menu && event.target instanceof Node && menu.contains(event.target)) return;
      close(false);
    };
    const onAnyChange = () => close(false);
    document.addEventListener("pointerdown", onPressOutside, true);
    window.addEventListener("scroll", onAnyChange, { capture: true, passive: true });
    window.addEventListener("resize", onAnyChange);
    window.addEventListener("blur", onAnyChange);
    return () => {
      document.removeEventListener("pointerdown", onPressOutside, true);
      window.removeEventListener("scroll", onAnyChange, { capture: true });
      window.removeEventListener("resize", onAnyChange);
      window.removeEventListener("blur", onAnyChange);
    };
    // close only reads refs and stable callbacks
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [leaving]);

  useEffect(() => () => window.clearTimeout(typed.current.timer), []);

  const choose = (item: ContextMenuItem) => {
    if (item.disabled || leavingRef.current) return;
    close(true);
    item.onSelect?.(item);
    onItemSelect?.(item);
  };

  const rowElements = () => Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])') ?? []);

  const onMenuKeyDown = (event: ReactKeyboardEvent) => {
    const rows = rowElements();
    const index = rows.indexOf(document.activeElement as HTMLElement);
    const go = (next: number) => {
      event.preventDefault();
      rows[(next + rows.length) % rows.length]?.focus({ preventScroll: true });
    };
    if (event.key === "ArrowDown") go(index + 1);
    else if (event.key === "ArrowUp") go(index < 0 ? rows.length - 1 : index - 1);
    else if (event.key === "Home") go(0);
    else if (event.key === "End") go(rows.length - 1);
    else if (event.key === "Escape") {
      event.preventDefault();
      event.stopPropagation();
      close(true);
    } else if (event.key === "Tab") {
      event.preventDefault();
      close(true);
    } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey && event.key !== " ") {
      // Type a letter (or a few quickly) to jump to the row that starts with it
      window.clearTimeout(typed.current.timer);
      typed.current.text += event.key.toLowerCase();
      typed.current.timer = window.setTimeout(() => (typed.current.text = ""), 600);
      const ordered = [...rows.slice(index + 1), ...rows.slice(0, index + 1)];
      const match = ordered.find((row) => (row.textContent ?? "").trim().toLowerCase().startsWith(typed.current.text));
      if (match) {
        event.preventDefault();
        match.focus({ preventScroll: true });
      }
    }
  };

  const { width, accent, glass, menuClassName } = settings;
  return (
    <div
      ref={menuRef}
      role="menu"
      aria-orientation="vertical"
      tabIndex={-1}
      onKeyDown={onMenuKeyDown}
      onContextMenu={(event) => event.preventDefault()}
      className={`fixed z-[2147483000] max-w-[calc(100vw-16px)] rounded-2xl border p-1.5 text-[13.5px] leading-none outline-none select-none ${
        glass
          ? "border-black/10 bg-white/82 text-stone-900 shadow-[0_24px_60px_-18px_rgba(30,20,10,0.35),0_2px_6px_rgba(30,20,10,0.08)] backdrop-blur-2xl backdrop-saturate-150 dark:border-white/12 dark:bg-stone-900/80 dark:text-stone-100 dark:shadow-[0_28px_70px_-16px_rgba(0,0,0,0.75),0_2px_8px_rgba(0,0,0,0.4)]"
          : "border-stone-200 bg-white text-stone-900 shadow-[0_24px_60px_-18px_rgba(30,20,10,0.35),0_2px_6px_rgba(30,20,10,0.08)] dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100 dark:shadow-[0_28px_70px_-16px_rgba(0,0,0,0.75)]"
      } ${menuClassName}`}
      style={{ left: 0, top: 0, width, willChange: "transform, opacity", ["--menu-accent" as string]: accent }}
    >
      <div ref={listRef} className="relative flex flex-col">
        {/* The one highlight that glides between rows */}
        <div
          ref={pillRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-0 top-0 rounded-[10px] opacity-0"
          style={{ background: "color-mix(in oklab, var(--menu-accent) 16%, transparent)", boxShadow: "inset 0 0 0 1px color-mix(in oklab, var(--menu-accent) 28%, transparent)" }}
        />
        {items.map((item) => (
          <MenuRow key={item.id} item={item} onChoose={choose} />
        ))}
      </div>
    </div>
  );
}

function MenuRow({ item, onChoose }: { item: ContextMenuItem; onChoose: (item: ContextMenuItem) => void }) {
  return (
    <>
      <button
        type="button"
        role="menuitem"
        tabIndex={-1}
        aria-disabled={item.disabled ? "true" : undefined}
        onClick={() => onChoose(item)}
        onPointerEnter={(event) => {
          if (event.pointerType !== "touch" && !item.disabled) event.currentTarget.focus({ preventScroll: true });
        }}
        onPointerLeave={(event) => {
          // Moving off the rows lets the highlight fade: focus goes back to the menu
          if (event.pointerType !== "touch" && document.activeElement === event.currentTarget) (event.currentTarget.closest('[role="menu"]') as HTMLElement | null)?.focus({ preventScroll: true });
        }}
        className={`relative flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-left text-[13.5px] leading-5 outline-none pointer-coarse:py-2.5 ${
          item.disabled ? "cursor-not-allowed opacity-40" : item.danger ? "cursor-pointer text-red-600 dark:text-red-400" : "cursor-pointer"
        }`}
      >
        {item.icon ? (
          <span aria-hidden="true" className="flex h-4 w-4 shrink-0 items-center justify-center opacity-70">
            {item.icon}
          </span>
        ) : null}
        <span className="flex-1 truncate">{item.label}</span>
        {item.shortcut ? <span className="ml-3 shrink-0 font-mono text-[11px] tracking-wide opacity-45">{item.shortcut}</span> : null}
      </button>
      {item.separator ? <div role="separator" className="mx-2 my-1 h-px bg-black/10 dark:bg-white/12" /> : null}
    </>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { ArrowDownUp, ClipboardPaste, Copy, Download, Eye, FolderPlus, Grid3x3, Heart, RefreshCw, Share2, Trash2 } from "lucide-react";
import { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "@/components/dimaac/context-menu";

// A small photo board. Right-click a photo for its menu, right-click the
// board for the board's menu. Photos: Lummi (Lummi License).

type Look = { id: string; name: string; note: string; image: string; alt: string };

const looks: Look[] = [
  { id: "gold", name: "Gold visor", note: "No. 01", image: "https://ui.dimaac.com/media/fashion/gold-visor-4x5.jpg", alt: "Futuristic profile with a gold headpiece, amber sunglasses and red lips against a yellow background" },
  { id: "ring", name: "Orange ring", note: "No. 02", image: "https://ui.dimaac.com/media/fashion/orange-ring-4x5.jpg", alt: "A young man in a knit bucket hat framed through an orange circle" },
  { id: "corset", name: "Woven corset", note: "No. 03", image: "https://ui.dimaac.com/media/fashion/woven-corset-4x5.jpg", alt: "A model in a woven corset and pleated hat in natural tones" },
  { id: "knit", name: "Chunky knit", note: "No. 04", image: "https://ui.dimaac.com/media/fashion/chunky-knit-4x5.jpg", alt: "A model in a chunky knit sweater, ruffled pants and coral boots against a warm backdrop" },
];

export default function Demo(props: Partial<Omit<ContextMenuProps, "children" | "items">>) {
  const [favourites, setFavourites] = useState<string[]>(["ring"]);
  const [openId, setOpenId] = useState<string | null>(null);
  const [message, setMessage] = useState("");
  const timer = useRef<number | null>(null);

  const say = (text: string) => {
    setMessage(text);
    if (timer.current !== null) window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setMessage(""), 2200);
  };
  useEffect(
    () => () => {
      if (timer.current !== null) window.clearTimeout(timer.current);
    },
    [],
  );

  const itemsFor = (look: Look): ContextMenuItem[] => {
    const loved = favourites.includes(look.id);
    return [
      { id: "open", label: "Open look", icon: <Eye size={16} />, shortcut: "↵", onSelect: () => say(`Opened ${look.name}`) },
      {
        id: "favourite",
        label: loved ? "Remove from favourites" : "Add to favourites",
        icon: <Heart size={16} fill={loved ? "currentColor" : "none"} />,
        shortcut: "F",
        separator: true,
        onSelect: () => {
          setFavourites((list) => (loved ? list.filter((id) => id !== look.id) : [...list, look.id]));
          say(loved ? `${look.name} left your favourites` : `${look.name} saved to favourites`);
        },
      },
      { id: "duplicate", label: "Duplicate", icon: <Copy size={16} />, shortcut: "⌘ D", onSelect: () => say(`Duplicated ${look.name}`) },
      { id: "download", label: "Download image", icon: <Download size={16} />, shortcut: "⌘ S", onSelect: () => say(`Downloading ${look.name}…`) },
      { id: "share", label: "Share…", icon: <Share2 size={16} />, separator: true, onSelect: () => say(`Link to ${look.name} copied`) },
      { id: "archive", label: "Archive (soon)", icon: <FolderPlus size={16} />, disabled: true },
      { id: "remove", label: "Remove look", icon: <Trash2 size={16} />, shortcut: "⌫", danger: true, onSelect: () => say(`Removed ${look.name} (just kidding)`) },
    ];
  };

  const boardItems: ContextMenuItem[] = [
    { id: "new", label: "New board", icon: <FolderPlus size={16} />, shortcut: "⌘ N", onSelect: () => say("New board created") },
    { id: "paste", label: "Paste photo", icon: <ClipboardPaste size={16} />, shortcut: "⌘ V", separator: true, onSelect: () => say("Nothing on the clipboard") },
    { id: "sort", label: "Sort by date", icon: <ArrowDownUp size={16} />, onSelect: () => say("Sorted by date") },
    { id: "grid", label: "Show grid", icon: <Grid3x3 size={16} />, onSelect: () => say("Grid shown") },
    { id: "refresh", label: "Refresh", icon: <RefreshCw size={16} />, shortcut: "⌘ R", onSelect: () => say("Board refreshed") },
  ];

  return (
    <ContextMenu {...props} items={boardItems} className="relative h-full w-full overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <div className="flex h-full w-full flex-col px-5 py-6 sm:px-8 sm:py-8">
        <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-5 sm:gap-8">
          <h1 className="text-center text-[clamp(30px,5.4vw,72px)] leading-[0.95] font-medium tracking-[-0.04em] opacity-90">
            Pick a look,
            <br />
            then <span className="pointer-coarse:hidden">right-click it</span>
            <span className="hidden pointer-coarse:inline">hold it down</span>
          </h1>

          <div className="grid w-full max-w-[1020px] grid-cols-2 gap-3 sm:gap-5 md:grid-cols-4">
            {looks.map((look) => (
              <ContextMenu key={look.id} {...props} items={itemsFor(look)} onOpenChange={(open) => setOpenId((current) => (open ? look.id : current === look.id ? null : current))}>
                <div
                  tabIndex={0}
                  role="group"
                  aria-label={`${look.name}. Press the Menu key or Shift and F10 for actions.`}
                  className={`group relative aspect-[4/5] cursor-context-menu overflow-hidden rounded-2xl bg-black/10 shadow-[0_18px_40px_-20px_rgba(30,20,10,0.45)] outline-none transition-[box-shadow,transform] duration-300 focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f3f0eb] motion-reduce:transition-none dark:focus-visible:ring-teal-300 dark:focus-visible:ring-offset-[#0f0b0b] ${
                    openId === look.id ? "-translate-y-1 ring-2 ring-teal-600 ring-offset-2 ring-offset-[#f3f0eb] dark:ring-teal-300 dark:ring-offset-[#0f0b0b]" : ""
                  }`}
                >
                  <img src={look.image} alt={look.alt} draggable={false} className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04] motion-reduce:transition-none" />
                  <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
                  {favourites.includes(look.id) ? (
                    <span className="absolute top-2.5 left-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-white/90 text-red-500" aria-label="Favourite">
                      <Heart size={13} fill="currentColor" />
                    </span>
                  ) : null}
                  <div className="absolute inset-x-3 bottom-3 text-white">
                    <p className="text-[15px] font-medium tracking-[-0.01em] sm:text-[17px]">{look.name}</p>
                    <p className="font-mono text-[10px] tracking-wide uppercase opacity-70">{look.note}</p>
                  </div>
                </div>
              </ContextMenu>
            ))}
          </div>
        </div>

        <footer className="text-center font-mono text-[11px] tracking-wide uppercase opacity-60">
          <span className="pointer-coarse:hidden">The board has a menu too</span>
          <span className="hidden pointer-coarse:inline">The board has a menu too, hold it down</span>
        </footer>
      </div>

      <div
        role="status"
        aria-live="polite"
        className={`pointer-events-none absolute bottom-14 left-1/2 -translate-x-1/2 rounded-full bg-[#1c1917] px-4 py-2 text-[13px] whitespace-nowrap text-[#f3f0eb] shadow-lg transition-[opacity,transform] duration-300 motion-reduce:transition-none dark:bg-[#e8ddd4] dark:text-[#0f0b0b] ${
          message ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
        }`}
      >
        {message || " "}
      </div>
    </ContextMenu>
  );
}
```

## Agent prompt

```text
Add the Context Menu component (<ContextMenu />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/context-menu
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/context-menu.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/context-menu.json

Then use it like this:
import { ContextMenu } from "@/components/dimaac/context-menu";
<ContextMenu children={children} items={items} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/context-menu.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Electric Border
description: A card edge that crackles like electricity.
category: Cards
tier: free
url: https://ui.dimaac.com/components/electric-border
registry: https://ui.dimaac.com/r/electric-border.json
---

# Electric Border

A card whose border crackles like a live wire. A thin coloured line is torn up by an SVG displacement filter fed with two pairs of sliding turbulence noise that loop without a seam, and soft copies of the edge, a white sheen and a wide aura make it glow. Hover it (or focus something inside it) and a wash of colour charges the card. Pure SVG filters and CSS, no canvas and no libraries: any CSS colour works, every card gets its own filter, the noise stops moving off screen, and with reduced motion the crackle holds still.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/electric-border.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/electric-border.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/electric-border.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/electric-border`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/electric-border.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ElectricBorder } from "@/components/dimaac/electric-border" and give its parent a size: it fills it.

## Usage

```tsx
import { ElectricBorder } from "@/components/dimaac/electric-border";

<ElectricBorder />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `color` | `string` | `"#9a71f5"` | The colour of the wire and its glow. Any CSS colour (the default is oklch(0.65 0.19 295)). |
| `speed` | `number` | `1` | How fast the crackle travels. 1 is one lap of the noise every 6 seconds. |
| `chaos` | `number` | `0.5` | How far the noise throws the wire off its line. 0 is a clean edge. |
| `thickness` | `number` | `2` | Width of the wire and its glows, in pixels. |
| `radius` | `number` | `24` | Corner radius, in pixels. |
| `glow` | `number` | `0.6` | How strong the glow, sheen and aura are. 0 leaves only the wire. |
| `charged` | `boolean` | `false` | Keeps the card lit up as if it were hovered (it also lights up on hover and keyboard focus inside it). |
| `surface` | `string` | - | Background of the card. Defaults to a near-black tinted with the colour. |
| `className` | `string` | `""` | Extra classes for the card. Give it a size, or let its content size it. |
| `children` | `ReactNode` | - | The card's content. |

## Source

### electric-border.tsx → `components/dimaac/electric-border.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";

// A card whose edge crackles like electricity. A thin coloured line (the "wire") is pushed around
// by an SVG displacement filter fed with moving turbulence noise, and a few soft copies of the edge
// (sharp, blurred, a white sheen and a wide aura) sit around it so the line seems to glow.
//
// How the noise moves: the same noise picture is drawn twice, one copy sliding in from the top
// while the other slides out at the bottom. Their seam travels down the box and after one lap the
// picture is back where it started, so the loop has no jump. A second pair does the same sideways.
// We move them from requestAnimationFrame (not SMIL) so speed can change without a jump, and so
// the loop stops off screen and for reduced motion.

export type ElectricBorderProps = {
  /** The colour of the wire and its glow. Any CSS colour (the default is oklch(0.65 0.19 295)). */
  color?: string;
  /** How fast the crackle travels. 1 is one lap of the noise every 6 seconds. */
  speed?: number;
  /** How far the noise throws the wire off its line. 0 is a clean edge. */
  chaos?: number;
  /** Width of the wire and its glows, in pixels. */
  thickness?: number;
  /** Corner radius, in pixels. */
  radius?: number;
  /** How strong the glow, sheen and aura are. 0 leaves only the wire. */
  glow?: number;
  /** Keeps the card lit up as if it were hovered (it also lights up on hover and keyboard focus inside it). */
  charged?: boolean;
  /** Background of the card. Defaults to a near-black tinted with the colour. */
  surface?: string;
  /** Extra classes for the card. Give it a size, or let its content size it. */
  className?: string;
  /** The card's content. */
  children?: ReactNode;
};

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// One lap of the noise at speed 1, in seconds (the timing of the original build)
const LAP = 6;
// Room around the wire for the filter to draw into, so a thrown-out spark isn't cut off
const PAD = 80;
// The frame of the loop shown, still, when the visitor prefers reduced motion
const STILL = 0.35;

export function ElectricBorder({
  color = "#9a71f5",
  speed = 1,
  chaos = 0.5,
  thickness = 2,
  radius = 24,
  glow = 0.6,
  charged = false,
  surface,
  className = "",
  children,
}: ElectricBorderProps) {
  // useId can hold characters that break url(#…), so keep letters, digits, - and _ only
  const filterId = `electric-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
  const wireRef = useRef<HTMLDivElement>(null);
  const downRef = useRef<SVGFEOffsetElement>(null);
  const upRef = useRef<SVGFEOffsetElement>(null);
  const rightRef = useRef<SVGFEOffsetElement>(null);
  const leftRef = useRef<SVGFEOffsetElement>(null);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);

  // The wire's size. The noise has to slide exactly one filter area per lap to loop cleanly.
  const [size, setSize] = useState({ width: 350, height: 500 });
  const sizeRef = useRef(size);
  // Read every frame, so a new speed never restarts the loop
  const speedRef = useRef(speed);
  useEffect(() => {
    speedRef.current = speed;
  }, [speed]);

  useEffect(() => {
    const wire = wireRef.current;
    if (!wire) return;
    const observer = new ResizeObserver(() => {
      const next = { width: Math.max(1, Math.round(wire.offsetWidth)), height: Math.max(1, Math.round(wire.offsetHeight)) };
      if (next.width === sizeRef.current.width && next.height === sizeRef.current.height) return;
      sizeRef.current = next;
      setSize(next);
    });
    observer.observe(wire);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    // Reduced motion: the still frame written in the markup below, no movement
    if (reducedMotion) return;
    const wire = wireRef.current;
    const offsets = [downRef.current, upRef.current, rightRef.current, leftRef.current];
    if (!wire || offsets.some((offset) => !offset)) return;
    const [down, up, right, left] = offsets as SVGFEOffsetElement[];

    // progress 0 → 1 is one lap. The pairs always cover the whole area between them.
    function place(progress: number) {
      const areaHeight = sizeRef.current.height + PAD * 2;
      const areaWidth = sizeRef.current.width + PAD * 2;
      down!.setAttribute("dy", String(areaHeight * (1 - progress)));
      up!.setAttribute("dy", String(-areaHeight * progress));
      right!.setAttribute("dx", String(areaWidth * (1 - progress)));
      left!.setAttribute("dx", String(-areaWidth * progress));
    }

    let frame = 0;
    let visible = true;
    let progress = 0;
    let last = performance.now();

    function tick(now: number) {
      frame = requestAnimationFrame(tick);
      // A long gap (a background tab) shouldn't make the noise leap
      const seconds = Math.min((now - last) / 1000, 0.1);
      last = now;
      if (!visible) return;
      progress = (progress + (seconds * speedRef.current) / LAP) % 1;
      place(progress);
    }

    // Nothing to move while the card is off screen (each frame re-renders the noise, which costs)
    const visibility = new IntersectionObserver(([entry]) => {
      visible = entry?.isIntersecting ?? true;
    });
    visibility.observe(wire);
    frame = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(frame);
      visibility.disconnect();
    };
  }, [reducedMotion]);

  // The glow numbers are the original's at glow 0.6, scaled up or down from there
  const strength = glow / 0.6;
  const radiusPx = `${radius}px`;
  const ring: CSSProperties = { borderRadius: radiusPx, borderWidth: thickness, borderStyle: "solid" };
  const sheen = "linear-gradient(-30deg, white, transparent 30%, transparent 70%, white)";
  const areaHeight = size.height + PAD * 2;
  const areaWidth = size.width + PAD * 2;
  // Where the noise starts (the loop moves it from here). With reduced motion it stays at this still frame.
  const start = reducedMotion ? STILL : 0;

  return (
    <div
      className={`group/electric relative ${className}`}
      style={
        {
          "--electric": color,
          // Lighter and softer versions of the colour, worked out by the browser so any colour works
          "--electric-light": "oklch(from var(--electric) calc(l + 0.06) calc(c * 0.75) h)",
          "--electric-muted": "oklch(from var(--electric) l c h / 0.45)",
          "--electric-soft": "oklch(from var(--electric) l c h / 0.55)",
          "--electric-tint": "oklch(from var(--electric) 0.3 calc(c / 2) h / 0.4)",
          padding: thickness,
          borderRadius: radiusPx,
          color: "oklch(0.985 0 0)",
        } as CSSProperties
      }
    >
      <svg aria-hidden="true" focusable="false" width="0" height="0" className="pointer-events-none absolute">
        <defs>
          <filter id={filterId} filterUnits="userSpaceOnUse" x={-PAD} y={-PAD} width={areaWidth} height={areaHeight} colorInterpolationFilters="linearRGB">
            {/* Two noise pictures, each used by a pair of offsets: one pair slides down, one sideways.
                stitchTiles makes each picture wrap around, so the seam where a pair meets doesn't show.
                Six octaves: the original's ten only add detail finer than a pixel, at a real cost per frame. */}
            <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves={6} seed={1} stitchTiles="stitch" result="noiseA" />
            <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves={6} seed={2} stitchTiles="stitch" result="noiseB" />
            <feOffset ref={downRef} in="noiseA" dx="0" dy={areaHeight * (1 - start)} result="down" />
            <feOffset ref={upRef} in="noiseA" dx="0" dy={-areaHeight * start} result="up" />
            <feOffset ref={rightRef} in="noiseB" dx={areaWidth * (1 - start)} dy="0" result="right" />
            <feOffset ref={leftRef} in="noiseB" dx={-areaWidth * start} dy="0" result="left" />
            <feComposite in="down" in2="up" result="vertical" />
            <feComposite in="right" in2="left" result="horizontal" />
            <feBlend in="vertical" in2="horizontal" mode="color-dodge" result="noise" />
            {/* chaos 0.5 is the original's scale of 50 */}
            <feDisplacementMap in="SourceGraphic" in2="noise" scale={chaos * 100} />
          </filter>
        </defs>
      </svg>

      {/* The wide coloured aura behind the card */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        style={{
          borderRadius: radiusPx,
          filter: "blur(32px)",
          transform: "scale(1.1)",
          opacity: Math.min(0.3 * strength, 0.6),
          background: "linear-gradient(-30deg, var(--electric-light), transparent, var(--electric))",
        }}
      />

      {/* The card itself: a faint diagonal tint of the colour over a dark surface */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        style={{
          borderRadius: radiusPx,
          background: `linear-gradient(-30deg, var(--electric-tint), transparent, var(--electric-tint)), ${surface ?? "oklch(from var(--electric) 0.17 0.02 h)"}`,
        }}
      />

      {/* The edge: a faint steady line with the crackling wire inside it, then two glowing copies */}
      <div aria-hidden="true" className="pointer-events-none absolute" style={{ inset: thickness }}>
        <div className="absolute inset-0" style={{ ...ring, borderColor: "var(--electric-muted)" }}>
          <div ref={wireRef} className="relative h-full w-full overflow-hidden" style={{ ...ring, borderColor: "var(--electric)", filter: `url(#${filterId})` }}>
            {/* The charge: a wash of colour that fills the card on hover or keyboard focus, torn by the same noise.
                (Only :focus-visible, so a tapped button doesn't keep it lit after a second tap.) */}
            <div
              className={`absolute inset-0 transition-opacity duration-[650ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover/electric:opacity-100 group-has-[:focus-visible]/electric:opacity-100 ${charged ? "opacity-100" : "opacity-0"}`}
              style={{ background: "linear-gradient(to bottom, var(--electric), transparent)" }}
            />
          </div>
        </div>
        <div className="absolute inset-0" style={{ ...ring, borderColor: "var(--electric-soft)", filter: "blur(1px)", opacity: Math.min(strength, 1) }} />
        <div className="absolute inset-0" style={{ ...ring, borderColor: "var(--electric-light)", filter: `blur(${4 * Math.max(strength, 0.5)}px)`, opacity: Math.min(strength, 1) }} />
      </div>

      {/* A white sheen on two corners, blended into what's under it (twice, the second one softer) */}
      {[1, 0.5].map((amount) => (
        <div
          key={amount}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
          style={{ borderRadius: radiusPx, mixBlendMode: "overlay", transform: "scale(1.1)", filter: "blur(16px)", background: sheen, opacity: Math.min(amount * strength, 1) }}
        />
      ))}

      <div className="relative h-full w-full">{children}</div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { ElectricBorder, type ElectricBorderProps } from "@/components/dimaac/electric-border";

// The card from the video: a dark "Neon Pulse" card with a crackling edge that fills with colour
// when you hover it. Here the card is also a real button, so a tap or Enter charges it too.

export default function Demo(props: Partial<ElectricBorderProps>) {
  const [on, setOn] = useState(false);

  return (
    <div
      className="relative flex h-full w-full items-center justify-center overflow-hidden bg-[oklch(0.965_0.008_295)] text-[oklch(0.22_0.03_285)] dark:bg-[oklch(0.11_0.025_285)] dark:text-[oklch(0.92_0.01_285)]"
      style={{ fontFamily: '"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif' }}
    >
      {/* A faint pool of colour behind the card, so the edge has something to glow against */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(48%_52%_at_50%_50%,oklch(0.93_0.05_295/0.9),transparent)] dark:bg-[radial-gradient(48%_52%_at_50%_50%,oklch(0.24_0.07_290/0.7),transparent)]" />
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&display=swap" precedence="default" />

      <ElectricBorder {...props} charged={on || props.charged} className={`aspect-[358/508] h-[min(508px,calc(100dvh-110px))] max-w-[calc(100vw-48px)] ${props.className ?? ""}`}>
        <button
          type="button"
          aria-pressed={on}
          onClick={() => setOn((value) => !value)}
          className="flex h-full w-full cursor-pointer flex-col text-left outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-[10px] focus-visible:outline-[var(--electric)]"
          style={{ borderRadius: props.radius ?? 24 }}
        >
          <span className="flex flex-1 flex-col px-8 pt-8 pb-3 sm:px-9 sm:pt-9">
            <span className="relative w-fit rounded-[14px] border border-white/25 bg-white/[0.06] bg-[radial-gradient(47%_50%_at_50%_88%,rgba(255,255,255,0.12),transparent)] px-4 py-2 text-[14px] font-bold tracking-wide text-white/80 uppercase">
              Live wire
            </span>
            <span className="mt-auto text-[26px] leading-tight font-semibold tracking-[-0.02em]">Neon Pulse</span>
          </span>
          <span aria-hidden="true" className="block h-px w-full bg-current opacity-50 [mask-image:linear-gradient(to_right,transparent,black,transparent)]" />
          <span className="block px-8 pt-3 pb-8 text-[13px] leading-[1.55] opacity-55 sm:px-9 sm:pb-9">
            The border hums with energy. Hover or tap to charge it up.
          </span>
        </button>
      </ElectricBorder>
    </div>
  );
}
```

## Agent prompt

```text
Add the Electric Border component (<ElectricBorder />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/electric-border
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/electric-border.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/electric-border.json

Then use it like this:
import { ElectricBorder } from "@/components/dimaac/electric-border";
<ElectricBorder />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/electric-border.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Expandable Panel
description: Photo strips that open wide on hover, focus or tap.
category: Layout
tier: free
url: https://ui.dimaac.com/components/expandable-panel
registry: https://ui.dimaac.com/r/expandable-panel.json
---

# Expandable Panel

An accordion gallery. One photo strip is wide open and the others are slim; hover, focus or tap another and the room moves over to it, with a gentle zoom, a fading shade and a title that slides up. Every strip is a real button with aria-expanded, arrow keys walk along them, and on narrow boxes the strips stack and open downwards instead of overflowing. GSAP tweens flex-grow rather than width, so the layout never jumps, and the movement is skipped for visitors who prefer reduced motion. Ported from the first DiMaac UI, which used a plain CSS width transition on clickable divs.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/expandable-panel.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/expandable-panel.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/expandable-panel.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/expandable-panel`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/expandable-panel.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ExpandablePanel } from "@/components/dimaac/expandable-panel" and give its parent a size: it fills it.

## Usage

```tsx
import { ExpandablePanel } from "@/components/dimaac/expandable-panel";

<ExpandablePanel panels={panels} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `panels` (required) | `ExpandablePanelItem[]` | - | The strips, in order. Four to eight look best. |
| `trigger` | `"hover" \| "click"` | `"hover"` | What opens a strip: "hover" (mouse), or "click". Tap and keyboard focus always open one. |
| `defaultActive` | `number` | `0` | Which strip starts open. -1 starts with all of them closed. |
| `resetOnLeave` | `boolean` | `false` | Go back to the first choice when the pointer leaves the whole row. |
| `expandedRatio` | `number` | `6` | How much wider the open strip is than a closed one (1 is the same width). |
| `gap` | `number` | `12` | Space between the strips, in pixels. |
| `radius` | `number` | `24` | Corner radius of each strip, in pixels. |
| `duration` | `number` | `0.9` | How long the strips take to change size, in seconds. |
| `ease` | `string` | `"expo.out"` | The GSAP ease of the change, e.g. "power3.inOut" or "expo.out". |
| `orientation` | `"auto" \| "horizontal" \| "vertical"` | `"auto"` | "auto" stacks the strips when the box is narrower than 672px. |
| `onChange` | `(index: number) => void` | - | Called with the new strip's index whenever the open one changes. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `panelClassName` | `string` | `""` | Extra classes for each strip. |

## Credits

- Mossy forest: PZF, Lummi (https://www.lummi.ai/photo/mossy-forest-scene-o4b8t), Lummi License
- Yellow planes: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Coastline: Cayetano Gros, Lummi (https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7), Lummi License
- Organic forms: Dona Mara, Lummi (https://www.lummi.ai/photo/abstract-sculptural-objects-flp-3), Lummi License
- Desert wanderer: Beth Design Studio, Lummi (https://www.lummi.ai/photo/desert-wanderer-9puhu), Lummi License
- Pastel rooftop: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License
- Misty hills: SHIHO, Lummi (https://www.lummi.ai/photo/lush-green-hills-mdvvk), Lummi License

## Source

### expandable-panel.tsx → `components/dimaac/expandable-panel.tsx`

```tsx
"use client";

import { useRef, useState, type KeyboardEvent } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// An accordion of photos. One strip is wide open, the rest are slim; hover, focus or tap another
// and the room moves over to it. Each strip is a real button, so the keyboard works too.
// GSAP tweens flex-grow (not width), so the browser shares the space for us and every strip
// stays exactly as tall as the box. On narrow boxes the strips stack and open downwards.

export type ExpandablePanelItem = {
  /** The photo's address. */
  image: string;
  /** Describes the photo for screen readers. */
  alt: string;
  /** The label shown on the open strip, and the accessible name of the button. */
  title: string;
  /** A small line under the title, optional. */
  caption?: string;
};

export type ExpandablePanelProps = {
  /** The strips, in order. Four to eight look best. */
  panels: ExpandablePanelItem[];
  /** What opens a strip: "hover" (mouse), or "click". Tap and keyboard focus always open one. */
  trigger?: "hover" | "click";
  /** Which strip starts open. -1 starts with all of them closed. */
  defaultActive?: number;
  /** Go back to the first choice when the pointer leaves the whole row. */
  resetOnLeave?: boolean;
  /** How much wider the open strip is than a closed one (1 is the same width). */
  expandedRatio?: number;
  /** Space between the strips, in pixels. */
  gap?: number;
  /** Corner radius of each strip, in pixels. */
  radius?: number;
  /** How long the strips take to change size, in seconds. */
  duration?: number;
  /** The GSAP ease of the change, e.g. "power3.inOut" or "expo.out". */
  ease?: string;
  /** "auto" stacks the strips when the box is narrower than 672px. */
  orientation?: "auto" | "horizontal" | "vertical";
  /** Called with the new strip's index whenever the open one changes. */
  onChange?: (index: number) => void;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Extra classes for each strip. */
  panelClassName?: string;
};

export function ExpandablePanel({
  panels,
  trigger = "hover",
  defaultActive = 0,
  resetOnLeave = false,
  expandedRatio = 6,
  gap = 12,
  radius = 24,
  duration = 0.9,
  ease = "expo.out",
  orientation = "auto",
  onChange,
  className = "",
  panelClassName = "",
}: ExpandablePanelProps) {
  const listRef = useRef<HTMLDivElement>(null);
  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [active, setActive] = useState(defaultActive);
  // The strips are drawn at their starting size by React; after that only GSAP changes them
  const startActive = useRef(defaultActive);
  const started = useRef(false);

  // When the defaultActive prop changes on a live strip (the playground does this), follow it
  const [seenDefault, setSeenDefault] = useState(defaultActive);
  if (seenDefault !== defaultActive) {
    setSeenDefault(defaultActive);
    setActive(defaultActive);
  }

  const open = (index: number) => {
    if (index === active) return;
    setActive(index);
    onChange?.(index);
  };

  useGSAP(
    () => {
      const buttons = buttonRefs.current.slice(0, panels.length).filter((button): button is HTMLButtonElement => button !== null);
      if (buttons.length === 0) return;

      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      // The first run only places things, so the page doesn't open with an animation nobody asked for
      const time = started.current && !reduced ? duration : 0;
      started.current = true;

      const isOpen = (i: number) => i === active;
      gsap.to(buttons, { flexGrow: (i: number) => (isOpen(i) ? expandedRatio : 1), duration: time, ease, overwrite: "auto" });
      // The photo eases from a slight zoom to rest, and closed strips are dimmed
      gsap.to(buttons.map((b) => b.querySelector("[data-photo]")), { scale: (i: number) => (isOpen(i) ? 1 : 1.12), duration: time * 1.1, ease, overwrite: "auto" });
      gsap.to(buttons.map((b) => b.querySelector("[data-shade]")), { opacity: (i: number) => (isOpen(i) ? 0 : 0.45), duration: time, ease: "power2.out", overwrite: "auto" });
      // The title slides up into view only on the open strip; on the others it fades out quickly
      const titles = buttons.map((b) => b.querySelector("[data-title]"));
      gsap.to(titles, {
        opacity: (i: number) => (isOpen(i) ? 1 : 0),
        y: (i: number) => (isOpen(i) || reduced ? 0 : 18),
        duration: time * 0.6,
        delay: (i: number) => (isOpen(i) && time > 0 ? 0.12 : 0),
        ease: "power3.out",
        overwrite: "auto",
      });
    },
    { scope: listRef, dependencies: [active, panels.length, expandedRatio, duration, ease] },
  );

  // Arrow keys walk along the strips, and landing on one opens it (focus opens)
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"];
    if (!keys.includes(event.key)) return;
    const current = buttonRefs.current.findIndex((button) => button === document.activeElement);
    if (current === -1) return;
    event.preventDefault();
    const last = panels.length - 1;
    const next = event.key === "Home" ? 0 : event.key === "End" ? last : event.key === "ArrowRight" || event.key === "ArrowDown" ? Math.min(last, current + 1) : Math.max(0, current - 1);
    buttonRefs.current[next]?.focus();
  };

  const direction = orientation === "vertical" ? "flex-col" : orientation === "horizontal" ? "flex-row" : "flex-row @max-2xl:flex-col";

  return (
    <div className={`@container relative h-full w-full ${className}`}>
      <div
        ref={listRef}
        role="group"
        aria-label="Expandable panels"
        onKeyDown={onKeyDown}
        onPointerLeave={() => resetOnLeave && open(defaultActive)}
        className={`flex h-full w-full ${direction}`}
        style={{ gap }}
      >
        {panels.map((panel, index) => (
          <button
            key={`${index}-${panel.image}`}
            ref={(button) => {
              buttonRefs.current[index] = button;
            }}
            type="button"
            aria-expanded={index === active}
            aria-label={panel.title}
            onPointerEnter={(event) => {
              // Touch fires an enter just before the tap, so only a real pointer counts as a hover
              if (trigger === "hover" && event.pointerType !== "touch") open(index);
            }}
            onClick={() => open(index)}
            onFocus={() => open(index)}
            className={`group relative isolate min-h-0 min-w-0 basis-0 cursor-pointer overflow-hidden border-0 bg-transparent p-0 text-left text-white outline-none focus-visible:ring-2 focus-visible:ring-white/90 focus-visible:ring-offset-2 focus-visible:ring-offset-black/60 ${panelClassName}`}
            style={{ flexGrow: index === startActive.current ? expandedRatio : 1, borderRadius: radius, WebkitTapHighlightColor: "transparent" }}
          >
            <img
              data-photo
              src={panel.image}
              alt={panel.alt}
              draggable={false}
              loading="lazy"
              decoding="async"
              className="absolute inset-0 -z-10 block h-full w-full object-cover"
              style={{ transform: index === startActive.current ? "scale(1)" : "scale(1.12)" }}
            />
            {/* Darkens closed strips, and the bottom of every strip so the white text reads */}
            <span aria-hidden className="pointer-events-none absolute inset-0 bg-black" data-shade style={{ opacity: index === startActive.current ? 0 : 0.45 }} />
            <span aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/70 to-transparent" />
            <span aria-hidden className="absolute top-4 left-5 font-mono text-[11px] tracking-[0.14em] opacity-80 @max-2xl:top-3">
              {String(index + 1).padStart(2, "0")}
            </span>
            <span data-title className="pointer-events-none absolute right-5 bottom-5 left-5 block whitespace-nowrap" style={{ opacity: index === startActive.current ? 1 : 0 }}>
              <span className="block text-[clamp(22px,2.6vw,40px)] leading-[1.05] font-medium tracking-[-0.03em]">{panel.title}</span>
              {panel.caption ? <span className="mt-1.5 block text-[13px] opacity-75">{panel.caption}</span> : null}
            </span>
          </button>
        ))}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { ExpandablePanel, type ExpandablePanelItem, type ExpandablePanelProps } from "@/components/dimaac/expandable-panel";

// Seven Lummi photos (Lummi License: free for personal and commercial use)
const panels: ExpandablePanelItem[] = [
  { image: "https://ui.dimaac.com/media/nature/mossy-forest-4x5.jpg", alt: "Moss-covered forest floor with misty trees in the background", title: "Mossy Forest", caption: "Quiet, damp and very green" },
  { image: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg", alt: "Minimalist yellow and beige walls under a clear blue sky", title: "Yellow Planes", caption: "Clean lines at noon" },
  { image: "https://ui.dimaac.com/media/nature/coastline-aerial-4x5.jpg", alt: "Aerial view of turquoise waves crashing against a sandy beach", title: "Coastline", caption: "Where the water meets the cliffs" },
  { image: "https://ui.dimaac.com/media/art/organic-forms-4x5.jpg", alt: "Abstract sculptural objects with organic and geometric forms in neutral tones", title: "Organic Forms", caption: "Sculpture in sand and gold" },
  { image: "https://ui.dimaac.com/media/nature/desert-wanderer-4x5.jpg", alt: "A person in a flowing garment walks across a desert under a golden sky", title: "Desert Wanderer", caption: "A long walk under a golden sky" },
  { image: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg", alt: "Pastel purple and pink architecture with a solitary figure against a blue sky", title: "Pastel Rooftop", caption: "Pink, purple and a lot of sky" },
  { image: "https://ui.dimaac.com/media/nature/misty-hills-4x5.jpg", alt: "Aerial view of green mountains with misty valleys", title: "Misty Hills", caption: "Morning fog in the valleys" },
];

export default function Demo(props: Partial<ExpandablePanelProps>) {
  return (
    <div className="flex h-full w-full flex-col gap-5 bg-[#f3f0eb] p-4 text-[#1c1917] sm:p-8 dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <p className="shrink-0 text-right font-mono text-[11px] tracking-wide uppercase opacity-60">Hover, tap or tab to open</p>
      <div className="min-h-0 flex-1">
        <ExpandablePanel panels={panels} {...props} />
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Expandable Panel component (<ExpandablePanel />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/expandable-panel
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/expandable-panel.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/expandable-panel.json

Then use it like this:
import { ExpandablePanel } from "@/components/dimaac/expandable-panel";
<ExpandablePanel panels={panels} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/expandable-panel.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Fashion Preloader
description: Tilted photo cards open one by one, a brand name rises and a counter runs to 100.
category: Loaders
tier: free
url: https://ui.dimaac.com/components/fashion-preloader
registry: https://ui.dimaac.com/r/fashion-preloader.json
---

# Fashion Preloader

A full-page loading curtain for fashion, studio and portfolio sites. Tilted photo cards open from a small square one after another while the brand name rises letter by letter and a counter runs from 000 to 100. Then the letters lift away, the cards shrink back and the curtain pulls up to reveal the page, with onReveal and onComplete callbacks so the page underneath can animate in on cue. The counter time, the lean of the cards, the word and the colours are props. It waits for the photos to decode before it starts, cleans up its timeline and split text on unmount, and with reduced motion it shows one still frame and fades out. Remount it with a React key to play it again. Rebuilt from the YouTube Fashion preloader, with licence-clear photos.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/fashion-preloader`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/fashion-preloader.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { FashionPreloader } from "@/components/dimaac/fashion-preloader" and give its parent a size: it fills it.

## Usage

```tsx
import { FashionPreloader } from "@/components/dimaac/fashion-preloader";

<FashionPreloader images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `string[]` | - | The photos, one per card (they stack in this order). Four to eight look best. |
| `brand` | `string` | `"DiMaac"` | The big word in the middle. |
| `duration` | `number` | `3` | How long the counter takes to run from 0 to 100, in seconds. The whole intro lasts about 3 seconds longer. |
| `tilt` | `number` | `8` | How far the cards lean, in degrees. 8 is the original look. 0 stacks them straight. |
| `counter` | `boolean` | `true` | Show the 000 to 100 counter next to the brand. |
| `background` | `string` | `"#14161c"` | The curtain colour. |
| `color` | `string` | `"#e8e2d6"` | The colour of the brand name and counter. |
| `onReveal` | `() => void` | - | Called when the curtain starts to lift. Use it to start the entrance of the page underneath. |
| `onComplete` | `() => void` | - | Called when the curtain has gone. |
| `className` | `string` | `""` | Extra classes for the curtain, e.g. the font. Give the brand a font here, it inherits it. |

## Credits

- Orange visor portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License (free for personal and commercial use)
- Grey suit portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License (free for personal and commercial use)
- Chunky knit portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License (free for personal and commercial use)
- Woven corset portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr), Lummi License (free for personal and commercial use)
- Gold visor portrait: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License (free for personal and commercial use)
- Mid-air suit portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/modern-fashion-pose-zsdav), Lummi License (free for personal and commercial use)

## Source

### fashion-preloader.tsx → `components/dimaac/fashion-preloader.tsx`

```tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

if (typeof window !== "undefined") gsap.registerPlugin(SplitText, useGSAP);

// A full-box loading curtain. Tilted photo cards open up one after another, the brand name rises
// letter by letter with a counter beside it, then the cards shrink away and the curtain lifts to
// show whatever sits underneath. Put it inside a `relative` box that is as big as the page you reveal.

export type FashionPreloaderProps = {
  /** The photos, one per card (they stack in this order). Four to eight look best. */
  images: string[];
  /** The big word in the middle. */
  brand?: string;
  /** How long the counter takes to run from 0 to 100, in seconds. The whole intro lasts about 3 seconds longer. */
  duration?: number;
  /** How far the cards lean, in degrees. 8 is the original look. 0 stacks them straight. */
  tilt?: number;
  /** Show the 000 to 100 counter next to the brand. */
  counter?: boolean;
  /** The curtain colour. */
  background?: string;
  /** The colour of the brand name and counter. */
  color?: string;
  /** Called when the curtain starts to lift. Use it to start the entrance of the page underneath. */
  onReveal?: () => void;
  /** Called when the curtain has gone. */
  onComplete?: () => void;
  /** Extra classes for the curtain, e.g. the font. Give the brand a font here, it inherits it. */
  className?: string;
};

// Each card leans by this share of `tilt`, so the pile fans out in both directions.
// At tilt 8 these are the angles 8, -3, -10, 10, -7, 5.
const LEAN = [1, -0.375, -1.25, 1.25, -0.875, 0.625];

// Cards start as a small square in the middle of the photo and open up to the full card
const CLOSED = "polygon(20% 20%, 80% 20%, 80% 80%, 20% 80%)";
const OPEN = "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)";
// The curtain lifts by pulling its bottom edge up to the top
const CURTAIN_UP = "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)";

const RISE = { yPercent: 100, rotation: 10, transformOrigin: "0% 100%" };

export function FashionPreloader({
  images,
  brand = "DiMaac",
  duration = 3,
  tilt = 8,
  counter = true,
  background = "#14161c",
  color = "#e8e2d6",
  onReveal,
  onComplete,
  className = "",
}: FashionPreloaderProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const brandRef = useRef<HTMLDivElement>(null);
  const titleRef = useRef<HTMLDivElement>(null);
  const countRef = useRef<HTMLParagraphElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  // The callbacks are read when they fire, so passing a new function never restarts the intro
  const onRevealRef = useRef(onReveal);
  onRevealRef.current = onReveal;
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  useGSAP(
    () => {
      const root = rootRef.current;
      const brandBox = brandRef.current;
      const title = titleRef.current;
      const count = countRef.current;
      const cards = cardRefs.current.slice(0, images.length).filter((card): card is HTMLDivElement => card !== null);
      if (!root || !brandBox || !title) return;

      const angle = (i: number) => (LEAN[i % LEAN.length] ?? 0) * tilt;
      const finish = () => {
        root.setAttribute("aria-hidden", "true");
        gsap.set(root, { display: "none" });
        onCompleteRef.current?.();
      };

      const mm = gsap.matchMedia();

      // Reduced motion: no flying cards. The loader shows its finished frame for a moment,
      // fades out, and the page underneath takes over.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        gsap.set(cards, { visibility: "visible", rotation: angle, xPercent: -50, yPercent: -50, clipPath: OPEN });
        gsap.set(brandBox, { visibility: "visible" });
        if (count) count.textContent = "100";
        const hold = gsap.timeline({ delay: Math.min(duration, 1.2) });
        hold.to(root, { opacity: 0, duration: 0.4, ease: "power1.out" });
        hold.call(() => onRevealRef.current?.(), [], 0);
        hold.call(finish);
      });

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        // The letters sit in masks, so each one rises out of a clip and nothing shows below
        const split = SplitText.create(title, { type: "chars", mask: "chars" });
        // The tight letter spacing makes glyphs overhang their box, so pad the masks to keep the edges
        gsap.set(split.masks, { paddingInline: "0.08em", marginInline: "-0.08em" });

        gsap.set(cards, { xPercent: -50, yPercent: -50, scale: 0, rotation: angle, clipPath: CLOSED, visibility: "visible" });
        gsap.set(split.chars, RISE);
        if (count) gsap.set(count, { yPercent: 100 });

        // When every moment happens, in seconds. Cards open first, the counter starts a little later,
        // and the exit begins just after the counter reaches 100.
        const cardsOpenEnd = 1 + (cards.length - 1) * 0.2;
        const counterStart = 0.85;
        const exitAt = Math.max(counterStart + duration + 0.4, cardsOpenEnd + 0.25);
        const curtainAt = exitAt + 1.1;

        const tl = gsap.timeline({ paused: true, delay: 0.3 });
        tl.to(cards, { scale: 1, clipPath: OPEN, duration: 1, ease: "power3.inOut", stagger: 0.2 }, 0);

        tl.set(brandBox, { visibility: "visible" }, 0.35);
        tl.to(split.chars, { yPercent: 0, rotation: 0, duration: 1, ease: "power3.out", stagger: 0.04 }, 0.35);
        if (count) {
          tl.to(count, { yPercent: 0, duration: 1, ease: "power3.out" }, 0.35);
          const progress = { value: 0 };
          tl.to(
            progress,
            {
              value: 100,
              duration,
              ease: "power2.inOut",
              onUpdate: () => {
                count.textContent = String(Math.round(progress.value)).padStart(3, "0");
              },
            },
            counterStart,
          );
          tl.to(count, { yPercent: -100, duration: 0.75, ease: "power3.in" }, exitAt);
        }

        tl.to(split.chars, { yPercent: -100, rotation: -10, duration: 0.75, ease: "power3.in", stagger: 0.04 }, exitAt);
        tl.to(cards, { scale: 0, clipPath: CLOSED, duration: 1, ease: "power3.inOut", stagger: -0.075 }, exitAt + 0.25);
        tl.to(root, { clipPath: CURTAIN_UP, duration: 1, ease: "power3.inOut" }, curtainAt);
        tl.call(() => onRevealRef.current?.(), [], curtainAt + 0.3);
        tl.call(finish, [], curtainAt + 1);

        // Start once the photos have decoded (or after a moment), so a card never opens empty
        let cancelled = false;
        const photos = Array.from(root.querySelectorAll("img"));
        const ready = Promise.all(photos.map((image) => image.decode().catch(() => undefined)));
        const timeout = new Promise((resolve) => window.setTimeout(resolve, 1500));
        void Promise.race([ready, timeout]).then(() => {
          if (!cancelled) tl.play();
        });

        return () => {
          cancelled = true;
          tl.kill();
          split.revert();
          root.removeAttribute("aria-hidden");
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [images.join("|"), brand, duration, tilt, counter], revertOnUpdate: true },
  );

  return (
    <div
      ref={rootRef}
      role="status"
      aria-label={`Loading ${brand}`}
      className={`absolute inset-0 z-50 overflow-hidden leading-[0.85] uppercase ${className}`}
      style={{ background, color, clipPath: OPEN }}
    >
      {images.map((src, index) => (
        <div
          key={`${index}-${src}`}
          ref={(card) => {
            cardRefs.current[index] = card;
          }}
          aria-hidden="true"
          className="absolute top-1/2 left-1/2 aspect-[5/6] w-[clamp(160px,17vw,300px)]"
          style={{ visibility: "hidden", willChange: "transform, clip-path" }}
        >
          <img src={src} alt="" className="block h-full w-full object-cover" draggable={false} loading="eager" decoding="async" />
        </div>
      ))}

      <div ref={brandRef} aria-hidden="true" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" style={{ visibility: "hidden" }}>
        <div ref={titleRef} className="whitespace-nowrap" style={{ fontSize: `clamp(1.5rem, min(10vw, ${55 / Math.max(brand.length, 1)}vw), 15rem)` }}>
          {brand}
        </div>
        {counter ? (
          <div className="absolute top-[-1.5rem] left-[calc(100%+1.5rem)] overflow-hidden text-[clamp(1rem,1.5vw,2rem)]">
            <p ref={countRef} className="tabular-nums">
              000
            </p>
          </div>
        ) : null}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useRef, useState } from "react";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";
import { FashionPreloader, type FashionPreloaderProps } from "@/components/dimaac/fashion-preloader";

if (typeof window !== "undefined") gsap.registerPlugin(SplitText, useGSAP);

// A studio landing page. The preloader covers it, then lifts, and the big word rises into place.
// Photos: Lummi (Lummi License: free for personal and commercial use). See the credits for the photographers.

const photos = [
  "fashion/orange-visor-4x5.jpg",
  "fashion/grey-suit-stride-4x5.jpg",
  "fashion/chunky-knit-4x5.jpg",
  "fashion/woven-corset-4x5.jpg",
  "fashion/gold-visor-4x5.jpg",
  "fashion/mid-air-suit-4x5.jpg",
].map(media);

const titles = ["Orange visor", "Grey suit", "Chunky knit", "Woven corset", "Gold visor", "Mid-air suit"];

// The page under the curtain. It waits hidden, then animates in when the preloader says "reveal".
function Landing({ brand, revealed, onReplay, canReplay }: { brand: string; revealed: boolean; onReplay: () => void; canReplay: boolean }) {
  const pageRef = useRef<HTMLDivElement>(null);
  const headingRef = useRef<HTMLHeadingElement>(null);

  useGSAP(
    () => {
      const heading = headingRef.current;
      if (!heading) return;
      const fromBelow = { yPercent: 100, rotation: 10, transformOrigin: "0% 100%" };
      const mm = gsap.matchMedia();
      // Reduced motion leaves plain text: no split, no masks, nothing to clip
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const split = SplitText.create(heading, { type: "chars", mask: "chars" });
        // The tight letter spacing makes glyphs overhang their box, so pad the masks to keep the edges
        gsap.set(split.masks, { paddingInline: "0.08em", marginInline: "-0.08em" });
        if (!revealed) {
          gsap.set(split.chars, fromBelow);
          gsap.set("[data-fade]", { opacity: 0, y: 14 });
        } else {
          gsap.fromTo(split.chars, fromBelow, { yPercent: 0, rotation: 0, duration: 1, ease: "power3.out", stagger: 0.04 });
          gsap.fromTo("[data-fade]", { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.9, ease: "power3.out", stagger: 0.08, delay: 0.35 });
        }
        return () => split.revert();
      });
      return () => mm.revert();
    },
    { scope: pageRef, dependencies: [revealed, brand], revertOnUpdate: true },
  );

  return (
    <div ref={pageRef} className="absolute inset-0 flex flex-col overflow-hidden bg-[#e8e2d6] text-[#14161c]">
      <div className="relative flex flex-1 items-center justify-center pt-10">
        <h1
          ref={headingRef}
          className="leading-[0.85] font-medium tracking-[-0.045em] whitespace-nowrap uppercase"
          style={{ fontSize: `clamp(1.5rem, min(22vw, ${130 / Math.max(brand.length, 1)}vw), 26rem)` }}
        >
          {brand}
        </h1>
      </div>

      <footer data-fade className="relative z-10 flex items-center justify-center gap-2 px-6 pt-4 pb-24 md:gap-3 md:px-10 md:pt-6 md:pb-20">
        {photos.map((photo, i) => (
          <figure key={photo} className="w-[13%] max-w-[120px] min-w-[44px]">
            <img src={photo} alt={titles[i]} draggable={false} className="aspect-[4/5] w-full rounded-[3px] object-cover" />
            <figcaption className="mt-2 text-[10px] leading-none font-medium tracking-[0.06em] uppercase opacity-60 md:text-[11px]">No. {String(i + 1).padStart(2, "0")}</figcaption>
          </figure>
        ))}
      </footer>

      <button
        type="button"
        onClick={onReplay}
        tabIndex={canReplay ? 0 : -1}
        className={`absolute bottom-6 left-1/2 z-10 -translate-x-1/2 cursor-pointer rounded-full border border-[#14161c]/30 bg-[#e8e2d6] px-4 py-2 text-[12px] font-medium tracking-[0.04em] uppercase transition-opacity duration-300 hover:bg-[#14161c] hover:text-[#e8e2d6] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#14161c] ${canReplay ? "opacity-100" : "pointer-events-none opacity-0"}`}
      >
        Replay
      </button>
    </div>
  );
}

export default function Demo(props: Partial<FashionPreloaderProps>) {
  // A new key builds both the curtain and the page again, which is how you replay it
  const [run, setRun] = useState(0);
  const [revealed, setRevealed] = useState(false);
  const [done, setDone] = useState(false);
  const brand = props.brand ?? "DiMaac";

  const replay = () => {
    setRevealed(false);
    setDone(false);
    setRun((n) => n + 1);
  };

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#e8e2d6]" style={{ fontFamily: '"Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif', fontWeight: 500 }}>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500&display=swap" precedence="default" />
      <Landing key={`page-${run}`} brand={brand} revealed={revealed} onReplay={replay} canReplay={done} />
      <FashionPreloader
        key={`loader-${run}`}
        images={photos}
        {...props}
        className={`font-medium tracking-[-0.04em] ${props.className ?? ""}`}
        onReveal={() => {
          setRevealed(true);
          props.onReveal?.();
        }}
        onComplete={() => {
          setDone(true);
          props.onComplete?.();
        }}
      />
    </div>
  );
}
```

## Agent prompt

```text
Add the Fashion Preloader component (<FashionPreloader />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/fashion-preloader
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/fashion-preloader.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/fashion-preloader.json

Then use it like this:
import { FashionPreloader } from "@/components/dimaac/fashion-preloader";
<FashionPreloader images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/fashion-preloader.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Film Split Loader
description: Loader words spread apart and a tilted card grows between them into the full-screen picture.
category: Loaders
tier: free
url: https://ui.dimaac.com/components/film-split-loader
registry: https://ui.dimaac.com/r/film-split-loader.json
---

# Film Split Loader

A film-title opening. Two words sit in the middle of the screen, then they spread apart and a small tilted card pops up between them. The card straightens and grows until it fills the whole box with a picture, your header fades in over it and the title rises word by word. It waits for the picture to decode before it starts, so the card never grows empty. Colours come from three CSS variables, so it follows a light or dark theme. With reduced motion the picture is shown full screen straight away. Rebuilt from the De PuyDT-style opening in the DiMaac YouTube demo with GSAP.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/film-split-loader.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/film-split-loader.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/film-split-loader.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/film-split-loader`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/film-split-loader.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { FilmSplitLoader } from "@/components/dimaac/film-split-loader" and give its parent a size: it fills it.

## Usage

```tsx
import { FilmSplitLoader } from "@/components/dimaac/film-split-loader";

<FilmSplitLoader src={src} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` | `[string, string]` | `["Di", "Maac"]` | The two loader words: one goes left of the card, one goes right. |
| `src` (required) | `string` | - | The picture the card grows into. |
| `alt` | `string` | `""` | Alt text for the picture. Leave empty if it is only decoration. |
| `rotate` | `number` | `-8` | How much the card is tilted while it pops up, in degrees. It straightens as it grows. |
| `duration` | `number` | `2.4` | How long the whole opening takes, in seconds (from the first word to the full-screen picture). |
| `gap` | `number` | `20` | How far apart the two words spread, as a percentage of the box width. On narrow boxes it grows so the card always fits. |
| `title` | `string` | - | The big line that rises over the picture once it is full screen. |
| `subtitle` | `string` | - | The smaller line under the title. |
| `children` | `ReactNode` | - | Your own header or navigation, shown over the picture. It fades in while the card grows. |
| `onComplete` | `() => void` | - | Called when the title has finished rising. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |

## Credits

- Photo: Siren Media, Lummi (https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl), Lummi License

## Source

### film-split-loader.tsx → `components/dimaac/film-split-loader.tsx`

```tsx
"use client";

import { useRef, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A loader that opens like a film title. Two words sit in the middle of the screen, then they
// spread apart and a small tilted card pops up between them. The card straightens, grows to fill
// the whole box and shows the picture, and the title rises word by word on top of it.
//
// Colours come from CSS variables so the host page can theme it (see className):
//   --film-bg  the loader background        --film-fg  the loader words
//   --film-on  text and header on the image

export type FilmSplitLoaderProps = {
  /** The two loader words: one goes left of the card, one goes right. */
  words?: [string, string];
  /** The picture the card grows into. */
  src: string;
  /** Alt text for the picture. Leave empty if it is only decoration. */
  alt?: string;
  /** How much the card is tilted while it pops up, in degrees. It straightens as it grows. */
  rotate?: number;
  /** How long the whole opening takes, in seconds (from the first word to the full-screen picture). */
  duration?: number;
  /** How far apart the two words spread, as a percentage of the box width. On narrow boxes it grows so the card always fits. */
  gap?: number;
  /** The big line that rises over the picture once it is full screen. */
  title?: string;
  /** The smaller line under the title. */
  subtitle?: string;
  /** Your own header or navigation, shown over the picture. It fades in while the card grows. */
  children?: ReactNode;
  /** Called when the title has finished rising. */
  onComplete?: () => void;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// How big the card is at the start, as a share of the box width, for a few box widths
function cardSize(boxWidth: number) {
  if (boxWidth <= 480) return { w: 0.35, h: 0.5 };
  if (boxWidth <= 768) return { w: 0.3, h: 0.4 };
  if (boxWidth <= 1024) return { w: 0.18, h: 0.25 };
  return { w: 0.1, h: 0.15 };
}

// Each word sits in its own clipped box so it can slide up from below the line
function RiseWords({ text, className }: { text: string; className: string }) {
  return (
    <p className={`overflow-hidden ${className}`}>
      {text.split(" ").map((word, index, all) => (
        <span key={`${index}-${word}`} data-rise className="inline-block">
          {word}
          {index < all.length - 1 ? " " : ""}
        </span>
      ))}
    </p>
  );
}

export function FilmSplitLoader({
  words = ["Di", "Maac"],
  src,
  alt = "",
  rotate = -8,
  duration = 2.4,
  gap = 20,
  title,
  subtitle,
  children,
  onComplete,
  className = "",
}: FilmSplitLoaderProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const loaderRef = useRef<HTMLDivElement>(null);
  const wordsRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const imageRef = useRef<HTMLImageElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  // Read when the timeline finishes, so a new callback doesn't replay the opening
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  useGSAP(
    () => {
      const box = boxRef.current;
      const loader = loaderRef.current;
      const heading = wordsRef.current;
      const card = cardRef.current;
      const image = imageRef.current;
      const content = contentRef.current;
      if (!box || !loader || !heading || !card || !image || !content) return;
      const riseWords = gsap.utils.toArray<HTMLElement>("[data-rise]", box);

      const mm = gsap.matchMedia();

      // Reduced motion: no opening. The picture is already full screen and the text is in place.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        gsap.set(loader, { autoAlpha: 0 });
        gsap.set(card, { autoAlpha: 1, width: "100%", height: "100%" });
        gsap.set(content, { opacity: 1 });
        onCompleteRef.current?.();
      });

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const boxWidth = box.clientWidth;
        const size = cardSize(boxWidth);
        const cardWidth = boxWidth * size.w;
        // The words must spread wide enough for the card to fit between them
        const spread = Math.max((boxWidth * gap) / 100, cardWidth * 1.6);

        gsap.set(loader, { autoAlpha: 1 });
        gsap.set(heading, { opacity: 0, columnGap: 8 });
        gsap.set(card, { autoAlpha: 1, width: cardWidth, height: boxWidth * size.h, rotation: rotate, scale: 0 });
        gsap.set(image, { scale: 1.5 });
        gsap.set(content, { opacity: 0 });
        // Start from a clean transform, so a replay never stacks on where the last run ended
        gsap.set(riseWords, { clearProps: "transform" });
        gsap.set(riseWords, { yPercent: 120 });

        // Part one: words appear, spread apart, the card pops up, grows and straightens.
        // It is built in "natural" seconds and then stretched to the duration you asked for.
        const opening = gsap.timeline({ defaults: { ease: "power2.inOut" } });
        opening
          .to(heading, { opacity: 1, duration: 0.5, ease: "power2.out" })
          .to(heading, { columnGap: spread, duration: 0.8 }, 0.4)
          .to(card, { scale: 1, duration: 0.8 }, 0.4)
          .to(card, { rotation: 0, width: "100%", height: "100%", duration: 1.5 }, 1.2)
          .to(image, { scale: 1, duration: 1.5 }, 1.5)
          .to(content, { opacity: 1, duration: 0.6, ease: "power2.out" }, 2.1)
          .set(loader, { autoAlpha: 0 });
        opening.duration(Math.max(0.2, duration));

        // Part two: the title rises word by word
        const tl = gsap.timeline({ paused: true, onComplete: () => onCompleteRef.current?.() });
        tl.add(opening).to(riseWords, { yPercent: 0, duration: 0.5, stagger: 0.03, ease: "power3.out" }, "-=0.2");

        // Wait for the picture so the card never grows empty (but never wait longer than 1.5 s)
        let cancelled = false;
        const ready = Promise.race([image.decode().catch(() => undefined), new Promise((resolve) => window.setTimeout(resolve, 1500))]);
        void ready.then(() => {
          if (!cancelled) tl.play();
        });

        return () => {
          cancelled = true;
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [rotate, duration, gap, words[0], words[1], title, subtitle], revertOnUpdate: true },
  );

  return (
    <div
      ref={boxRef}
      className={`relative h-full w-full overflow-hidden bg-[var(--film-bg,#faf0ed)] text-[var(--film-fg,#2a1f1c)] ${className}`}
    >
      {/* The loader: the two words, centred, with the background */}
      <div ref={loaderRef} aria-hidden="true" className="absolute inset-0 flex items-center justify-center bg-[var(--film-bg,#faf0ed)]">
        <div ref={wordsRef} className="flex items-center justify-center gap-2 text-[clamp(1.2rem,3vw,1.6rem)] font-light tracking-wide uppercase opacity-0">
          <span>{words[0]}</span>
          <span>{words[1]}</span>
        </div>
      </div>

      {/* The card that grows into the picture */}
      <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
        <div ref={cardRef} className="invisible relative shrink-0 overflow-hidden bg-[#1a1411]">
          <img ref={imageRef} src={src} alt={alt} className="absolute inset-0 block h-full w-full object-cover" draggable={false} decoding="async" />
          <div className="absolute inset-0 bg-gradient-to-b from-black/50 to-black/35" />
        </div>
      </div>

      {/* What sits on the picture: your header, then the title */}
      <div ref={contentRef} className="absolute inset-0 flex flex-col justify-between text-[var(--film-on,#faf0ed)] opacity-0">
        <div>{children}</div>
        {title || subtitle ? (
          <div className="mb-[12vh] flex flex-col items-center gap-1.5 px-4 text-center font-extralight">
            {title ? <RiseWords text={title} className="text-[clamp(1.5rem,6vw,3rem)] leading-tight" /> : null}
            {subtitle ? <RiseWords text={subtitle} className="text-[clamp(0.9rem,2.5vw,1.5rem)]" /> : null}
          </div>
        ) : null}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { FilmSplitLoader, type FilmSplitLoaderProps } from "@/components/dimaac/film-split-loader";

// The playground sends the two words as separate controls, so the demo joins them again
type DemoProps = Partial<FilmSplitLoaderProps> & { leftWord?: string; rightWord?: string };

export default function Demo({ leftWord, rightWord, className, ...props }: DemoProps) {
  const words: [string, string] = [leftWord ?? props.words?.[0] ?? "Di", rightWord ?? props.words?.[1] ?? "Maac"];

  return (
    <FilmSplitLoader
      src="https://ui.dimaac.com/media/architecture/red-wall-interior-3x2.jpg"
      alt="A modern interior with a bold red textured wall and large windows onto a desert"
      title="Beautiful, Modern UI"
      subtitle="Components for effortless web design"
      {...props}
      words={words}
      className={`[--film-bg:#faf0ed] [--film-fg:#2a1f1c] dark:[--film-bg:#16110f] dark:[--film-fg:#f1e6e0] ${className ?? ""}`}
    >
      <header className="flex justify-center px-[clamp(1.5rem,4vw,3rem)] pt-[clamp(1.5rem,4vw,3.5rem)]">
        <span className="text-[clamp(1.5rem,5vw,3rem)] leading-none font-light">DI MAAC</span>
      </header>
    </FilmSplitLoader>
  );
}
```

## Agent prompt

```text
Add the Film Split Loader component (<FilmSplitLoader />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/film-split-loader
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/film-split-loader.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/film-split-loader.json

Then use it like this:
import { FilmSplitLoader } from "@/components/dimaac/film-split-loader";
<FilmSplitLoader src={src} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/film-split-loader.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Flow Art
description: Colour-block sections that swing in over each other as you scroll.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/flow-art
registry: https://ui.dimaac.com/r/flow-art.json
---

# Flow Art

A stack of big editorial colour blocks. As you scroll, each new section swings in from a tilted angle around its bottom corner, like a page being turned, while the section before it stays pinned underneath. The swing is scrubbed to the scrollbar, so it swings back out when you scroll up. Tune the angle, the corner it pivots on, where on the screen it settles, and whether the old sections pin. It works on the page or inside any scrolling element through the scroller prop, handles sections taller than the screen, measures again when fonts and pictures arrive, and falls back to a plain coloured page when the visitor prefers reduced motion. Ported from the first DiMaac UI, with its site-only scroll context replaced by a scroller prop. Two small parts: FlowArt and FlowSection.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/flow-art.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/flow-art.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/flow-art.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/flow-art`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/flow-art.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { FlowArt } from "@/components/dimaac/flow-art" and give its parent a size: it fills it.

## Usage

```tsx
import { FlowArt } from "@/components/dimaac/flow-art";

<FlowArt children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | - | The sections, in order. Use <FlowSection> for each one. |
| `angle` | `number` | `30` | How far each section is tilted before it swings in, in degrees. |
| `from` | `"left" \| "right"` | `"left"` | Which bottom corner the section swings around: the left one or the right one. |
| `settle` | `number` | `0.25` | Where on the screen the swing ends, as a fraction of the screen height from the top. 0.25 means a quarter of the way down. |
| `pin` | `boolean` | `true` | Keep every section pinned in place while the next one covers it. Off, they scroll away normally. |
| `smoothing` | `number` | `0` | How long the swing takes to catch up with the scrollbar, in seconds. 0 locks it to the scrollbar. |
| `scroller` | `string \| HTMLElement \| Window` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes for the outer wrapper. |

## Credits

- Modern Sculpture Display: rena, Lummi (https://www.lummi.ai/photo/modern-sculpture-display-y1he7), Lummi License
- Surreal 3D Planet Scene: Ricardo Matos, Lummi (https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw), Lummi License
- Abstract Sculptural Objects: Dona Mara, Lummi (https://www.lummi.ai/photo/abstract-sculptural-objects-flp-3), Lummi License
- Vibrant Cylindrical Arrangement: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Abstract Wavy Design: rena, Lummi (https://www.lummi.ai/photo/abstract-wavy-design-bypvi), Lummi License
- Glossy Pink Sphere: marisol, Lummi (https://www.lummi.ai/photo/glossy-pink-sphere-45si5), Lummi License

## Source

### flow-art.tsx → `components/dimaac/flow-art.tsx`

```tsx
"use client";

import { useRef, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// Big colour-block sections that flow into each other. As you scroll, the next section swings in
// from a tilted angle (like a page being turned) while the section before it stays pinned underneath.
// Two small pieces:
//   <FlowArt>      wraps everything and runs the animation
//   <FlowSection>  one coloured panel. Put any content inside it.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);

export type FlowArtProps = {
  /** The sections, in order. Use <FlowSection> for each one. */
  children: ReactNode;
  /** How far each section is tilted before it swings in, in degrees. */
  angle?: number;
  /** Which bottom corner the section swings around: the left one or the right one. */
  from?: "left" | "right";
  /** Where on the screen the swing ends, as a fraction of the screen height from the top. 0.25 means a quarter of the way down. */
  settle?: number;
  /** Keep every section pinned in place while the next one covers it. Off, they scroll away normally. */
  pin?: boolean;
  /** How long the swing takes to catch up with the scrollbar, in seconds. 0 locks it to the scrollbar. */
  smoothing?: number;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement | Window;
  /** Extra classes for the outer wrapper. */
  className?: string;
};

export function FlowArt({ children, angle = 30, from = "left", settle = 0.25, pin = true, smoothing = 0, scroller, className = "" }: FlowArtProps) {
  const rootRef = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;

      const mm = gsap.matchMedia();

      // Reduced motion: no tilting and no pinning. The sections are just a plain coloured page.
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const sections = gsap.utils.toArray<HTMLElement>("[data-flow-section]", root);
        // Swinging round the right corner means turning the other way
        const direction = from === "right" ? -1 : 1;
        const origin = from === "right" ? "bottom right" : "bottom left";

        sections.forEach((section, index) => {
          const panel = section.querySelector<HTMLElement>("[data-flow-panel]");
          if (!panel) return;

          // The first section is simply there. Every other one swings in while it climbs the screen.
          // scrub ties the swing to the scrollbar, so it swings back out when you scroll up.
          if (index > 0) {
            gsap.fromTo(
              panel,
              { rotation: angle * direction, transformOrigin: origin },
              {
                rotation: 0,
                ease: "none",
                force3D: true,
                scrollTrigger: { trigger: section, scroller, start: "top bottom", end: `top ${settle * 100}%`, scrub: smoothing > 0 ? smoothing : true },
              },
            );
          }

          // Hold the section still, with the next one sliding over the top of it. A section taller than
          // the screen pins by its bottom edge, so everything in it has been scrolled past first.
          if (pin && index < sections.length - 1) {
            ScrollTrigger.create({ trigger: section, scroller, start: "bottom bottom", end: "bottom top", pin: true, pinSpacing: false });
          }
        });

        // Fonts and pictures arriving late change the heights, so measure the pins again.
        // A short delay turns a burst of changes into one measurement.
        let measure: gsap.core.Tween | undefined;
        const measureSoon = () => {
          measure?.kill();
          measure = gsap.delayedCall(0.2, () => ScrollTrigger.refresh());
        };
        const observer = new ResizeObserver(measureSoon);
        // Only the wrapper: a pinned section changes its own box while it sticks, which must not re-trigger this
        observer.observe(root);
        let cancelled = false;
        document.fonts?.ready.then(() => {
          if (!cancelled) measureSoon();
        });

        return () => {
          cancelled = true;
          measure?.kill();
          observer.disconnect();
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [angle, from, settle, pin, smoothing, scroller], revertOnUpdate: true },
  );

  return (
    <div ref={rootRef} className={`relative w-full overflow-x-clip ${className}`}>
      {children}
    </div>
  );
}

export type FlowSectionProps = {
  /** What the section shows. */
  children: ReactNode;
  /** Extra classes for the coloured panel: the background, text colour and padding go here. */
  className?: string;
  /** Inline styles for the panel, e.g. a background colour. */
  style?: CSSProperties;
};

/** One coloured panel. It is the panel (not the section around it) that swings in, so the corners cut into the section below. */
export function FlowSection({ children, className = "", style }: FlowSectionProps) {
  return (
    <section data-flow-section className="relative w-full overflow-hidden">
      <div
        data-flow-panel
        className={`relative flex min-h-[100svh] w-full flex-col justify-between gap-6 px-[max(1.25rem,4vw)] pt-[max(2rem,4vw)] pb-[max(1.5rem,4vw)] ${className}`}
        style={{ transformOrigin: "bottom left", willChange: "transform", ...style }}
      >
        {children}
      </div>
    </section>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import type { ReactNode } from "react";
import { FlowArt, FlowSection, type FlowArtProps } from "@/components/dimaac/flow-art";

// A maker's manifesto told in five colour blocks. The sections have their own fixed colours,
// so the effect reads the same in the light and the dark site theme.

const display = "font-[family-name:'Bebas_Neue',sans-serif]";

const headline = `${display} text-[clamp(3.5rem,12vw,14rem)] leading-[0.85] tracking-[-0.02em] uppercase`;
const lead = "max-w-[50ch] text-[clamp(1rem,2.5vw,2rem)] leading-[1.4]";

function Tag({ children }: { children: ReactNode }) {
  return <p className="text-[0.8rem] font-bold tracking-[0.2em] uppercase">{children}</p>;
}

function Rule({ strong = false }: { strong?: boolean }) {
  return <div aria-hidden="true" className={`my-[2vw] h-px w-full shrink-0 bg-current ${strong ? "" : "opacity-60"}`} />;
}

type Note = { label: string; text: string };

function Notes({ notes }: { notes: Note[] }) {
  return (
    <div className="flex flex-wrap gap-x-[3vw] gap-y-6">
      {notes.map((note) => (
        <div key={note.label} className="min-w-[180px] flex-1">
          <p className="mb-2 text-[0.85rem] font-bold tracking-[0.1em] uppercase">{note.label}</p>
          <p className="text-[clamp(0.85rem,1.3vw,1.05rem)] leading-normal opacity-75">{note.text}</p>
        </div>
      ))}
    </div>
  );
}

function Picture({ file, alt, className = "" }: { file: string; alt: string; className?: string }) {
  return <img src={media(file)} alt={alt} loading="lazy" decoding="async" draggable={false} className={`block w-full object-cover ${className}`} />;
}

export default function Demo(props: Partial<FlowArtProps>) {
  const { children: _children, className = "", ...settings } = props;
  return (
    <div className="font-[family-name:'Bricolage_Grotesque',sans-serif]">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Bricolage+Grotesque:wght@400;600;700&display=swap" precedence="default" />

      <FlowArt {...settings} className={className}>
        <FlowSection style={{ backgroundColor: "#E85A2B", color: "#000" }}>
          <Tag>01 — Who we are</Tag>
          <Rule strong />
          <div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
            <h1 className={headline}>
              Create
              <br />
              Without
              <br />
              Limits
            </h1>
            <Picture
              file="art/orange-sculpture-1x1.jpg"
              alt="Minimalist sculpture with an orange oval shape on a wooden base in a modern interior"
              className="aspect-square max-w-[min(100%,420px)] md:w-[28vw] md:max-w-none"
            />
          </div>
          <Rule strong />
          <p className={`${lead} mt-auto`}>We believe good interfaces start with taste. No filler, no noise — just considered motion and the people who build it.</p>
        </FlowSection>

        <FlowSection style={{ backgroundColor: "#000", color: "#fff" }}>
          <Tag>02 — The idea</Tag>
          <Rule />
          <h2 className={headline}>
            Craft
            <br />
            First
            <br />
            Always
          </h2>
          <Rule />
          <div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
            <p className={lead}>A toolkit built for makers, by makers. Small pieces, each one finished, ready to copy into your own project.</p>
            <Picture
              file="art/floating-spheres-4x5.jpg"
              alt="Surreal 3D scene of a textured gray sphere with floating colourful spheres resembling planets"
              className="aspect-[4/5] max-w-[320px] md:w-[20vw]"
            />
          </div>
          <Rule />
          <Notes
            notes={[
              { label: "Pick", text: "Hand-chosen pieces that put real taste first. Every one is tested in the browser before it ships, and the best ones rise to the top." },
              { label: "Copy", text: "Take the code, not a dependency. Each piece is plain files you can read, change and make your own, with no lock-in." },
              { label: "Free", text: "Fair licensing. Clear terms. Free pieces stay free, and the paid ones are a one-time price with no hidden fees." },
            ]}
          />
          <Rule />
          <Notes
            notes={[
              { label: "Previews", text: "Every piece runs live in its own page. Open it full screen, scrub the settings and see exactly what you are getting." },
              { label: "Tuning", text: "Sliders for the numbers that matter. Change the timing, the ease or the radius, then copy the code that matches." },
              { label: "Notes", text: "Short, honest write-ups for every piece: what it does, what it needs and where it can break. No surprises later." },
            ]}
          />
          <Rule />
          <p className={`${lead} mt-auto self-end text-right`}>Every piece we build starts with one question — would we use this ourselves?</p>
        </FlowSection>

        <FlowSection style={{ backgroundColor: "#F5F0E8", color: "#000" }}>
          <Tag>03 — How it works</Tag>
          <Rule />
          <div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
            <h2 className={headline}>
              Show
              <br />
              Up.
              <br />
              Stand
              <br />
              Out.
            </h2>
            <div className="grid grid-cols-2 gap-3 md:w-[40vw] md:gap-[1.5vw]">
              <Picture file="art/organic-forms-4x5.jpg" alt="Abstract sculptural objects with organic and geometric forms in neutral tones with gold accents" className="aspect-[4/5]" />
              <Picture file="art/colour-cylinders-4x5.jpg" alt="Colourful cylindrical objects on a vibrant geometric background" className="mt-10 aspect-[4/5]" />
            </div>
          </div>
          <Rule />
          <p className={lead}>Three steps. Zero complexity. Your interface starts moving the moment you paste it in.</p>
          <Rule />
          <Notes
            notes={[
              { label: "01 — Browse", text: "Scroll the library, hover to see a piece move and open any of them in its own page in a single click." },
              { label: "02 — Adjust", text: "Tune the settings live until it feels right. Everything you change is written into the snippet as you go." },
              { label: "03 — Install", text: "One command, or copy the files by hand. It lands in your project as plain, readable code." },
            ]}
          />
          <Rule />
          <Notes
            notes={[
              { label: "04 — Restyle", text: "Colours, fonts and spacing come from your own tokens, so a piece takes on the look of your site." },
              { label: "05 — Combine", text: "Pieces are small and independent. Mix a few on one page and they still behave." },
              { label: "06 — Update", text: "When a piece improves, the changes are listed, so you can pull in what you want and skip the rest." },
            ]}
          />
        </FlowSection>

        <FlowSection style={{ backgroundColor: "#1A3DE8", color: "#fff" }}>
          <Tag>04 — The aim</Tag>
          <Rule />
          <h2 className={headline}>
            Future
            <br />
            Of
            <br />
            Motion
          </h2>
          <Rule />
          <p className={lead}>We're not just building a library. We're raising the bar.</p>
          <Rule />
          <Notes
            notes={[
              { label: "Readable", text: "Plain code with sensible names, so the next person understands it in a minute." },
              { label: "Fast", text: "Only transforms and clip-paths move, so everything stays smooth on an average phone." },
              { label: "Yours", text: "No account, no tracking, no strings. Take it and build what you like." },
            ]}
          />
          <Rule />
          <Picture file="art/blue-wave-lines-16x9.jpg" alt="Abstract wavy lines in teal and blue on a deep blue background" className="aspect-[16/9] max-h-[48svh] md:aspect-[21/8]" />
          <Rule />
          <p className={lead}>Most motion on the web is heavy, copied and never quite right. We're here to change that — one careful piece at a time.</p>
          <Rule />
          <Notes
            notes={[
              { label: "Open door", text: "No invite codes. No waiting lists. If you build for the web, you belong here." },
              { label: "Every screen", text: "Phones, tablets and big monitors, with touch and keyboard handled from day one." },
              { label: "Fair terms", text: "Use it in client work and commercial projects. Pro pieces are a single payment, forever." },
            ]}
          />
        </FlowSection>

        <FlowSection style={{ backgroundColor: "#E8E02B", color: "#000" }}>
          <Tag>05 — Join us</Tag>
          <Rule />
          <div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
            <h2 className={headline}>
              Ready
              <br />
              To
              <br />
              Begin?
            </h2>
            <Picture
              file="art/pink-sphere-1x1.jpg"
              alt="A glossy pink sphere reflecting a dreamy sky with clouds"
              className="aspect-square max-w-[420px] rounded-full md:w-[26vw] md:max-w-none"
            />
          </div>
          <Rule />
          <p className={`${lead} mt-auto`}>Take control of your interface. Pick a piece, paste it in and let's make the web feel better together.</p>
        </FlowSection>
      </FlowArt>
    </div>
  );
}
```

## Agent prompt

```text
Add the Flow Art component (<FlowArt />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/flow-art
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/flow-art.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/flow-art.json

Then use it like this:
import { FlowArt } from "@/components/dimaac/flow-art";
<FlowArt children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/flow-art.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Folder Hover Tabs
description: Service folders that lift and fan three photos open on hover.
category: Cards
tier: free
url: https://ui.dimaac.com/components/folder-hover
registry: https://ui.dimaac.com/r/folder-hover.json
---

# Folder Hover Tabs

A stack of coloured folders along the bottom of the page, like a filing drawer. Point at one and its tab lifts, the folders you are not pointing at fade back, and three photos slide out from behind the front and fan open above the tab. Each folder has its own colour, title, number and photos. It works with the mouse, with the keyboard (Tab onto a folder, Escape to close) and with touch (tap to open, tap again to close), it makes fewer columns on narrow screens, and with reduced motion the folders still open, just in one step. Rebuilt from a YouTube demo with scoped GSAP tweens and no Pinterest images.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/folder-hover.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/folder-hover.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/folder-hover.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/folder-hover`

Dependencies:

```bash
pnpm add gsap
npm install gsap
```

By hand:

1. Install the packages it uses: gsap.
2. Copy the component file below into components/dimaac/folder-hover.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { FolderHover } from "@/components/dimaac/folder-hover" and give its parent a size: it fills it.

## Usage

```tsx
import { FolderHover } from "@/components/dimaac/folder-hover";

<FolderHover folders={folders} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `folders` (required) | `FolderItem[]` | - | The folders, in reading order. Five fill two rows nicely (2 then 3). |
| `fan` | `number` | `18` | The widest tilt of the photos when a folder opens, in degrees. The left photo leans left, the right one leans right. |
| `lift` | `number` | `24` | How far the open folder's front and tab rise, in pixels. |
| `duration` | `number` | `0.6` | How long the folder takes to open, in seconds. |
| `ease` | `string` | `"back.out(1.4)"` | The GSAP ease of the photos sliding out, e.g. "back.out(1.4)" or "power3.out". |
| `color` | `string` | `"#f5e04a"` | The colour used when `colorMode` is "single". |
| `colorMode` | `"own" \| "single"` | `"own"` | "own" gives each folder its own colour, "single" paints them all with `color`. |
| `paper` | `string` | `"#ece8e1"` | The colour of the page behind the folders. The folders you are not pointing at fade toward it. Any CSS colour or var(). |
| `ink` | `string` | `"#1a1a1a"` | The colour of text on the page. The faded folders print their titles in a soft mix of it. Any CSS colour or var(). |
| `perRow` | `number` | `3` | The most folders in one row. Narrow boxes use fewer, so the folders never get too thin. |
| `folderHeight` | `number` | `200` | The height of one folder in pixels (the tab sits on top of it). Narrow boxes shrink it a quarter. |
| `defaultOpen` | `number \| null` | `null` | The index of a folder that starts open, or null for none. It closes as soon as the pointer leaves it. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |

## Credits

- Photo: Elegant perfume bottle: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Photo: Elegant perfume bottles: Amino, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottles-vnjg1), Lummi License
- Photo: Minimalist skincare tube: Clemara Studio, Lummi (https://www.lummi.ai/photo/minimalist-skincare-tube-tmejb), Lummi License
- Photo: Abstract eye close-up: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Photo: Modern fashion pose: Axel Photography, Lummi (https://www.lummi.ai/photo/modern-fashion-pose-zsdav), Lummi License
- Photo: Serene gradient landscape: Nika, Lummi (https://www.lummi.ai/photo/serene-gradient-landscape-80b2l), Lummi License
- Photo: Modern architectural design: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Photo: Contemplative silhouette against minimalist architecture: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License
- Photo: Modern minimalist building: Jupiter et Passagère®, Lummi (https://www.lummi.ai/photo/modern-minimalist-building-kumrj), Lummi License
- Photo: Vibrant cylindrical arrangement: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Photo: Mystery portrait: SHIHO, Lummi (https://www.lummi.ai/photo/mystery-portrait-cffyo), Lummi License
- Photo: Portrait through orange frame: Bernardo Lickfold, Lummi (https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i), Lummi License
- Photo: Surreal 3D planet scene: Ricardo Matos, Lummi (https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw), Lummi License
- Photo: Abstract sculptural objects: Dona Mara, Lummi (https://www.lummi.ai/photo/abstract-sculptural-objects-flp-3), Lummi License
- Photo: Intricate natural texture: Nika, Lummi (https://www.lummi.ai/photo/intricate-natural-texture-buk3_), Lummi License

## Source

### folder-hover.tsx → `components/dimaac/folder-hover.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";

// Service folders stacked along the bottom of the page. Point at one and its tab lifts, the others
// fade back, and three photos slide out from behind the front and fan open above the tab.
// Mouse: hover. Keyboard: Tab onto a folder (Escape closes it). Touch: tap a folder, tap it again to close.

export type FolderItem = {
  /** The name on the tab, e.g. "Branding". */
  title: string;
  /** The small number or word above the title, e.g. "01". */
  label?: string;
  /** The photos inside, left to right. The first three are used. */
  images: string[];
  /** Alt text for each photo, in the same order. */
  alts?: string[];
  /** This folder's colour. Used unless the `color` prop paints every folder the same. */
  color?: string;
};

export type FolderHoverProps = {
  /** The folders, in reading order. Five fill two rows nicely (2 then 3). */
  folders: FolderItem[];
  /** The widest tilt of the photos when a folder opens, in degrees. The left photo leans left, the right one leans right. */
  fan?: number;
  /** How far the open folder's front and tab rise, in pixels. */
  lift?: number;
  /** How long the folder takes to open, in seconds. */
  duration?: number;
  /** The GSAP ease of the photos sliding out, e.g. "back.out(1.4)" or "power3.out". */
  ease?: string;
  /** The colour used when `colorMode` is "single". */
  color?: string;
  /** "own" gives each folder its own colour, "single" paints them all with `color`. */
  colorMode?: "own" | "single";
  /** The colour of the page behind the folders. The folders you are not pointing at fade toward it. Any CSS colour or var(). */
  paper?: string;
  /** The colour of text on the page. The faded folders print their titles in a soft mix of it. Any CSS colour or var(). */
  ink?: string;
  /** The most folders in one row. Narrow boxes use fewer, so the folders never get too thin. */
  perRow?: number;
  /** The height of one folder in pixels (the tab sits on top of it). Narrow boxes shrink it a quarter. */
  folderHeight?: number;
  /** The index of a folder that starts open, or null for none. It closes as soon as the pointer leaves it. */
  defaultOpen?: number | null;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// Used when a folder has no colour of its own
const FALLBACK_COLOURS = ["#f5e04a", "#d4d4d4", "#e8e4dc", "#9c9c9c", "#ffcc4d"];
// The text printed on a folder. The folders are always light-ish paper, so this stays dark in both themes.
const FOLDER_INK = "#1a1a1a";
// A folder never gets thinner than this, so titles keep fitting
const MIN_FOLDER_WIDTH = 170;

// Where the three photos sit (-1 left, 0 middle, 1 right of the centre of the photo box) and how far
// each may tilt, as a share of `fan`. The middle photo is the only one that can lean either way.
const SLOTS = [
  { side: -1, tilt: [-1, -0.5] },
  { side: 0, tilt: [-0.5, 0.5] },
  { side: 1, tilt: [0.5, 1] },
] as const;

/** Splits the folders into rows, filling the bottom row first: 5 folders at 3 per row become 2 + 3. */
function toRows(count: number, perRow: number): number[][] {
  const rows: number[][] = [];
  for (let end = count; end > 0; end -= perRow) {
    const start = Math.max(0, end - perRow);
    rows.unshift(Array.from({ length: end - start }, (_, i) => start + i));
  }
  return rows;
}

export function FolderHover({
  folders,
  fan = 18,
  lift = 24,
  duration = 0.6,
  ease = "back.out(1.4)",
  color = "#f5e04a",
  colorMode = "own",
  paper = "#ece8e1",
  ink = "#1a1a1a",
  perRow = 3,
  folderHeight = 200,
  defaultOpen = null,
  className = "",
}: FolderHoverProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const pointerType = useRef("mouse");

  // The folder that is open right now. React state, so the colours and aria-expanded follow it.
  const [open, setOpen] = useState<number | null>(defaultOpen);
  // The folder that has keyboard focus (it gets a ring on its front, because the button itself stays put while the front lifts)
  const [focused, setFocused] = useState<number | null>(null);
  // How many folders fit in a row at the current width (measured below)
  const [columns, setColumns] = useState(perRow);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const measure = () => {
      const fit = Math.floor(root.clientWidth / MIN_FOLDER_WIDTH);
      setColumns(Math.max(1, Math.min(perRow, fit)));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    return () => observer.disconnect();
  }, [perRow]);

  // Everything is sized from the folder height, so a narrow box just uses a smaller one
  const height = columns >= 3 ? folderHeight : Math.round(folderHeight * 0.75);
  const tabHeight = Math.round(height * 0.24);
  const photoWidth = Math.round(height * 0.6);
  const photoHeight = Math.round(height * 0.88);
  const restTop = Math.round(height * 0.06);
  // Open: the photos end up with about 60% of their height above the lifted tab
  const openTop = -(tabHeight + lift) - photoHeight * 0.62;
  const travel = openTop - restTop;

  // Opens the folder that is `open` and closes the rest. It runs again whenever the open folder or a
  // setting changes, and every folder gets fresh tweens each time (`overwrite: true` removes the old
  // ones, even those still waiting out their delay), so quick hovering from folder to folder never
  // leaves a photo stranded. A plain effect instead of useGSAP: useGSAP would either revert the
  // photos to the start on every change or keep every old tween around.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    // With reduced motion the folder still opens, it just does it in one step
    const time = reduced ? 0 : duration;
    // Found by data attributes, so a folder that moved to another row is still found
    const panels = Array.from(root.querySelectorAll<HTMLElement>("[data-panel]"));
    const photos = Array.from(root.querySelectorAll<HTMLElement>("[data-photo]"));

    root.querySelectorAll<HTMLElement>("[data-folder]").forEach((folderElement) => {
      const isOpen = Number(folderElement.dataset.folder) === open;
      const panel = folderElement.querySelector<HTMLElement>("[data-panel]");
      if (panel) {
        gsap.to(panel, { y: isOpen ? -lift : 0, duration: time * (isOpen ? 0.7 : 0.6), ease: "power3.out", overwrite: true });
      }
      folderElement.querySelectorAll<HTMLElement>("[data-photo]").forEach((photo, slot) => {
        const [low, high] = SLOTS[slot]!.tilt;
        gsap.to(photo, {
          y: isOpen ? travel : 0,
          rotation: isOpen ? gsap.utils.random(low * fan, high * fan) : 0,
          duration: isOpen ? time : time * 0.7,
          // Slide out with a little overshoot, tuck back without one
          ease: isOpen ? ease : "power2.inOut",
          // The photos leave in order and return in order
          delay: time * (isOpen ? 0.06 : 0.04) * slot,
          overwrite: true,
        });
        // Closed photos are hidden outright. Their shadows would otherwise leak out of a narrow folder
        // onto its neighbour. Fading is quick and shares the photo's delay, so it never shows mid-rise.
        gsap.to(photo, {
          autoAlpha: isOpen ? 1 : 0,
          duration: time === 0 ? 0 : 0.12,
          delay: isOpen ? time * 0.06 * slot : time * (0.04 * slot + 0.6),
          ease: "none",
          overwrite: "auto",
        });
      });
    });

    return () => {
      gsap.killTweensOf([...panels, ...photos]);
    };
  }, [open, fan, lift, duration, ease, travel, folders.length, columns]);

  const openFolder = (index: number) => setOpen(index);
  const closeFolder = (index: number) => setOpen((current) => (current === index ? null : current));

  const rows = toRows(folders.length, columns);

  return (
    <div
      ref={rootRef}
      // The side gutter leaves room for the photos an edge folder fans out
      className={`relative flex h-full w-full flex-col justify-end overflow-hidden px-7 md:px-12 ${className}`}
      style={{ containerType: "inline-size" }}
      onPointerDown={(event) => {
        pointerType.current = event.pointerType;
      }}
      onClick={(event) => {
        // A tap on the empty page closes the open folder on touch screens
        if (event.target === event.currentTarget && pointerType.current === "touch") setOpen(null);
      }}
    >
      {rows.map((row, rowIndex) => (
        <div key={rowIndex} className="flex w-full">
          {row.map((index) => {
            const folder = folders[index]!;
            const base = colorMode === "single" ? color : (folder.color ?? FALLBACK_COLOURS[index % FALLBACK_COLOURS.length]!);
            const isOpen = open === index;
            // While another folder is open this one fades toward the page colour
            const faded = open !== null && !isOpen;
            const fill = faded ? `color-mix(in srgb, ${base} 18%, ${paper})` : base;
            const textColor = faded ? `color-mix(in srgb, ${ink} 38%, color-mix(in srgb, ${base} 18%, ${paper}))` : FOLDER_INK;
            const slide = `background-color ${Math.max(duration * 0.5, 0.15)}s ease, color ${Math.max(duration * 0.5, 0.15)}s ease`;
            const alts = folder.alts ?? [];

            return (
              <div key={index} data-folder={index} className="relative min-w-0 flex-1" style={{ height }}>
                {/* The photos sit behind the front of the folder until it opens. `isolate` keeps their z-index inside this box, so the front always covers them. */}
                <div aria-hidden={!isOpen} className="pointer-events-none absolute top-0 isolate h-full" style={{ left: 10, width: "min(22rem, calc(100% - 20px))" }}>
                  {SLOTS.map((slot, photoIndex) => {
                    const src = folder.images[photoIndex];
                    if (!src) return null;
                    return (
                      <div
                        key={photoIndex}
                        data-photo={photoIndex}
                        className="invisible absolute overflow-hidden rounded-[3px] border-[3px] border-[#f4f1ea] bg-[#f4f1ea] shadow-[0_14px_28px_-10px_rgba(0,0,0,0.45)]"
                        style={{
                          // 24% of the box to each side, but never so far that a photo sticks out of a narrow folder
                          left: `calc(50% + ${slot.side} * min(24%, 50% - ${photoWidth / 2 + 14}px))`,
                          top: restTop,
                          width: photoWidth,
                          height: photoHeight,
                          marginLeft: -photoWidth / 2,
                          transformOrigin: "50% 100%",
                          // The middle photo sits on top of the other two
                          zIndex: photoIndex === 1 ? 2 : 1,
                          willChange: "transform",
                        }}
                      >
                        <img src={src} alt={alts[photoIndex] ?? ""} className="block h-full w-full object-cover" draggable={false} decoding="async" />
                      </div>
                    );
                  })}
                </div>

                {/* The front of the folder and its tab (the tab overlaps the front by a pixel so no seam shows the photos). It reaches `lift` pixels below the folder so nothing shows underneath when it rises. */}
                <div
                  data-panel=""
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-x-0 top-0"
                  style={{ bottom: -lift, backgroundColor: fill, color: textColor, transition: slide, willChange: "transform", boxShadow: focused === index ? `inset 0 0 0 3px ${FOLDER_INK}` : "none" }}
                >
                  <span
                    className="absolute left-0"
                    style={{ top: -tabHeight, height: tabHeight + 1, width: "min(360px, 100%)", backgroundColor: "inherit", clipPath: "polygon(0 0, 78% 0, 100% 100%, 0 100%)" }}
                  />
                  <span className="absolute text-[11px] font-semibold tracking-[0.06em]" style={{ left: 16, top: -tabHeight * 0.58 }}>
                    {folder.label ?? String(index + 1).padStart(2, "0")}
                  </span>
                  <div className="font-semibold tracking-[-0.01em]" style={{ padding: "12px 16px", fontSize: "clamp(1.25rem, 2.6cqw, 2.25rem)", lineHeight: 1.1 }}>
                    {folder.title}
                  </div>
                </div>

                {/* The real control: it covers the tab and the front, so pointing at the tab counts too */}
                <button
                  type="button"
                  aria-expanded={isOpen}
                  className="absolute right-0 bottom-0 left-0 cursor-pointer border-0 bg-transparent p-0 outline-none"
                  style={{ top: -(tabHeight + lift + 4) }}
                  onPointerEnter={(event) => {
                    if (event.pointerType !== "touch") openFolder(index);
                  }}
                  onPointerLeave={(event) => {
                    if (event.pointerType !== "touch") closeFolder(index);
                  }}
                  onClick={() => {
                    // On touch there is no hover, so a tap opens it and another tap closes it
                    if (pointerType.current === "touch") setOpen((current) => (current === index ? null : index));
                  }}
                  onFocus={(event) => {
                    // Only keyboard focus opens it (a mouse click focuses too, and hover already handles that)
                    if (!event.currentTarget.matches(":focus-visible")) return;
                    setFocused(index);
                    openFolder(index);
                  }}
                  onBlur={() => {
                    setFocused((current) => (current === index ? null : current));
                    closeFolder(index);
                  }}
                  onKeyDown={(event) => {
                    if (event.key === "Escape") closeFolder(index);
                  }}
                >
                  <span className="sr-only">{folder.title}</span>
                </button>
              </div>
            );
          })}
        </div>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { FolderHover, type FolderHoverProps, type FolderItem } from "@/components/dimaac/folder-hover";

// Five folders of work, each fanning three photos. Photos: Lummi (Lummi License), see meta.credits.

const folders: FolderItem[] = [
  {
    title: "Branding",
    label: "01",
    color: "#f5e04a",
    images: ["https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg", "https://ui.dimaac.com/media/objects/blue-bottles-4x5.jpg", "https://ui.dimaac.com/media/objects/skincare-tube-4x5.jpg"],
    alts: ["An amber glass perfume bottle", "Perfume bottles with blue liquid", "A skincare tube among soft flowers"],
  },
  {
    title: "Motion design",
    label: "02",
    color: "#d4d4d4",
    images: ["https://ui.dimaac.com/media/portraits/light-streaks-4x5.jpg", "https://ui.dimaac.com/media/fashion/mid-air-suit-4x5.jpg", "https://ui.dimaac.com/media/art/red-gradient-4x5.jpg"],
    alts: ["A close-up face crossed by streaks of coloured light", "A person in a black suit caught in mid-air", "A red to pink gradient sky over dark mountains"],
  },
  {
    title: "UI & UX",
    label: "03",
    color: "#d9cdb8",
    images: ["https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg", "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg", "https://ui.dimaac.com/media/architecture/corrugated-facade-4x5.jpg"],
    alts: ["Yellow and beige walls under a pale sky", "Pastel minimalist architecture", "A corrugated facade with greenery in front"],
  },
  {
    title: "Graphics",
    label: "04",
    color: "#9c9c9c",
    images: ["https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg", "https://ui.dimaac.com/media/portraits/red-brushstroke-4x5.jpg", "https://ui.dimaac.com/media/fashion/orange-ring-4x5.jpg"],
    alts: ["Colourful cylinders on a geometric background", "A portrait with a red brushstroke across the eyes", "A portrait framed through an orange ring"],
  },
  {
    title: "3D Animation",
    label: "05",
    color: "#ffcc4d",
    images: ["https://ui.dimaac.com/media/art/floating-spheres-4x5.jpg", "https://ui.dimaac.com/media/art/organic-forms-4x5.jpg", "https://ui.dimaac.com/media/nature/coral-texture-4x5.jpg"],
    alts: ["A textured grey sphere with floating colour", "Abstract sculptural forms", "A close-up of an intricate natural texture"],
  },
];

// The page colour and text colour flip with the site theme. The folders keep their own paper colours.
export default function Demo(props: Partial<FolderHoverProps>) {
  return (
    <div className="relative h-full w-full overflow-hidden bg-[#ece8e1] font-sans text-[#1a1a1a] [--stage-ink:#1a1a1a] [--stage-paper:#ece8e1] dark:bg-[#13110f] dark:text-[#ece6de] dark:[--stage-ink:#ece6de] dark:[--stage-paper:#13110f]">
      <div className="pointer-events-none absolute inset-x-6 top-[10%] z-20 md:inset-x-10">
        <h1 className="max-w-[14ch] text-[clamp(40px,7.2vw,112px)] leading-[0.95] font-semibold tracking-[-0.045em]">Open a folder</h1>
        <p className="mt-4 max-w-[34ch] text-[15px] opacity-60">
          <span className="hidden [@media(hover:hover)]:inline">Point at a folder to open it.</span>
          <span className="[@media(hover:hover)]:hidden">Tap a folder to open it.</span>
        </p>
      </div>

      <FolderHover folders={folders} defaultOpen={2} {...props} paper="var(--stage-paper)" ink="var(--stage-ink)" />
    </div>
  );
}
```

## Agent prompt

```text
Add the Folder Hover Tabs component (<FolderHover />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/folder-hover
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/folder-hover.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/folder-hover.json

Then use it like this:
import { FolderHover } from "@/components/dimaac/folder-hover";
<FolderHover folders={folders} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/folder-hover.tsx and install gsap.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Rotating Image Gallery
description: A ring of photos you spin with a drag, a swipe or the arrow keys.
category: Gallery
tier: free
url: https://ui.dimaac.com/components/image-gallery
registry: https://ui.dimaac.com/r/image-gallery.json
---

# Rotating Image Gallery

A flat circular gallery. The photos sit on an invisible circle (GSAP MotionPath places them and turns each one along the curve) and the whole ring is a single element, so turning it is one rotation. Drag it from anywhere in the box and let go: InertiaPlugin throws it and it slows down on its own, or snaps to a photo if you ask. It also turns with the arrow keys, a sideways trackpad swipe, and slowly by itself while nobody touches it. The page still scrolls past it: the vertical wheel and, on touch screens, an up or down swipe are left alone unless you turn that off. Photos can face outward with the ring or stay upright as it turns. Ported from the first DiMaac UI and rebuilt without next/image, with every animation cleaned up on unmount, a ring that re-places itself when resized, and a still, draggable ring when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/image-gallery.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/image-gallery.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/image-gallery.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/image-gallery`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/image-gallery.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ImageGallery } from "@/components/dimaac/image-gallery" and give its parent a size: it fills it.

## Usage

```tsx
import { ImageGallery } from "@/components/dimaac/image-gallery";

<ImageGallery images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `GalleryImage[]` | - | The photos, placed around the ring in this order. Six to twelve look best. |
| `ringSize` | `number` | `74` | How big the ring is, as a percentage of the smaller side of the box. |
| `tileScale` | `number` | `1` | How big the photos are. 1 is as big as they can be without touching, above 1 they overlap like a fan. |
| `tileShape` | `"square" \| "portrait" \| "landscape"` | `"portrait"` | The shape of each photo: square, portrait (4:5) or landscape (4:3). |
| `radius` | `number` | `10` | Corner radius of each photo, in pixels. |
| `tileMode` | `"ring" \| "upright"` | `"ring"` | "ring" turns every photo with the ring, heads outward. "upright" keeps each photo level while the ring turns. |
| `autoSpin` | `number` | `5` | Idle spin in degrees per second. 0 keeps the ring still until touched. Negative spins the other way. |
| `pauseOnHover` | `boolean` | `true` | Stop the idle spin while a mouse pointer is over one of the photos. |
| `wheel` | `"horizontal" \| "always" \| "off"` | `"horizontal"` | What the mouse wheel does: "horizontal" turns on a sideways swipe only (the page still scrolls), "always" turns on any wheel movement and traps the page scroll, "off" ignores the wheel. |
| `snap` | `boolean` | `false` | After a drag or a key press, come to rest with a photo exactly at the top. |
| `touchScroll` | `boolean` | `true` | On touch screens, let an up or down swipe scroll the page. Turn off if the gallery is the whole screen. |
| `intro` | `boolean` | `true` | Spin the ring in when it appears. |
| `imageSize` | `number` | `400` | The size in pixels the image tags are given as width and height. Set it to the real size of your files. |
| `circleSize` | `number` | `400` | The size of the drawing area of the circle the photos are placed on. Only its shape matters, so leave it at 400. |
| `label` | `string` | `"Image gallery"` | The accessible name of the gallery. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |

## Credits

- Photo: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Photo: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Photo: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License
- Photo: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Photo: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Photo: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Photo: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License
- Photo: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/chic-fashion-portrait-wojpk), Lummi License
- Photo: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Photo: Bernardo Lickfold, Lummi (https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i), Lummi License

## Source

### image-gallery.tsx → `components/dimaac/image-gallery.tsx`

```tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { Draggable } from "gsap/Draggable";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
import { useGSAP } from "@gsap/react";

if (typeof window !== "undefined") gsap.registerPlugin(Draggable, InertiaPlugin, MotionPathPlugin, useGSAP);

// A ring of photos you can spin. Every photo sits on an invisible circle (MotionPath puts it there
// and turns it to follow the curve), and the whole ring is one element, so spinning it is a single
// rotation. Drag it and let go: InertiaPlugin throws it and it slows down by itself. It also turns
// with the arrow keys, with a sideways trackpad swipe, and slowly on its own while nobody touches it.

export type GalleryImage = {
  /** The image address. */
  src: string;
  /** What the picture shows, for screen readers. */
  alt: string;
};

export type ImageGalleryProps = {
  /** The photos, placed around the ring in this order. Six to twelve look best. */
  images: GalleryImage[];
  /** How big the ring is, as a percentage of the smaller side of the box. */
  ringSize?: number;
  /** How big the photos are. 1 is as big as they can be without touching, above 1 they overlap like a fan. */
  tileScale?: number;
  /** The shape of each photo: square, portrait (4:5) or landscape (4:3). */
  tileShape?: "square" | "portrait" | "landscape";
  /** Corner radius of each photo, in pixels. */
  radius?: number;
  /** "ring" turns every photo with the ring, heads outward. "upright" keeps each photo level while the ring turns. */
  tileMode?: "ring" | "upright";
  /** Idle spin in degrees per second. 0 keeps the ring still until touched. Negative spins the other way. */
  autoSpin?: number;
  /** Stop the idle spin while a mouse pointer is over one of the photos. */
  pauseOnHover?: boolean;
  /** What the mouse wheel does: "horizontal" turns on a sideways swipe only (the page still scrolls), "always" turns on any wheel movement and traps the page scroll, "off" ignores the wheel. */
  wheel?: "horizontal" | "always" | "off";
  /** After a drag or a key press, come to rest with a photo exactly at the top. */
  snap?: boolean;
  /** On touch screens, let an up or down swipe scroll the page. Turn off if the gallery is the whole screen. */
  touchScroll?: boolean;
  /** Spin the ring in when it appears. */
  intro?: boolean;
  /** The size in pixels the image tags are given as width and height. Set it to the real size of your files. */
  imageSize?: number;
  /** The size of the drawing area of the circle the photos are placed on. Only its shape matters, so leave it at 400. */
  circleSize?: number;
  /** The accessible name of the gallery. */
  label?: string;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// How much taller than wide each shape is
const SHAPES = { square: 1, portrait: 1.25, landscape: 0.75 } as const;

// Width of one photo as a percentage of the ring's box. At a tileScale of 1 the photos just touch
// their neighbours at their inner edge (the inner edge is the tight side: the circle is smaller
// there). A bigger scale lets them overlap like a fan. It is shrunk if a photo would poke out
// past the edge of the box.
function tileWidth(count: number, ringSize: number, tileScale: number, ratio: number, upright: boolean) {
  // The gap between two neighbours on a circle of radius 1 is 2 * sin(PI / count)
  const around = count < 3 ? 0.5 : Math.min(Math.sin(Math.PI / count), 0.55);
  const fit = upright ? ringSize * around * 0.78 : (ringSize * around * 0.96) / (1 + around * ratio);
  let width = fit * tileScale;
  const reach = upright ? Math.hypot(width, width * ratio) / 2 : (width * ratio) / 2;
  const available = 49 - ringSize / 2;
  // Never shrink below 55% of the wanted size, so the photo size control always does something
  if (reach > available) width *= Math.max(available / reach, 0.55);
  return Math.max(width, 3);
}

export function ImageGallery({
  images,
  ringSize = 74,
  tileScale = 1,
  tileShape = "portrait",
  radius = 10,
  tileMode = "ring",
  autoSpin = 5,
  pauseOnHover = true,
  wheel = "horizontal",
  snap = false,
  touchScroll = true,
  intro = true,
  imageSize = 400,
  circleSize = 400,
  label = "Image gallery",
  className = "",
}: ImageGalleryProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const ringRef = useRef<HTMLDivElement>(null);
  const pathRef = useRef<SVGPathElement>(null);
  const tileRefs = useRef<(HTMLDivElement | null)[]>([]);
  const count = images.length;
  const upright = tileMode === "upright";
  const ratio = SHAPES[tileShape];
  const width = tileWidth(count, ringSize, tileScale, ratio, upright);

  // Read on every frame or event, so changing them doesn't rebuild anything
  const live = useRef({ autoSpin, pauseOnHover, wheel });
  live.current = { autoSpin, pauseOnHover, wheel };
  // Where the ring is turned to, kept so a control change (which rebuilds the ring) doesn't snap it back to 0
  const savedRotation = useRef(0);

  // 1. Put every photo on the circle. Runs again when the layout changes or the box is resized,
  //    because MotionPath turns the circle into pixel positions.
  useGSAP(
    () => {
      const ring = ringRef.current;
      const path = pathRef.current;
      const tiles = tileRefs.current.slice(0, count).filter((tile): tile is HTMLDivElement => tile !== null);
      if (!ring || !path || tiles.length === 0) return;

      const place = () => {
        tiles.forEach((tile, index) => {
          gsap.set(tile, { x: 0, y: 0 });
          gsap.set(tile, {
            motionPath: {
              path,
              align: path,
              alignOrigin: [0.5, 0.5],
              // The circle starts at 3 o'clock; +0.75 puts the first photo at 12 o'clock
              start: 0,
              end: (index / tiles.length + 0.75) % 1,
              autoRotate: !upright,
            },
            visibility: "visible",
          });
        });
      };
      place();

      let lastWidth = ring.offsetWidth;
      const resize = new ResizeObserver(() => {
        if (Math.abs(ring.offsetWidth - lastWidth) < 0.5) return;
        lastWidth = ring.offsetWidth;
        place();
      });
      resize.observe(ring);
      return () => resize.disconnect();
    },
    { scope: rootRef, dependencies: [count, ringSize, tileScale, tileShape, tileMode, circleSize], revertOnUpdate: true },
  );

  // 2. Make the ring turn: drag with inertia, wheel, arrow keys and the idle spin
  useGSAP(
    () => {
      const root = rootRef.current;
      const ring = ringRef.current;
      const tiles = tileRefs.current.slice(0, count).filter((tile): tile is HTMLDivElement => tile !== null);
      if (!root || !ring || count === 0) return;

      const mm = gsap.matchMedia();
      mm.add({ reduce: "(prefers-reduced-motion: reduce)", normal: "(prefers-reduced-motion: no-preference)" }, (context) => {
        const reduce = Boolean(context.conditions?.reduce);
        const step = 360 / count;
        const setRotation = gsap.quickSetter(ring, "rotation", "deg");
        const setTileRotations = tiles.map((tile) => gsap.quickSetter(tile, "rotation", "deg"));
        // Carry the turn over from before the rebuild
        setRotation(savedRotation.current);

        let pressed = false;
        let hovering = false;
        let onScreen = true;
        let idleSeconds = 0;
        // Where a wheel or key turn is heading, so quick repeats add up instead of restarting
        let heading: number | null = null;
        let lastRotation = Number.NaN;

        const rotationNow = () => Number(gsap.getProperty(ring, "rotation"));

        // In "upright" mode every photo turns the opposite way to the ring, so it stays level
        const keepUpright = () => {
          const rotation = rotationNow();
          savedRotation.current = rotation;
          if (!upright) return;
          if (rotation === lastRotation) return;
          lastRotation = rotation;
          setTileRotations.forEach((set) => set(-rotation));
        };
        keepUpright();

        const turnTo = (rotation: number) => {
          heading = rotation;
          if (reduce) {
            gsap.killTweensOf(ring);
            setRotation(rotation);
            heading = null;
            keepUpright();
            return;
          }
          gsap.to(ring, {
            rotation,
            duration: 0.8,
            ease: "power3.out",
            overwrite: true,
            onUpdate: keepUpright,
            onComplete: () => {
              heading = null;
            },
          });
        };
        const turnBy = (degrees: number) => turnTo((heading ?? rotationNow()) + degrees);

        const drag = Draggable.create(ring, {
          type: "rotation",
          // Dragging works from anywhere in the box, not only on the ring's own square
          trigger: root,
          inertia: !reduce,
          snap: snap ? (value: number) => Math.round(value / step) * step : undefined,
          cursor: "grab",
          activeCursor: "grabbing",
          zIndexBoost: false,
          onPress: () => {
            pressed = true;
            heading = null;
            gsap.killTweensOf(ring);
          },
          onRelease: () => {
            pressed = false;
          },
          onDrag: keepUpright,
          onThrowUpdate: keepUpright,
        })[0];
        if (!drag) return;
        // Draggable switches touch scrolling off for rotation. Hand the up and down swipe back to
        // the page, so only a sideways swipe turns the ring.
        if (touchScroll) root.style.touchAction = "pan-y";

        const onWheel = (event: WheelEvent) => {
          const mode = live.current.wheel;
          if (mode === "off" || pressed) return;
          const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY);
          // In the default mode a plain vertical wheel is left alone, so the page can scroll past
          if (mode === "horizontal" && !sideways) return;
          event.preventDefault();
          turnBy((sideways ? event.deltaX : event.deltaY) * 0.4);
        };

        const onKeyDown = (event: KeyboardEvent) => {
          const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
          const back = event.key === "ArrowLeft" || event.key === "ArrowUp";
          if (!forward && !back) return;
          event.preventDefault();
          // Always land on a whole step, so the arrow keys move one photo at a time
          const from = heading ?? rotationNow();
          turnTo(Math.round(from / step) * step + (forward ? step : -step));
        };

        // Hovering means a mouse pointer is over one of the photos (not the empty space around them)
        const onOver = (event: PointerEvent) => {
          hovering = event.pointerType !== "touch" && event.target instanceof Element && event.target.closest("[role=listitem]") !== null;
        };
        const onLeave = () => {
          hovering = false;
        };

        root.addEventListener("wheel", onWheel, { passive: false });
        root.addEventListener("keydown", onKeyDown);
        ring.addEventListener("pointerover", onOver);
        root.addEventListener("pointerleave", onLeave);

        const visibility = new IntersectionObserver(([entry]) => {
          onScreen = entry?.isIntersecting ?? true;
        });
        visibility.observe(root);

        // The idle spin. It waits a moment after any touch, then eases up to speed.
        const spin = (_time: number, deltaMs: number) => {
          const seconds = Math.min(deltaMs, 100) / 1000;
          const busy = pressed || drag.isThrowing || heading !== null || !onScreen || (hovering && live.current.pauseOnHover);
          if (busy) {
            idleSeconds = 0;
            return;
          }
          idleSeconds += seconds;
          const speed = live.current.autoSpin;
          if (reduce || speed === 0) return;
          const ramp = Math.min(1, Math.max(0, (idleSeconds - 0.8) / 1.5));
          setRotation(rotationNow() + speed * seconds * ramp * ramp * (3 - 2 * ramp));
          keepUpright();
        };
        gsap.ticker.add(spin);

        return () => {
          gsap.ticker.remove(spin);
          visibility.disconnect();
          root.removeEventListener("wheel", onWheel);
          root.removeEventListener("keydown", onKeyDown);
          ring.removeEventListener("pointerover", onOver);
          root.removeEventListener("pointerleave", onLeave);
          drag.kill();
          gsap.killTweensOf(ring);
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [count, tileMode, snap, touchScroll], revertOnUpdate: true },
  );

  // 3. The way in: the ring unwinds into place while the photos pop up one by one
  useGSAP(
    () => {
      const ring = ringRef.current;
      const tiles = tileRefs.current.slice(0, count).filter((tile): tile is HTMLDivElement => tile !== null);
      if (!intro || !ring || tiles.length === 0) return;
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        gsap.from(ring, { rotation: -140, duration: 2.4, ease: "expo.out" });
        gsap.from(tiles, { scale: 0.3, opacity: 0, duration: 1.1, stagger: 0.07, ease: "back.out(1.5)" });
      });
      return () => {
        mm.revert();
        // Undoing the intro puts the ring back where it began, so hand it the turn it had
        gsap.set(ring, { rotation: savedRotation.current });
      };
    },
    { scope: rootRef, dependencies: [count, intro], revertOnUpdate: true },
  );

  const half = circleSize / 2;
  const circle = `M${circleSize},${half} A${half},${half} 0 1 1 0,${half} A${half},${half} 0 1 1 ${circleSize},${half}Z`;

  return (
    <div
      ref={rootRef}
      role="group"
      aria-roledescription="rotating gallery"
      aria-label={label}
      aria-keyshortcuts="ArrowLeft ArrowRight"
      tabIndex={0}
      className={`relative h-full w-full overflow-hidden select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current ${className}`}
      style={{ containerType: "size", touchAction: touchScroll ? "pan-y" : "none" }}
    >
      <div className="absolute inset-0 flex items-center justify-center">
        {/* The ring: a square as big as the box allows. Rotating it turns every photo. */}
        <div ref={ringRef} role="list" className="relative shrink-0" style={{ width: "min(100cqw, 100cqh)", height: "min(100cqw, 100cqh)" }}>
          <svg
            viewBox={`0 0 ${circleSize} ${circleSize}`}
            aria-hidden="true"
            className="pointer-events-none absolute opacity-0"
            style={{ width: `${ringSize}%`, height: `${ringSize}%`, left: `${(100 - ringSize) / 2}%`, top: `${(100 - ringSize) / 2}%` }}
          >
            <path ref={pathRef} d={circle} fill="none" stroke="none" />
          </svg>

          {images.map((image, index) => (
            <div
              key={`${index}-${image.src}`}
              ref={(tile) => {
                tileRefs.current[index] = tile;
              }}
              role="listitem"
              className="group absolute top-0 left-0"
              style={{ width: `${width}%`, aspectRatio: `1 / ${ratio}`, visibility: "hidden", willChange: "transform" }}
            >
              <div
                className="h-full w-full overflow-hidden shadow-[0_18px_38px_-18px_rgba(0,0,0,0.45)] outline-1 -outline-offset-1 outline-black/10 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.06] dark:outline-white/10"
                style={{ borderRadius: radius }}
              >
                <img
                  src={image.src}
                  alt={image.alt}
                  width={imageSize}
                  height={Math.round(imageSize * ratio)}
                  loading={index < 3 ? "eager" : "lazy"}
                  decoding="async"
                  draggable={false}
                  className="block h-full w-full object-cover object-[50%_25%]"
                />
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { ImageGallery, type GalleryImage, type ImageGalleryProps } from "@/components/dimaac/image-gallery";

// Ten photos from our own media set (Lummi License). Colours alternate warm and cool, so the ring
// has a rhythm as it turns.
const photos: GalleryImage[] = [
  { src: "https://ui.dimaac.com/media/portraits/tinted-glasses-4x5.jpg", alt: "Woman in purple-tinted sunglasses against an orange glow" },
  { src: "https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg", alt: "Profile of a woman in neon pink sunglasses on a blue background" },
  { src: "https://ui.dimaac.com/media/fashion/gold-visor-4x5.jpg", alt: "Profile with a gold headpiece on a yellow background" },
  { src: "https://ui.dimaac.com/media/portraits/red-coat-4x5.jpg", alt: "Person in a red coat against deep blue" },
  { src: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg", alt: "Person in a lime green blazer on a pink background" },
  { src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg", alt: "Woman in an orange visor on a teal background" },
  { src: "https://ui.dimaac.com/media/fashion/chunky-knit-4x5.jpg", alt: "Model in a chunky knit sweater and coral boots" },
  { src: "https://ui.dimaac.com/media/fashion/purple-hoodie-4x5.jpg", alt: "Person in a purple tie-dye hoodie in a concrete room" },
  { src: "https://ui.dimaac.com/media/portraits/platinum-blonde-4x5.jpg", alt: "Young man with platinum blonde hair in a yellow jacket" },
  { src: "https://ui.dimaac.com/media/fashion/orange-ring-4x5.jpg", alt: "Young man framed through an orange circle" },
];

// On a phone the ring is small, so it gets two photos fewer and each one is bigger
const skippedOnPhone = [6, 9];

export default function Demo(props: Partial<ImageGalleryProps>) {
  const [phone, setPhone] = useState(false);

  useEffect(() => {
    const query = window.matchMedia("(max-width: 767px)");
    const update = () => setPhone(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);

  const shown = phone ? photos.filter((_, index) => !skippedOnPhone.includes(index)) : photos;

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <div className="pointer-events-none absolute inset-0 z-0 flex flex-col items-center justify-center gap-3 px-6 text-center">
        <h1 className="text-[clamp(30px,min(6.4vw,9vh),84px)] leading-[0.95] font-medium tracking-[-0.04em] opacity-90">
          Turn it,
          <br />
          slowly
        </h1>
        <p className="text-[13px] opacity-55">
          <span className="md:hidden">Drag or swipe</span>
          <span className="hidden md:inline">Drag, swipe or use the arrow keys</span>
        </p>
      </div>

      <ImageGallery images={shown} {...props} className={`relative z-10 ${props.className ?? ""}`} />
    </div>
  );
}
```

## Agent prompt

```text
Add the Rotating Image Gallery component (<ImageGallery />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/image-gallery
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/image-gallery.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/image-gallery.json

Then use it like this:
import { ImageGallery } from "@/components/dimaac/image-gallery";
<ImageGallery images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/image-gallery.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Image Loader
description: A counter climbs to 100 while photos open in a stack and the last one fills the screen.
category: Loaders
tier: free
url: https://ui.dimaac.com/components/image-loader
registry: https://ui.dimaac.com/r/image-loader.json
---

# Image Loader

A full-screen intro loader. A big rolling counter climbs from 0 to 100 while photos clip open one after another in a tilted stack. At 100 the last photo opens as a window in the middle, grows to fill the whole box and a headline slides up over it, word by word. It is one GSAP timeline with a single duration to tune, sizes itself from its own box (so it fits any container, not just the window), waits for the photos to decode before it starts, and skips straight to the finished hero when the visitor prefers reduced motion. Built for the last chapter of the GSAP crash course.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/image-loader.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/image-loader.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/image-loader.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/image-loader`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/image-loader.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ImageLoader } from "@/components/dimaac/image-loader" and give its parent a size: it fills it.

## Usage

```tsx
import { ImageLoader } from "@/components/dimaac/image-loader";

<ImageLoader images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `string[]` | - | The photos. All but the last one open in the stack, the last one grows into the full-screen hero. Two or more look best. |
| `duration` | `number` | `3.2` | How long the counter takes to climb from 0 to 100, in seconds. The reveal after it adds about two more. |
| `counterDigits` | `boolean` | `true` | Show the rolling counter (and the label above it). Turn it off for a counter-free photo intro. |
| `clipFrom` | `"bottom" \| "center"` | `"center"` | Where each photo starts opening from: "center" splits open from the middle line, "bottom" wipes up from its bottom edge. |
| `heroTitle` | `string` | `"Welcome in"` | The headline that slides up over the hero photo. |
| `heroAlt` | `string` | `""` | Describes the hero photo for screen readers. Empty means the photo is decoration. |
| `label` | `string` | `"Loading"` | The small word in the top corner while it loads. Empty hides it. |
| `onComplete` | `() => void` | - | Called once the headline has landed (or straight away when the visitor prefers reduced motion). |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills its parent). Add `bg-...!` to change the paper colour. |

## Credits

- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Photo: SHIHO, Lummi (https://www.lummi.ai/photo/minimalist-rustic-interior-qppyl), Lummi License
- Photo: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License
- Photo: Siren Media, Lummi (https://www.lummi.ai/photo/minimalist-stucco-building-gurez), Lummi License
- Photo: Jupiter et Passagère®, Lummi (https://www.lummi.ai/photo/modern-minimalist-building-kumrj), Lummi License
- Photo: Dona Mara, Lummi (https://www.lummi.ai/photo/abstract-sculptural-objects-flp-3), Lummi License
- Hero photo: Siren Media, Lummi (https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl), Lummi License

## Source

### image-loader.tsx → `components/dimaac/image-loader.tsx`

```tsx
"use client";

import { useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A full-screen intro loader. A big counter climbs from 0 to 100 while photos clip open one on top
// of the other in a tilted stack. When the counter reaches 100 the last photo opens as a window,
// grows to fill the whole box and a headline slides up over it.
//
// The whole thing is one GSAP timeline. Read it from top to bottom: every line says when something
// happens (the last number is the time in seconds, or a label like "reveal").

export type ImageLoaderProps = {
  /** The photos. All but the last one open in the stack, the last one grows into the full-screen hero. Two or more look best. */
  images: string[];
  /** How long the counter takes to climb from 0 to 100, in seconds. The reveal after it adds about two more. */
  duration?: number;
  /** Show the rolling counter (and the label above it). Turn it off for a counter-free photo intro. */
  counterDigits?: boolean;
  /** Where each photo starts opening from: "center" splits open from the middle line, "bottom" wipes up from its bottom edge. */
  clipFrom?: "bottom" | "center";
  /** The headline that slides up over the hero photo. */
  heroTitle?: string;
  /** Describes the hero photo for screen readers. Empty means the photo is decoration. */
  heroAlt?: string;
  /** The small word in the top corner while it loads. Empty hides it. */
  label?: string;
  /** Called once the headline has landed (or straight away when the visitor prefers reduced motion). */
  onComplete?: () => void;
  /** Extra classes for the outer box. Give it a size (it fills its parent). Add `bg-...!` to change the paper colour. */
  className?: string;
};

// How far each photo in the stack leans, in degrees. Used in turn, so any number of photos works.
const tilts = [-6, 4.5, -3.5, 5.5, -4.5, 2.5];

// Digits for the rolling columns. Each column is a tall strip of numbers that slides up behind a
// one-digit window. The ones column rolls through 20 digits, the tens through 10, the hundreds once.
const hundreds = [0, 1];
const tens = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
const ones = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

// The photo frame is 34 by 44 (portrait). On a narrow box it shrinks so it never touches the sides.
const FRAME_RATIO = 44 / 34;
const frameWidth = (width: number, height: number) => Math.min(height * 0.34, width * 0.72);

// A clip path that shows the rectangle between two corners. We use polygon() rather than inset()
// because a polygon always has eight distinct numbers to tween, and the browser never rewrites it
// into a shorter form (it does that for inset(), which breaks the tween halfway).
const rect = (left: number, top: number, right: number, bottom: number, unit: "px" | "%") =>
  `polygon(${left}${unit} ${top}${unit}, ${right}${unit} ${top}${unit}, ${right}${unit} ${bottom}${unit}, ${left}${unit} ${bottom}${unit})`;

const photoOpen = rect(0, 0, 100, 100, "%");

export function ImageLoader({
  images,
  duration = 3.2,
  counterDigits = true,
  clipFrom = "center",
  heroTitle = "Welcome in",
  heroAlt = "",
  label = "Loading",
  onComplete,
  className = "",
}: ImageLoaderProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  // Always the latest callback, so a new function from the parent doesn't restart the loader
  const onCompleteRef = useRef(onComplete);
  onCompleteRef.current = onComplete;

  const stack = images.slice(0, -1);
  const hero = images[images.length - 1];
  const words = heroTitle.split(" ").filter(Boolean);
  // Photos start closed: a thin line across the middle, or a line along the bottom edge
  const photoClosed = clipFrom === "bottom" ? rect(0, 100, 100, 100, "%") : rect(0, 50, 100, 50, "%");

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root || !hero) return;
      const q = gsap.utils.selector(root);
      const photos = q(".il-photo");
      const photoImages = q(".il-photo img");
      const heroBox = q(".il-hero")[0];
      const heroImage = q(".il-hero img")[0];
      const shade = q(".il-shade")[0];
      const title = q(".il-title")[0];
      const titleWords = q(".il-word");
      const digits = q(".il-digit");
      const labelText = q(".il-label");
      if (!heroBox || !heroImage || !shade || !title) return;

      const mm = gsap.matchMedia();

      // Reduced motion: no counter, no stack. The hero is simply there with its headline.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        gsap.set(photos, { autoAlpha: 0 });
        gsap.set(heroBox, { clipPath: "none" });
        gsap.set([...digits, ...labelText], { autoAlpha: 0 });
        gsap.set(shade, { opacity: 1 });
        gsap.set(title, { visibility: "visible" });
        gsap.set(titleWords, { yPercent: 0 });
        onCompleteRef.current?.();
      });

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        // The hero starts as a window the size of one photo frame. Clip paths only tween smoothly
        // between values in the same unit, so the frame is measured in pixels here.
        const width = root.clientWidth;
        const height = root.clientHeight;
        const frame = frameWidth(width, height);
        const left = (width - frame) / 2;
        const top = (height - frame * FRAME_RATIO) / 2;
        const windowOpen = rect(left, top, width - left, height - top, "px");
        const windowClosed = clipFrom === "bottom" ? rect(left, height - top, width - left, height - top, "px") : rect(left, height / 2, width - left, height / 2, "px");
        const fullBox = rect(0, 0, width, height, "px");

        gsap.set(title, { visibility: "visible" });
        gsap.set(titleWords, { yPercent: 130 });

        // The photos should be ready before anything moves, or they'd pop in half way through
        const ready = Promise.race([
          Promise.all(q("img").map((img) => (img as HTMLImageElement).decode().catch(() => undefined))),
          new Promise((resolve) => setTimeout(resolve, 4000)),
        ]);
        let cancelled = false;

        const tl = gsap.timeline({
          paused: true,
          onComplete: () => {
            // Done: let the hero follow the box when it is resized
            gsap.set(heroBox, { clipPath: "none" });
            onCompleteRef.current?.();
          },
        });

        // The counter climbs for `duration` seconds. Everything below hangs off that one number.
        const climb = Math.max(duration, 0.8);
        // The photos open one after another, spread over two thirds of the climb
        const spread = { amount: climb * 0.66, ease: "power1.out" };

        if (photos.length > 0) {
          tl.fromTo(photos, { clipPath: photoClosed }, { clipPath: photoOpen, duration: 0.8, ease: "power4.out", stagger: spread }, 0)
            // Each photo also settles from slightly zoomed in, as it opens
            .fromTo(photoImages, { scale: 1.3 }, { scale: 1, duration: 1, ease: "power3.out", stagger: spread }, 0);
        }

        // Rolling counter: ones and tens spin for the whole climb (starting slow, ending fast),
        // the hundreds ticks over to 1 in the last moment
        const rolling = digits.length > 0;
        if (rolling) {
          tl.to(q(".il-ones span"), { yPercent: -2000, duration: climb, ease: "power1.in" }, 0)
            .to(q(".il-tens span"), { yPercent: -1000, duration: climb, ease: "power1.in" }, 0)
            .to(q(".il-hundreds span"), { yPercent: -100, duration: 0.4, ease: "power2.in" }, climb - 0.4);
        }

        // Just before 100 the hero opens as a thin window in the middle of the stack
        tl.fromTo(heroBox, { clipPath: windowClosed }, { clipPath: windowOpen, duration: 0.8, ease: "power4.out" }, climb - 0.8);

        // The reveal. "reveal" is the moment the counter hits 100.
        tl.addLabel("reveal", climb)
          // The window grows to the whole box...
          .to(heroBox, { clipPath: fullBox, duration: 1.2, ease: "power2.inOut" }, "reveal")
          // ...while the photo inside it settles from a zoom
          .fromTo(heroImage, { scale: 1.25 }, { scale: 1, duration: 1.6, ease: "power2.out" }, "reveal");
        if (rolling) {
          // The digits slide up and out one after another, and the label fades
          tl.to(digits, { yPercent: -100, duration: 0.5, ease: "power2.in", stagger: 0.04 }, "reveal+=0.2").to(labelText, { opacity: 0, duration: 0.4 }, "reveal+=0.2");
        }
        // A soft shade under the headline so white text reads on any photo
        tl.to(shade, { opacity: 1, duration: 1 }, "reveal+=0.4")
          // The headline slides up out of its mask, one word after another
          .to(titleWords, { yPercent: 0, duration: 1, ease: "power4.out", stagger: 0.06 }, "reveal+=0.8");

        ready.then(() => {
          if (!cancelled) tl.play();
        });

        return () => {
          cancelled = true;
          tl.kill();
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [images.join("|"), duration, counterDigits, clipFrom, heroTitle, label], revertOnUpdate: true },
  );

  if (!hero) return null;

  return (
    <div
      ref={rootRef}
      className={`relative h-full w-full overflow-hidden bg-[#f4f4ef] text-[#0b1220] dark:bg-[#0c0f14] dark:text-[#f4f4ef] ${className}`}
      // A size container, so everything below can be sized in cqh/cqw (a share of THIS box,
      // not of the browser window). That is what lets the loader sit inside any container.
      style={{ containerType: "size", "--il-frame": "min(34cqh, 72cqw)" } as CSSProperties}
    >
      {counterDigits && (
        <>
          {label && (
            <p role="status" className="il-label absolute top-[4cqh] left-[4cqw] m-0 text-[2.4cqh] leading-none font-medium text-[#8a93a3]">
              {label}
            </p>
          )}
          <div
            aria-hidden="true"
            className="absolute bottom-[4cqh] left-[4cqw] flex h-[1em] overflow-hidden pr-[0.1em] text-[15cqh] leading-none font-semibold tracking-[-0.05em] tabular-nums"
          >
            {[
              { name: "il-hundreds", list: hundreds },
              { name: "il-tens", list: tens },
              { name: "il-ones", list: ones },
            ].map(({ name, list }) => (
              // One column. The extra padding and negative margin stop the digit's edges being clipped.
              <div key={name} className={`il-digit ${name} -mr-[0.1em] h-[1em] overflow-hidden pr-[0.1em]`}>
                {list.map((digit, index) => (
                  <span key={index} className="block h-[1em]">
                    {digit}
                  </span>
                ))}
              </div>
            ))}
          </div>
        </>
      )}

      {stack.map((src, index) => (
        <div
          key={`${index}-${src}`}
          aria-hidden="true"
          className="il-photo absolute inset-0 m-auto"
          style={{ width: "var(--il-frame)", aspectRatio: "34 / 44", rotate: `${tilts[index % tilts.length]}deg`, clipPath: photoClosed }}
        >
          <img src={src} alt="" draggable={false} className="block h-full w-full object-cover" />
        </div>
      ))}

      {/* The hero photo always fills the box. Its clip path is the window that opens and grows. */}
      <div className="il-hero absolute inset-0" style={{ clipPath: rect(50, 50, 50, 50, "%") }}>
        <img src={hero} alt={heroAlt} draggable={false} className="absolute inset-0 h-full w-full object-cover" />
        <div className="il-shade absolute inset-0 opacity-0" style={{ background: "linear-gradient(transparent 40%, rgba(11, 18, 32, 0.6))" }} />
      </div>

      <h1
        className="il-title absolute bottom-[6cqh] left-[4cqw] m-0 max-w-[6em] text-[min(11cqh,15cqw)] leading-[1.05] font-semibold tracking-[-0.045em] text-white"
        style={{ visibility: "hidden" }}
      >
        {words.map((word, index) => (
          // Each word sits in its own mask (overflow hidden), so it slides up out of nothing
          <span key={index}>
            <span className="-mx-[0.1em] -mt-[0.06em] -mb-[0.14em] inline-block overflow-hidden px-[0.1em] pt-[0.06em] pb-[0.14em] align-top">
              <span className="il-word inline-block">{word}</span>
            </span>
            {index < words.length - 1 ? " " : null}
          </span>
        ))}
      </h1>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { ImageLoader, type ImageLoaderProps } from "@/components/dimaac/image-loader";

// Quiet rooms in warm light: six photos for the stack, then the red-wall room as the hero.
// All photos are from Lummi (Lummi License), see the credits in meta.ts.
const photos = [
  "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg",
  "https://ui.dimaac.com/media/architecture/rustic-interior-4x5.jpg",
  "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg",
  "https://ui.dimaac.com/media/architecture/pink-stucco-2x3.jpg",
  "https://ui.dimaac.com/media/architecture/corrugated-facade-4x5.jpg",
  "https://ui.dimaac.com/media/art/organic-forms-4x5.jpg",
  "https://ui.dimaac.com/media/architecture/red-wall-interior-3x2.jpg",
];

export default function Demo(props: Partial<ImageLoaderProps>) {
  // Changing the key builds the loader again from zero: that is the replay
  const [run, setRun] = useState(0);
  const [done, setDone] = useState(false);

  return (
    <div className="relative h-full w-full" style={{ fontFamily: '"Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif' }}>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;600&display=swap" precedence="default" />
      <ImageLoader
        key={run}
        images={photos}
        heroTitle="Quiet rooms in warm light"
        heroAlt="A red tiled wall with two doorways that open onto a sunlit desert living room"
        {...props}
        onComplete={() => {
          setDone(true);
          props.onComplete?.();
        }}
      />
      {done && (
        <button
          type="button"
          onClick={() => {
            setDone(false);
            setRun((count) => count + 1);
          }}
          className="absolute top-5 right-5 cursor-pointer rounded-full bg-white/15 px-4 py-2 text-[14px] font-medium text-white backdrop-blur-md transition-colors hover:bg-white/30 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
        >
          Replay
        </button>
      )}
    </div>
  );
}
```

## Agent prompt

```text
Add the Image Loader component (<ImageLoader />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/image-loader
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/image-loader.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/image-loader.json

Then use it like this:
import { ImageLoader } from "@/components/dimaac/image-loader";
<ImageLoader images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/image-loader.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Images flow
description: Images spread from the centre into 3D.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/images-flow
registry: https://ui.dimaac.com/r/images-flow.json
---

# Images flow

An intro title, a pinned tunnel and an outro title. While the tunnel is pinned, every scroll tick sends a stream of photos flying out of the middle of the screen toward the camera, each one starting a little after the one before and scattering to its own spot. The last photo is a dimmed full-screen backdrop that grows in behind the rest. The motion is scrubbed to the scrollbar, so it runs backwards when you scroll up, and a picture that has flown past the camera disappears cleanly. It measures once per refresh instead of rebuilding on every resize, works on the page or inside any scrolling element through the scroller prop, places extra pictures on a fixed spiral instead of at random, and shows one still frame of the tunnel when the visitor prefers reduced motion. Ported from the first DiMaac UI, with its hot-linked images replaced.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/images-flow.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/images-flow.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/images-flow.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/images-flow`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/images-flow.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ImagesFlow } from "@/components/dimaac/images-flow" and give its parent a size: it fills it.

## Usage

```tsx
import { ImagesFlow } from "@/components/dimaac/images-flow";

<ImagesFlow
  images={images}
  introTitle={introTitle}
  outroTitle={outroTitle}
/>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `(string \| ImagesFlowImage)[]` | - | The pictures. The last one is the full-screen backdrop, the others fly past. Twelve to thirty look best. |
| `introTitle` (required) | `string` | - | The big word before the flow starts. |
| `introSubtitle` | `string` | - | A line under the intro title. |
| `flowText` | `string` | `"Twenty-eight photos,\none scroll"` | The text that stays in the middle of the screen while the pictures fly. Use a line break for two lines. |
| `outroTitle` (required) | `string` | - | The big word after the flow ends. |
| `outroSubtitle` | `string` | - | A line under the outro title. |
| `spread` | `number` | `0.7` | How far the pictures spread sideways on a wide screen. 0.7 puts the outermost ones at 70% of the screen away from the middle. |
| `mobileSpread` | `number` | `1.5` | The same, on a screen narrower than 800px, where the pictures need to travel further to leave the frame. |
| `scrollLength` | `number` | `10` | How long the flow takes, in screens of scrolling. |
| `stagger` | `number` | `0.81` | How much of the scroll the start times of the pictures are spread over, from 0 (all leave together) to 0.9. |
| `flightSpeed` | `number` | `4` | How fast a picture flies, as a multiple of the whole scroll. 4 means one picture needs a quarter of the scroll. |
| `perspective` | `number` | `2000` | Depth of the 3D scene in pixels. Pictures vanish as they pass this distance. Smaller is more dramatic. |
| `cardWidth` | `number` | `500` | Width of each flying picture, in pixels. On a narrow screen it never goes wider than 90% of it. |
| `cardHeight` | `number` | `350` | Height of each flying picture, in pixels. |
| `dim` | `number` | `0.4` | How dark the full-screen backdrop is, from 0 (not at all) to 1 (black). |
| `pageColor` | `string` | `"#e9e9e9"` | Background of the intro and the outro. |
| `inkColor` | `string` | `"#1c1c1c"` | Colour of the intro and outro text. |
| `stageColor` | `string` | `"#1c1c1c"` | Background of the flow section, behind the pictures. |
| `titleAs` | `"h1" \| "h2" \| "h3"` | - | The tag of the two big titles: use "h1" if this is the main heading of your page. |
| `scroller` | `string \| HTMLElement` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes for the outer wrapper. |

## Credits

- Desert Dunes and Mountain Range: Pablo Stanley, Lummi (https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd), Lummi License
- Minimalist Urban Design: Ricardo Matos, Lummi (https://www.lummi.ai/photo/minimalist-urban-design-abiri), Lummi License
- Portrait with Neon Sunglasses: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Futuristic Fashion Pose: Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License
- Surreal 3D Planet Scene: Ricardo Matos, Lummi (https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw), Lummi License
- Elegant Perfume Bottle: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Serene Ocean Aerial View: Mikiwa, Lummi (https://www.lummi.ai/photo/serene-ocean-aerial-view-kjab6), Lummi License
- Modern Interior with Red Wall: Siren Media, Lummi (https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl), Lummi License
- Monochrome Portrait of a Young Woman: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s), Lummi License
- Fashion Portrait with Orange Visor: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Lush Green Hills: SHIHO, Lummi (https://www.lummi.ai/photo/lush-green-hills-mdvvk), Lummi License
- Minimalist Still Life: rena, Lummi (https://www.lummi.ai/photo/minimalist-still-life-gi7oj), Lummi License
- Contemplative Urban Sunset: Atelier Aji, Lummi (https://www.lummi.ai/photo/contemplative-urban-sunset-ttp5t), Lummi License
- Vibrant Cylindrical Arrangement: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Modern Architectural Design: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Vivid Portrait on Pink: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Solitary Tree in Desert: alexaperios, Lummi (https://www.lummi.ai/photo/solitary-tree-in-desert-wsyns), Lummi License
- Futuristic Gold Profile: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License
- Elegant Vase with Dried Flowers: Pablo Stanley, Lummi (https://www.lummi.ai/photo/elegant-vase-with-dried-flowers-dhxnk), Lummi License
- Serene Minimalist Scene with Palm Trees: Binks AI, Lummi (https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c), Lummi License
- Serene Gradient Landscape: Nika, Lummi (https://www.lummi.ai/photo/serene-gradient-landscape-80b2l), Lummi License
- Warm and Cool Portrait: Gerson Portillas, Lummi (https://www.lummi.ai/photo/warm-and-cool-portrait-6s3_), Lummi License
- Dramatic Coastline Aerial View: Cayetano Gros, Lummi (https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7), Lummi License
- Professional Portrait: Benginur Hajjaj, Lummi (https://www.lummi.ai/photo/professional-portrait-yps74), Lummi License
- Contemplative Silhouette against Modern Minimalist Architecture: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License
- Minimalist Still Life: Milan Bundalo, Lummi (https://www.lummi.ai/photo/minimalist-still-life-rrgma), Lummi License
- Avant-Garde Fashion Pose: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License
- Alpine Dawn Serenity: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/an-empty-snowy-mountain-with-pink-sunrise-on-the-hori-270fdea6-887e-4d89-8205-3eab907f3fb0-HgoCN), Lummi License

## Source

### images-flow.tsx → `components/dimaac/images-flow.tsx`

```tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// An intro title, then a pinned "tunnel" where photos explode out of the middle of the screen toward
// the camera as you scroll, then an outro title. The flow section pins to the screen and the
// scrollbar drives every photo: scroll down and they fly out, scroll up and they fly back in.
// The last image in the list is different: it is the full-screen backdrop that grows in behind the rest.
//
// Fonts: the demo uses Raleway for the titles and Instrument Serif for the flow text. Load them, or set
// the CSS variables --images-flow-title-font and --images-flow-flow-font to use your own.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);

export type ImagesFlowImage = {
  /** Where the picture lives. */
  src: string;
  /** What it shows. Leave it empty for a purely decorative picture (the default). */
  alt?: string;
};

export type ImagesFlowProps = {
  /** The pictures. The last one is the full-screen backdrop, the others fly past. Twelve to thirty look best. */
  images: (string | ImagesFlowImage)[];
  /** The big word before the flow starts. */
  introTitle: string;
  /** A line under the intro title. */
  introSubtitle?: string;
  /** The text that stays in the middle of the screen while the pictures fly. Use a line break for two lines. */
  flowText?: string;
  /** The big word after the flow ends. */
  outroTitle: string;
  /** A line under the outro title. */
  outroSubtitle?: string;
  /** How far the pictures spread sideways on a wide screen. 0.7 puts the outermost ones at 70% of the screen away from the middle. */
  spread?: number;
  /** The same, on a screen narrower than 800px, where the pictures need to travel further to leave the frame. */
  mobileSpread?: number;
  /** How long the flow takes, in screens of scrolling. */
  scrollLength?: number;
  /** How much of the scroll the start times of the pictures are spread over, from 0 (all leave together) to 0.9. */
  stagger?: number;
  /** How fast a picture flies, as a multiple of the whole scroll. 4 means one picture needs a quarter of the scroll. */
  flightSpeed?: number;
  /** Depth of the 3D scene in pixels. Pictures vanish as they pass this distance. Smaller is more dramatic. */
  perspective?: number;
  /** Width of each flying picture, in pixels. On a narrow screen it never goes wider than 90% of it. */
  cardWidth?: number;
  /** Height of each flying picture, in pixels. */
  cardHeight?: number;
  /** How dark the full-screen backdrop is, from 0 (not at all) to 1 (black). */
  dim?: number;
  /** Background of the intro and the outro. */
  pageColor?: string;
  /** Colour of the intro and outro text. */
  inkColor?: string;
  /** Background of the flow section, behind the pictures. */
  stageColor?: string;
  /** The tag of the two big titles: use "h1" if this is the main heading of your page. */
  titleAs?: "h1" | "h2" | "h3";
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes for the outer wrapper. */
  className?: string;
};

// Where each picture ends up, as a fraction of the screen (-1 left/top to 1 right/bottom).
// A hand-picked scatter, so no two neighbours fly the same way.
const SCATTER = [
  [-0.8, -0.6], [0.7, 0.4], [-0.5, 0.7], [0.6, -0.5], [-0.8, 0.2], [0.8, -0.3], [-0.6, -0.8],
  [0.4, 0.6], [-0.7, 0.5], [0.5, -0.7], [-0.4, -0.4], [0.3, 0.8], [-0.8, 0.3], [0.6, 0.2],
  [-0.2, -0.7], [0.7, -0.6], [-0.5, 0.4], [0.4, -0.4], [-0.6, 0.6], [0.8, 0.5], [-0.3, -0.5],
  [0.5, 0.3], [-0.7, -0.2], [0.2, 0.7], [-0.4, 0.8], [0.6, -0.8], [-0.8, 0.1],
] as const;

/** The target for picture number `index`. Past the hand-picked list it follows a fixed spiral, never random. */
function scatterAt(index: number): { x: number; y: number } {
  const picked = SCATTER[index];
  if (picked) return { x: picked[0], y: picked[1] };
  const angle = index * 2.399963; // the golden angle, so neighbours never line up
  const radius = 0.35 + 0.5 * ((index * 0.618) % 1);
  return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
}

export function ImagesFlow({
  images,
  introTitle,
  introSubtitle,
  flowText = "Twenty-eight photos,\none scroll",
  outroTitle,
  outroSubtitle,
  spread = 0.7,
  mobileSpread = 1.5,
  scrollLength = 10,
  stagger = 0.81,
  flightSpeed = 4,
  perspective = 2000,
  cardWidth = 500,
  cardHeight = 350,
  dim = 0.4,
  pageColor = "#e9e9e9",
  inkColor = "#1c1c1c",
  stageColor = "#1c1c1c",
  titleAs: Heading = "h2",
  scroller,
  className = "",
}: ImagesFlowProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const flowRef = useRef<HTMLElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);

  // These change how the pictures are placed, not how the scroll is wired up. The animation reads
  // them on every frame, so moving a slider doesn't rebuild the pin.
  const live = useRef({ spread, mobileSpread, stagger, flightSpeed, perspective });
  live.current = { spread, mobileSpread, stagger, flightSpeed, perspective };
  // Draws the pictures for the current scroll position. Set by the effect below.
  const redraw = useRef<(() => void) | null>(null);
  useEffect(() => {
    redraw.current?.();
  }, [spread, mobileSpread, stagger, flightSpeed, perspective]);

  const items = images.map((image) => (typeof image === "string" ? { src: image, alt: "" } : { src: image.src, alt: image.alt ?? "" }));
  const lastIndex = items.length - 1;

  useGSAP(
    () => {
      const root = rootRef.current;
      const flow = flowRef.current;
      const cards = cardRefs.current.slice(0, items.length).filter((card): card is HTMLDivElement => card !== null);
      if (!root || !flow || cards.length === 0) return;

      const scrollEl = typeof scroller === "string" ? document.querySelector<HTMLElement>(scroller) : scroller;
      if (typeof scroller === "string" && !scrollEl) return;

      // Pictures start in the middle, tiny, and are placed by x / y / z / scale from here on
      gsap.set(cards, { xPercent: -50, yPercent: -50, x: 0, y: 0, z: 0, scale: 0, force3D: true });
      const place = cards.map((card) => ({
        x: gsap.quickSetter(card, "x", "px"),
        y: gsap.quickSetter(card, "y", "px"),
        z: gsap.quickSetter(card, "z", "px"),
        scaleX: gsap.quickSetter(card, "scaleX"),
        scaleY: gsap.quickSetter(card, "scaleY"),
      }));

      let width = flow.clientWidth;
      let height = flow.clientHeight;
      let progress = 0;

      // Draws every picture for a scroll position between 0 and 1
      const draw = () => {
        const { spread, mobileSpread, stagger, flightSpeed, perspective } = live.current;
        const reach = width < 800 ? mobileSpread : spread;
        const last = cards.length - 1;
        cards.forEach((card, index) => {
          // Each picture starts a little after the one before it, and flies for a fixed slice of the scroll.
          // Clamped to 0..1: a picture that has arrived stays arrived instead of flying on forever.
          const delay = last > 0 ? (index / last) * stagger : 0;
          const t = gsap.utils.clamp(0, 1, (progress - delay) * flightSpeed);
          const isBackdrop = index === last && last > 0;
          const target = scatterAt(index);

          let x = target.x * width * reach * t;
          let y = target.y * height * reach * t;
          let z = gsap.utils.interpolate(-perspective / 2, perspective, t);
          if (isBackdrop) {
            // The backdrop stays in the middle and only grows, at 40% of the speed
            x = 0;
            y = 0;
            z *= 0.4;
          }
          place[index]!.x(x);
          place[index]!.y(y);
          place[index]!.z(z);
          place[index]!.scaleX(t);
          place[index]!.scaleY(t);
          // A picture that has reached the camera is gone: hide it so it can't flash at the edges
          card.style.visibility = t <= 0 || (!isBackdrop && z >= perspective - 1) ? "hidden" : "visible";
        });
      };
      redraw.current = draw;

      const measure = () => {
        width = flow.clientWidth;
        height = flow.clientHeight;
      };

      const mm = gsap.matchMedia();

      // Reduced motion: no pin and no flying. The flow section is one still frame of the tunnel.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        progress = 0.38;
        measure();
        draw();
        const onResize = () => {
          measure();
          draw();
        };
        window.addEventListener("resize", onResize);
        return () => window.removeEventListener("resize", onResize);
      });

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        // Sections are one screen tall. For an inner scroller that is the scroller's height, which CSS can't know.
        const sizeSections = () => {
          if (scrollEl) root.style.setProperty("--images-flow-height", `${scrollEl.clientHeight}px`);
        };
        sizeSections();

        const trigger = ScrollTrigger.create({
          trigger: flow,
          scroller: scrollEl ?? undefined,
          start: "top top",
          end: () => `+=${Math.round(flow.clientHeight * scrollLength)}`,
          pin: true,
          pinSpacing: true,
          scrub: 1,
          // Measure again whenever ScrollTrigger refreshes (window resize, fonts, pictures), not on every resize event
          invalidateOnRefresh: true,
          onRefresh: (self) => {
            measure();
            progress = self.progress;
            draw();
          },
          onUpdate: (self) => {
            progress = self.progress;
            draw();
          },
        });

        // An inner scroller can change size without the window knowing. Wait for the resizing to settle, then measure once.
        let settle: gsap.core.Tween | undefined;
        const observer = scrollEl
          ? new ResizeObserver(() => {
              settle?.kill();
              settle = gsap.delayedCall(0.2, () => {
                sizeSections();
                ScrollTrigger.refresh();
              });
            })
          : null;
        if (scrollEl) observer?.observe(scrollEl);

        return () => {
          settle?.kill();
          observer?.disconnect();
          trigger.kill();
        };
      });

      return () => {
        redraw.current = null;
        mm.revert();
      };
    },
    { scope: rootRef, dependencies: [items.length, scrollLength, scroller], revertOnUpdate: true },
  );

  const titleStyle: CSSProperties = {
    fontFamily: "var(--images-flow-title-font, Raleway, ui-sans-serif, system-ui, sans-serif)",
    fontSize: "clamp(2rem, 8vw, 6rem)",
    fontWeight: 200,
    letterSpacing: "0.3em",
    // The letter spacing also trails the last letter, so push the word back to the middle
    textIndent: "0.3em",
    textTransform: "uppercase",
    lineHeight: 1.1,
    marginBottom: "1rem",
    // A soft fade from the ink to a lighter ink across the word
    backgroundImage: `linear-gradient(135deg, ${inkColor} 0%, color-mix(in srgb, ${inkColor} 78%, ${pageColor}) 100%)`,
    WebkitBackgroundClip: "text",
    backgroundClip: "text",
    WebkitTextFillColor: "transparent",
  };
  const subtitleStyle: CSSProperties = {
    fontFamily: "var(--images-flow-title-font, Raleway, ui-sans-serif, system-ui, sans-serif)",
    fontSize: "clamp(0.75rem, 2vw, 1.2rem)",
    fontWeight: 300,
    letterSpacing: "0.15em",
    textIndent: "0.15em",
    opacity: 0.7,
  };
  const sectionStyle: CSSProperties = { height: "var(--images-flow-height, 100svh)" };

  return (
    <div ref={rootRef} className={`relative w-full overflow-x-clip ${className}`}>
      {/* Intro */}
      <section className="relative flex w-full flex-col items-center justify-center overflow-hidden px-6 text-center" style={{ ...sectionStyle, backgroundColor: pageColor, color: inkColor }}>
        <Heading style={titleStyle}>{introTitle}</Heading>
        {introSubtitle ? <p style={subtitleStyle}>{introSubtitle}</p> : null}
      </section>

      {/* Flow: this is the part that pins */}
      <section ref={flowRef} className="relative w-full overflow-hidden" style={{ ...sectionStyle, backgroundColor: stageColor }}>
        <p
          className="pointer-events-none absolute top-1/2 left-1/2 z-[100] w-full -translate-x-1/2 -translate-y-1/2 px-6 text-center whitespace-pre-line text-white opacity-80"
          style={{ fontFamily: "var(--images-flow-flow-font, 'Instrument Serif', Georgia, serif)", fontSize: "clamp(1.5rem, 5vw, 2rem)", letterSpacing: "0.05em" }}
        >
          {flowText}
        </p>
        <div className="absolute inset-0" style={{ perspective, transformStyle: "preserve-3d" }}>
          {items.map((item, index) => {
            const backdrop = index === lastIndex && lastIndex > 0;
            return (
              <div
                key={`${index}-${item.src}`}
                ref={(card) => {
                  cardRefs.current[index] = card;
                }}
                className={backdrop ? "absolute top-1/2 left-1/2 h-full w-full" : "absolute top-1/2 left-1/2 max-w-[90vw]"}
                // Before the script runs the pictures sit invisible in the middle, so nothing flashes on load
                style={{ ...(backdrop ? null : { width: cardWidth, height: cardHeight }), transform: "translate(-50%, -50%) scale(0)", visibility: "hidden", transformStyle: "preserve-3d", willChange: "transform" }}
              >
                <div className="relative h-full w-full overflow-hidden">
                  <img src={item.src} alt={item.alt} className="block h-full w-full object-cover" draggable={false} decoding="async" />
                  {backdrop ? <div aria-hidden="true" className="absolute inset-0 bg-black" style={{ opacity: dim }} /> : null}
                </div>
              </div>
            );
          })}
        </div>
      </section>

      {/* Outro */}
      <section className="relative flex w-full flex-col items-center justify-center overflow-hidden px-6 text-center" style={{ ...sectionStyle, backgroundColor: pageColor, color: inkColor }}>
        <Heading style={titleStyle}>{outroTitle}</Heading>
        {outroSubtitle ? <p style={subtitleStyle}>{outroSubtitle}</p> : null}
      </section>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { ImagesFlow, type ImagesFlowProps } from "@/components/dimaac/images-flow";

// Twenty-eight photos fly past and the last one (the dunes at dawn) is the full-screen backdrop that
// grows in behind them. The intro and outro keep their own pale paper and the flow its own dark
// stage, so the demo looks the same in the light and the dark site theme.
const photos = [
  "nature/golden-dunes-3x2.jpg",
  "architecture/concrete-corner-3x2.jpg",
  "portraits/neon-profile-4x5.jpg",
  "fashion/grey-suit-stride-4x5.jpg",
  "art/floating-spheres-4x5.jpg",
  "objects/amber-perfume-4x5.jpg",
  "nature/turquoise-shallows-3x2.jpg",
  "architecture/red-wall-interior-3x2.jpg",
  "portraits/mono-profile-4x5.jpg",
  "fashion/orange-visor-4x5.jpg",
  "nature/misty-hills-4x5.jpg",
  "objects/wine-still-life-3x2.jpg",
  "dark/dusk-city-3x2.jpg",
  "art/colour-cylinders-4x5.jpg",
  "architecture/yellow-planes-4x5.jpg",
  "portraits/lime-blazer-4x5.jpg",
  "nature/lone-tree-3x2.jpg",
  "fashion/gold-visor-4x5.jpg",
  "objects/purple-vase-3x2.jpg",
  "architecture/palm-pool-3x2.jpg",
  "art/red-gradient-4x5.jpg",
  "dark/lamp-light-3x2.jpg",
  "nature/coastline-aerial-4x5.jpg",
  "portraits/studio-blazer-16x9.jpg",
  "architecture/pastel-rooftop-4x5.jpg",
  "objects/amber-jars-3x2.jpg",
  "fashion/chunky-knit-4x5.jpg",
  "nature/alpine-dawn-16x9.jpg",].map((file) => media(file));

export default function Demo(props: Partial<ImagesFlowProps>) {
  return (
    <div>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Raleway:wght@200;300&family=Instrument+Serif:ital@0;1&display=swap" precedence="default" />
      <ImagesFlow
        images={photos}
        introTitle="Begin"
        introSubtitle="Scroll to fly through"
        flowText={"Twenty-eight photos,\none scroll"}
        outroTitle="The end"
        outroSubtitle="Scroll back up to go again"
        {...props}
      />
    </div>
  );
}
```

## Agent prompt

```text
Add the Images flow component (<ImagesFlow />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/images-flow
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/images-flow.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/images-flow.json

Then use it like this:
import { ImagesFlow } from "@/components/dimaac/images-flow";
<ImagesFlow
  images={images}
  introTitle={introTitle}
  outroTitle={outroTitle}
/>

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/images-flow.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Ink Hover Reveal
description: A brush stroke of ink pours the colour photo in on hover.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/ink-hover
registry: https://ui.dimaac.com/r/ink-hover.json
---

# Ink Hover Reveal

A greyscale photo card. When the cursor lands on it, a thick SVG stroke draws across it like wet ink, widening as it goes, and the colour photo shows through the stroke until the card is full. Leaving plays the same timeline backwards so the colour drains out along the same line. Pick one of three brush paths (wave, zigzag, scribble), the stroke thickness, the speed and the ink colour. Each card carries its own SVG mask (unique ids per instance), taps open and close it on touch screens, keyboard focus opens it too, and reduced motion swaps the brush for a short fade. Rebuilt from the svg-hover YouTube demo, with the colour revealed through the stroke instead of painted over the photo.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/ink-hover.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/ink-hover.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/ink-hover.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/ink-hover`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/ink-hover.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { InkHover } from "@/components/dimaac/ink-hover" and give its parent a size: it fills it.

## Usage

```tsx
import { InkHover } from "@/components/dimaac/ink-hover";

<InkHover src={src} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` | - | The photo. It shows in greyscale until the ink pours in. A 4:5 portrait fits best. |
| `hoverSrc` | `string` | - | A different photo to pour in. Leave it empty to pour in the colour version of `src`. |
| `alt` | `string` | `""` | Alt text for the photo. Leave it empty if the label already says what it is. |
| `label` | `string` | - | Title shown on the card once the colour is in. |
| `caption` | `string` | - | A short line under the title. |
| `strokeWidth` | `number` | `140` | How thick the brush stroke is while it draws, in pixels of a 800 wide card. The stroke then widens until it floods the card. |
| `duration` | `number` | `0.9` | How long the brush takes to cross the card, in seconds. |
| `path` | `InkHoverPath` | `"wave"` | The shape of the brush path. |
| `grayscaleBase` | `boolean` | `true` | Show the base photo in greyscale. Turn it off to see the full colour photo under the ink. |
| `inkColor` | `string` | `"#111111"` | The colour of the wet ink that leads the stroke. |
| `className` | `string` | `""` | Extra classes for the card. It is a 4:5 box by default: give it your own size or aspect. |

## Credits

- Fashion portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Perfume bottle: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Coastline: Cayetano Gros, Lummi (https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7), Lummi License
- Cylinders: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Architecture: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License

## Source

### ink-hover.tsx → `components/dimaac/ink-hover.tsx`

```tsx
"use client";

import { useId, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A greyscale photo that fills with colour when you hover it. A thick brush stroke draws across
// the card like wet ink, and the colour photo shows through the stroke as it widens, until the
// whole card is poured in. Leaving runs the same timeline backwards, so the colour drains away
// along the same line. Everything lives in one SVG, so the photo and the brush always line up.

/** The three brush paths. Each one is drawn in a 800 x 1000 box and runs from outside the card to outside it. */
const PATHS = {
  // Soft S-bends, row after row down the card
  wave: "M -60 130 C 120 20 260 240 420 130 S 720 20 860 130 C 960 200 700 300 520 300 S 160 270 -60 330 C -110 350 60 480 220 480 S 560 380 860 480 C 950 520 760 650 560 650 S 200 570 -60 650 C -110 680 100 800 300 800 S 620 700 860 810 C 950 850 780 940 560 940 S 220 890 -60 950",
  // Hard diagonal passes, like a marker filling a page
  zigzag: "M -40 60 L 840 220 L -40 380 L 840 540 L -40 700 L 840 860 L 120 1060",
  // A loose, looping scribble
  scribble: "M 90 940 C -40 640 380 740 300 520 S 700 700 640 400 S 130 460 240 220 S 720 280 700 60 C 690 -40 520 -20 560 120 S 640 420 420 600 S 100 780 200 950 S 520 1010 640 880 S 860 700 760 560",
} as const;

export type InkHoverPath = keyof typeof PATHS;

export type InkHoverProps = {
  /** The photo. It shows in greyscale until the ink pours in. A 4:5 portrait fits best. */
  src: string;
  /** A different photo to pour in. Leave it empty to pour in the colour version of `src`. */
  hoverSrc?: string;
  /** Alt text for the photo. Leave it empty if the label already says what it is. */
  alt?: string;
  /** Title shown on the card once the colour is in. */
  label?: string;
  /** A short line under the title. */
  caption?: string;
  /** How thick the brush stroke is while it draws, in pixels of a 800 wide card. The stroke then widens until it floods the card. */
  strokeWidth?: number;
  /** How long the brush takes to cross the card, in seconds. */
  duration?: number;
  /** The shape of the brush path. */
  path?: InkHoverPath;
  /** Show the base photo in greyscale. Turn it off to see the full colour photo under the ink. */
  grayscaleBase?: boolean;
  /** The colour of the wet ink that leads the stroke. */
  inkColor?: string;
  /** Extra classes for the card. It is a 4:5 box by default: give it your own size or aspect. */
  className?: string;
};

// How wide the stroke ends up, so the last pass floods every gap between the rows
const FLOOD_WIDTH = 780;
// The path is normalised to length 1 (pathLength), so the dash tricks work for any shape.
// Starting a little past 1 keeps the round cap of the empty stroke out of the card.
const HIDDEN_OFFSET = 1.12;

export function InkHover({
  src,
  hoverSrc,
  alt = "",
  label,
  caption,
  strokeWidth = 140,
  duration = 0.9,
  path = "wave",
  grayscaleBase = true,
  inkColor = "#111111",
  className = "",
}: InkHoverProps) {
  const cardRef = useRef<HTMLButtonElement>(null);
  const timelineRef = useRef<gsap.core.Timeline | null>(null);
  const pointerTypeRef = useRef("mouse");
  const openRef = useRef(false);

  // useId gives each card its own ids, so two cards never share a mask or a filter
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const maskId = `ink-mask-${uid}`;
  const greyId = `ink-grey-${uid}`;
  const d = PATHS[path];
  const revealSrc = hoverSrc || src;

  useGSAP(
    () => {
      const card = cardRef.current;
      if (!card) return;
      const inkLine = card.querySelector<SVGPathElement>("[data-ink]");
      const maskLine = card.querySelector<SVGPathElement>("[data-mask]");
      const reveal = card.querySelector<SVGGElement>("[data-reveal]");
      const revealImage = card.querySelector<SVGImageElement>("[data-reveal-image]");
      const words = card.querySelector<HTMLElement>("[data-words]");
      if (!inkLine || !maskLine || !reveal || !revealImage || !words) return;

      const mm = gsap.matchMedia();

      mm.add(
        { calm: "(prefers-reduced-motion: reduce)", lively: "(prefers-reduced-motion: no-preference)" },
        (context) => {
          const calm = Boolean(context.conditions?.calm);
          const timeline = gsap.timeline({ paused: true, defaults: { ease: "power2.out" } });
          timelineRef.current = timeline;

          if (calm) {
            // Reduced motion: no brush. The mask is fully open and the colour photo simply fades in.
            gsap.set(maskLine, { attr: { "stroke-dashoffset": 0, "stroke-width": FLOOD_WIDTH } });
            gsap.set(inkLine, { opacity: 0 });
            gsap.set(reveal, { opacity: 0 });
            gsap.set(words, { opacity: 0 });
            timeline.to(reveal, { opacity: 1, duration: 0.3 }, 0).to(words, { opacity: 1, duration: 0.3 }, 0);
            return;
          }

          // The ink is a little thinner than the colour, so it only shows as a dark tip at the
          // front of the stroke and a thin rim along its edge, never as blobs between the passes
          const inkScale = 0.8;
          gsap.set(maskLine, { attr: { "stroke-dashoffset": HIDDEN_OFFSET, "stroke-width": strokeWidth } });
          gsap.set(inkLine, { attr: { "stroke-dashoffset": HIDDEN_OFFSET, "stroke-width": strokeWidth * inkScale } });
          gsap.set(revealImage, { scale: 1.12, transformOrigin: "50% 50%" });
          gsap.set(words, { opacity: 0, y: 14 });

          // The ink goes first, the colour photo follows a beat behind
          const lag = duration * 0.07;
          [
            { line: inkLine, at: 0, width: FLOOD_WIDTH * inkScale },
            { line: maskLine, at: lag, width: FLOOD_WIDTH },
          ].forEach(({ line, at, width }) => {
            timeline.to(line, { attr: { "stroke-dashoffset": 0 }, duration, ease: "power2.out" }, at);
            // The stroke thickens while it is still travelling, which is what makes it feel poured
            timeline.to(line, { attr: { "stroke-width": width }, duration: duration * 0.8, ease: "power2.inOut" }, at + duration * 0.25);
          });
          timeline.to(revealImage, { scale: 1, duration: duration * 1.2, ease: "power2.out" }, lag);
          timeline.to(words, { opacity: 1, y: 0, duration: 0.5 }, duration * 0.55);
        },
      );

      const play = () => {
        openRef.current = true;
        card.setAttribute("aria-pressed", "true");
        timelineRef.current?.timeScale(1).play();
      };
      // Leaving is a touch faster than arriving
      const stop = () => {
        openRef.current = false;
        card.setAttribute("aria-pressed", "false");
        timelineRef.current?.timeScale(1.3).reverse();
      };

      const onPointerEnter = (event: PointerEvent) => {
        if (event.pointerType !== "touch") play();
      };
      const onPointerLeave = (event: PointerEvent) => {
        if (event.pointerType !== "touch") stop();
      };
      const onPointerDown = (event: PointerEvent) => {
        pointerTypeRef.current = event.pointerType;
      };
      // Touch has no hover, so a tap opens the card and the next tap closes it
      const onClick = () => {
        if (pointerTypeRef.current !== "touch") return;
        if (openRef.current) stop();
        else play();
      };
      // Keyboard users get the same reveal when the card takes focus
      const onFocus = () => {
        if (card.matches(":focus-visible")) play();
      };
      const onBlur = () => {
        if (openRef.current && pointerTypeRef.current !== "touch") stop();
      };

      card.addEventListener("pointerenter", onPointerEnter);
      card.addEventListener("pointerleave", onPointerLeave);
      card.addEventListener("pointerdown", onPointerDown);
      card.addEventListener("click", onClick);
      card.addEventListener("focus", onFocus);
      card.addEventListener("blur", onBlur);

      return () => {
        card.removeEventListener("pointerenter", onPointerEnter);
        card.removeEventListener("pointerleave", onPointerLeave);
        card.removeEventListener("pointerdown", onPointerDown);
        card.removeEventListener("click", onClick);
        card.removeEventListener("focus", onFocus);
        card.removeEventListener("blur", onBlur);
        timelineRef.current?.kill();
        timelineRef.current = null;
        openRef.current = false;
        card.setAttribute("aria-pressed", "false");
        mm.revert();
      };
    },
    { scope: cardRef, dependencies: [strokeWidth, duration, path, src, revealSrc], revertOnUpdate: true },
  );

  const accessibleName = [label, caption].filter(Boolean).join(", ") || alt || undefined;

  return (
    <button
      ref={cardRef}
      type="button"
      aria-pressed="false"
      aria-label={accessibleName}
      className={`group relative block aspect-[4/5] w-full cursor-pointer overflow-hidden rounded-xl border-0 bg-neutral-200 p-0 text-left outline-offset-4 focus-visible:outline-2 focus-visible:outline-current dark:bg-neutral-800 ${className}`}
      style={{ touchAction: "manipulation", WebkitTapHighlightColor: "transparent" }}
    >
      <svg viewBox="0 0 800 1000" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 h-full w-full" aria-hidden="true" focusable="false">
        <defs>
          <filter id={greyId} colorInterpolationFilters="sRGB">
            <feColorMatrix type="saturate" values="0" />
          </filter>
          {/* The brush is white on a clear mask, so the colour photo shows only where the brush has been */}
          <mask id={maskId} maskUnits="userSpaceOnUse" x="-400" y="-400" width="1600" height="1800">
            <path data-mask d={d} pathLength={1} fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} strokeDasharray="1 2" strokeDashoffset={HIDDEN_OFFSET} />
          </mask>
        </defs>
        <image href={src} x="0" y="0" width="800" height="1000" preserveAspectRatio="xMidYMid slice" filter={grayscaleBase ? `url(#${greyId})` : undefined} />
        <path data-ink d={d} pathLength={1} fill="none" stroke={inkColor} strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} strokeDasharray="1 2" strokeDashoffset={HIDDEN_OFFSET} />
        <g data-reveal mask={`url(#${maskId})`}>
          <image data-reveal-image href={revealSrc} x="0" y="0" width="800" height="1000" preserveAspectRatio="xMidYMid slice" />
        </g>
      </svg>

      {(label || caption) && (
        <span data-words className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-gradient-to-t from-black/60 to-transparent px-5 pt-14 pb-4 text-white opacity-0" aria-hidden="true">
          {label && <span className="text-[17px] leading-tight font-medium tracking-[-0.01em]">{label}</span>}
          {caption && <span className="text-[13px] opacity-80">{caption}</span>}
        </span>
      )}
    </button>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { InkHover, type InkHoverProps } from "@/components/dimaac/ink-hover";

// Six Lummi photos (Lummi License: free for personal and commercial use)
const cards = [
  { src: "fashion/orange-visor-4x5.jpg", label: "Orange visor", caption: "Studio, teal and amber", alt: "Woman in an orange visor and navy dress against a teal background" },
  { src: "objects/amber-perfume-4x5.jpg", label: "Amber glass", caption: "Leaf shadows, green wall", alt: "Amber perfume bottle against a green background with leaf shadows" },
  { src: "portraits/lime-blazer-4x5.jpg", label: "Lime blazer", caption: "Pink, loud and sure", alt: "Person with curly red hair in a lime green blazer against a pink background" },
  { src: "nature/coastline-aerial-4x5.jpg", label: "Turquoise edge", caption: "Where the cliffs stop", alt: "Aerial view of turquoise waves crashing on a sandy beach below cliffs" },
  { src: "art/colour-cylinders-4x5.jpg", label: "Colour blocks", caption: "Orange, red, blue, green", alt: "Colourful cylinders on a bold geometric background" },
  { src: "architecture/pastel-rooftop-4x5.jpg", label: "Pastel roofline", caption: "Purple wall, pink house", alt: "Pastel architecture with a purple wall and a pink building under a blue sky" },
];

export default function Demo(props: Partial<InkHoverProps>) {
  return (
    <div className="relative flex h-full w-full flex-col items-center justify-center gap-5 overflow-hidden bg-[#f3f0eb] px-5 py-6 text-[#1c1917] dark:bg-[#0f0d0c] dark:text-[#e8ddd4]">
      <header className="flex w-full max-w-[1100px] items-end justify-between gap-4" style={{ width: "min(100%, calc(1.2 * (100vh - 190px) + 32px))" }}>
        <h1 className="text-[clamp(22px,3.4vw,40px)] leading-none font-medium tracking-[-0.035em]">Colour, poured in</h1>
        <p className="font-mono text-[11px] tracking-wide uppercase opacity-60">Hover or tap a card</p>
      </header>

      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4" style={{ width: "min(100%, calc(1.2 * (100vh - 190px) + 32px))" }}>
        {cards.map((card) => (
          <InkHover key={card.src} {...props} src={media(card.src)} alt={card.alt} label={card.label} caption={card.caption} />
        ))}
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Ink Hover Reveal component (<InkHover />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/ink-hover
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/ink-hover.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/ink-hover.json

Then use it like this:
import { InkHover } from "@/components/dimaac/ink-hover";
<InkHover src={src} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/ink-hover.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Line Reveal
description: Headings and paragraphs that rise line by line out of a mask.
category: Text
tier: free
url: https://ui.dimaac.com/components/line-reveal
registry: https://ui.dimaac.com/r/line-reveal.json
---

# Line Reveal

A heading or paragraph that is cut into lines and lifted out of a mask as it scrolls into view, the way editorial award sites open a page. Switch on highlight and the words start faint and fill in one by one as you scroll through the text. It uses GSAP SplitText with autoSplit, so the lines are cut again when the width changes or the web font loads, and everything is put back the way it was on unmount. The text stays real text: selectable, and read as one sentence by screen readers. It takes an optional scroller for inner scroll areas and shows the text as it is when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/line-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/line-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/line-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/line-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/line-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { LineReveal } from "@/components/dimaac/line-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { LineReveal } from "@/components/dimaac/line-reveal";

<LineReveal children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `string` | - | The text to show. Plain text only (the lines are cut from it). |
| `as` | `"h1" \| "h2" \| "p"` | `"p"` | The tag to render: a page title, a section title or a paragraph. |
| `stagger` | `number` | `0.08` | Delay between one line starting and the next, in seconds. |
| `duration` | `number` | `1` | How long each line takes to rise, in seconds. |
| `highlight` | `boolean` | `false` | Fill the words in one by one as you scroll through the text. |
| `highlightColor` | `string` | `"#ff5a36"` | The colour a word turns when it is filled in. Empty keeps the text colour. |
| `start` | `string` | `"top 85%"` | When the reveal starts: "<element edge> <viewport edge>", e.g. "top 85%". |
| `scroller` | `string \| HTMLElement` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes: put the font size, weight and colour here. |

## Source

### line-reveal.tsx → `components/dimaac/line-reveal.tsx`

```tsx
"use client";

import { createElement, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

// A heading or paragraph that rises line by line out of a mask when it scrolls into view.
// Optionally the words start faint and fill in as you scroll, like reading along with your finger.
// The text stays real text: selectable, and read out as one sentence by screen readers.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);

export type LineRevealProps = {
  /** The text to show. Plain text only (the lines are cut from it). */
  children: string;
  /** The tag to render: a page title, a section title or a paragraph. */
  as?: "h1" | "h2" | "p";
  /** Delay between one line starting and the next, in seconds. */
  stagger?: number;
  /** How long each line takes to rise, in seconds. */
  duration?: number;
  /** Fill the words in one by one as you scroll through the text. */
  highlight?: boolean;
  /** The colour a word turns when it is filled in. Empty keeps the text colour. */
  highlightColor?: string;
  /** When the reveal starts: "<element edge> <viewport edge>", e.g. "top 85%". */
  start?: string;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes: put the font size, weight and colour here. */
  className?: string;
};

export function LineReveal({
  children,
  as = "p",
  stagger = 0.08,
  duration = 1,
  highlight = false,
  highlightColor = "#ff5a36",
  start = "top 85%",
  scroller,
  className = "",
}: LineRevealProps) {
  const textRef = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const element = textRef.current;
      if (!element) return;

      // The text is hidden until it is split, so you never see it jump. Show it again either way.
      element.style.visibility = "visible";

      // Reduced motion: leave the text exactly as written, fully readable
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

      let fill: gsap.core.Tween | undefined;

      const split = SplitText.create(element, {
        type: highlight ? "lines,words" : "lines",
        mask: "lines",
        linesClass: "line-reveal-line",
        // Re-cut the lines when the width changes or the web font arrives
        autoSplit: true,
        onSplit(self) {
          // The mask clips to the line box, which cuts the tails of g, y and p. Give it a little room.
          self.masks.forEach((mask) => {
            const box = mask as HTMLElement;
            box.style.paddingBottom = "0.14em";
            box.style.marginBottom = "-0.14em";
          });

          // This runs again after every re-split, so drop the old fill first
          fill?.scrollTrigger?.kill();
          fill?.kill();

          if (highlight) {
            const color = highlightColor || getComputedStyle(element).color;
            // Words start faint and fill in as the scrollbar moves (scrub), one after another
            fill = gsap.fromTo(
              self.words,
              { opacity: 0.18 },
              {
                opacity: 1,
                color,
                ease: "none",
                stagger: 0.12,
                duration: 0.4,
                scrollTrigger: { trigger: element, scroller, start, end: "bottom 45%", scrub: 0.4 },
              },
            );
          }

          // Returning the tween lets SplitText keep it in step when it re-splits
          return gsap.from(self.lines, {
            yPercent: 115,
            rotate: 3,
            transformOrigin: "0% 100%",
            duration,
            ease: "power4.out",
            stagger,
            scrollTrigger: { trigger: element, scroller, start, toggleActions: "play none none none" },
          });
        },
      });

      return () => {
        fill?.scrollTrigger?.kill();
        fill?.kill();
        split.revert();
      };
    },
    { scope: textRef, dependencies: [children, as, stagger, duration, highlight, highlightColor, start, scroller], revertOnUpdate: true },
  );

  // The key makes React build a fresh element when the text or tag changes, because SplitText
  // rewrites the inside of the old one.
  return createElement(as, { ref: textRef, key: `${as}:${children}`, className, style: { visibility: "hidden" } }, children);
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { LineReveal, type LineRevealProps } from "@/components/dimaac/line-reveal";

// An editorial page made only of type. Every block uses the same playground settings.
// The big statement always fills in as you scroll; the "highlight" switch turns it on for the body text too.

const notes = [
  {
    number: "01",
    title: "Set in the mask",
    text: "Each line is cut out of the paragraph and sits just below the edge of its own window. When it enters the page it rises into view, one line after another, and settles with a long slow ease.",
  },
  {
    number: "02",
    title: "Cut again when it changes",
    text: "Resize the window, rotate the phone or wait for the font to arrive and the lines are measured again. Nothing breaks halfway, and the animation carries on from where it was.",
  },
  {
    number: "03",
    title: "Still just text",
    text: "Select it, search it, translate it. Screen readers get the sentence in one piece, and anyone who asks their device for less motion simply gets the words, fully visible.",
  },
];

const serif = "font-[family-name:Instrument_Serif,serif]";

export default function Demo(props: Partial<LineRevealProps>) {
  const { className: _className, children: _children, as: _as, ...shared } = props;
  return (
    <div className="min-h-dvh bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap" precedence="default" />

      <section className="flex min-h-[100dvh] flex-col justify-end px-6 pb-16 md:px-12 md:pb-24">
        <LineReveal {...shared} as="h1" start="top 100%" className={`max-w-[14ch] ${serif} text-[clamp(52px,11vw,176px)] leading-[0.92] tracking-[-0.03em]`}>
          The words arrive one line at a time
        </LineReveal>
        <p className="mt-10 font-mono text-[11px] tracking-wide uppercase opacity-60">Scroll</p>
      </section>

      <section className="px-6 py-24 md:px-12 md:py-40">
        <LineReveal {...shared} as="p" className={`max-w-[30ch] ${serif} text-[clamp(28px,4.4vw,64px)] leading-[1.08] tracking-[-0.015em]`}>
          A page that reads itself aloud in your own time, line after line, and never faster than you can follow.
        </LineReveal>
      </section>

      <section className="px-6 py-24 md:px-12 md:py-40">
        <LineReveal {...shared} highlight className={`max-w-[26ch] ${serif} text-[clamp(32px,6vw,96px)] leading-[1.02] tracking-[-0.02em]`}>
          Good typography is the quiet part of motion. Let every sentence be earned by the scroll, and let the reader decide the pace.
        </LineReveal>
      </section>

      <section className="border-t border-current/15 px-6 py-24 md:px-12 md:py-40">
        <LineReveal {...shared} as="h2" className={`mb-16 max-w-[16ch] ${serif} text-[clamp(40px,7vw,104px)] leading-[0.96] tracking-[-0.025em] md:mb-28`}>
          Three small promises
        </LineReveal>
        <div className="grid gap-16 md:grid-cols-3 md:gap-12">
          {notes.map((note) => (
            <article key={note.number} className="border-t border-current/20 pt-5">
              <p className="mb-6 font-mono text-[11px] tracking-wide uppercase opacity-60">{note.number}</p>
              <LineReveal {...shared} as="h2" className={`mb-5 ${serif} text-[clamp(28px,3vw,44px)] leading-[1.05]`}>
                {note.title}
              </LineReveal>
              <LineReveal {...shared} as="p" className="max-w-[38ch] text-[16px] leading-[1.55] opacity-80">
                {note.text}
              </LineReveal>
            </article>
          ))}
        </div>
      </section>

      <div className="h-24 md:h-40" />
    </div>
  );
}
```

## Agent prompt

```text
Add the Line Reveal component (<LineReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/line-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/line-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/line-reveal.json

Then use it like this:
import { LineReveal } from "@/components/dimaac/line-reveal";
<LineReveal children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/line-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Liquid image reveal
description: An organic reveal with SVG filters.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/liquid-image-reveal
registry: https://ui.dimaac.com/r/liquid-image-reveal.json
---

# Liquid image reveal

A photo revealed through a circle that grows from a point, with an edge that wobbles like a puddle because an SVG noise filter pushes it around. It plays when the image scrolls into view, and clicking (or pressing Enter on) it closes the puddle and opens it again from that exact spot. It fills any box, resizes cleanly, ids are unique so several can share a page, and with reduced motion the image is simply there. Ported from the first DiMaac UI and rebuilt with useGSAP: a responsive box instead of fixed pixels, light octaves by default so phones stay smooth, a real accessible name, and a replay.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/liquid-image-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/liquid-image-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/liquid-image-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/liquid-image-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/liquid-image-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { LiquidImageReveal } from "@/components/dimaac/liquid-image-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { LiquidImageReveal } from "@/components/dimaac/liquid-image-reveal";

<LiquidImageReveal src={src} alt={alt} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` | - | The image address. |
| `alt` (required) | `string` | - | What the image shows. It becomes the accessible name of the whole piece. |
| `width` | `number \| string` | `"100%"` | Width of the box: a number in pixels or any CSS length. It fills its parent by default. |
| `height` | `number \| string` | `"100%"` | Height of the box: a number in pixels or any CSS length. It fills its parent by default. |
| `duration` | `number` | `2.2` | How long the reveal takes, in seconds. |
| `delay` | `number` | `0` | Seconds to wait before the reveal starts. |
| `ease` | `string` | `"power2.out"` | The GSAP ease of the reveal, e.g. "power2.out" or "expo.out". |
| `trigger` | `"visible" \| "mount"` | `"visible"` | When the first reveal plays: "visible" waits until the image is scrolled into view, "mount" plays straight away. |
| `replayOnClick` | `boolean` | `true` | Clicking (or pressing Enter on) the image closes the puddle and opens it again from that spot. |
| `centerX` | `number` | `0.5` | Where the puddle starts, across the box: 0 is the left edge, 0.5 the middle, 1 the right edge. |
| `centerY` | `number` | `0.5` | Where the puddle starts, down the box: 0 is the top edge, 0.5 the middle, 1 the bottom edge. |
| `maxRadius` | `number` | - | How big the circle gets, in pixels. Leave empty to grow just enough to cover the whole image. |
| `turbulenceFrequency` | `number` | `0.02` | How fine the wobble is. Small numbers give big slow waves, large numbers give small ripples. |
| `turbulenceOctaves` | `number` | `3` | Layers of detail in the wobble. More looks richer but costs more on phones. 2 to 4 is plenty. |
| `displacementScale` | `number` | `80` | How far the noise pushes the edge, in pixels. 0 is a clean circle. |
| `onComplete` | `() => void` | - | Called every time a reveal finishes. |
| `className` | `string` | `""` | Extra classes for the outer box, e.g. a radius. |

## Credits

- Photo: orange visor: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Photo: lime blazer: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Photo: neon profile: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License

## Source

### liquid-image-reveal.tsx → `components/dimaac/liquid-image-reveal.tsx`

```tsx
"use client";

import { useCallback, useId, useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// An image that opens like a drop of liquid. A circle grows from a point and the image shows
// through it. The edge of the circle is pushed around by an SVG noise filter, so it wobbles like
// a puddle instead of a clean circle. Click it and the puddle closes, then opens again from
// wherever you clicked.

export type LiquidImageRevealProps = {
  /** The image address. */
  src: string;
  /** What the image shows. It becomes the accessible name of the whole piece. */
  alt: string;
  /** Width of the box: a number in pixels or any CSS length. It fills its parent by default. */
  width?: number | string;
  /** Height of the box: a number in pixels or any CSS length. It fills its parent by default. */
  height?: number | string;
  /** How long the reveal takes, in seconds. */
  duration?: number;
  /** Seconds to wait before the reveal starts. */
  delay?: number;
  /** The GSAP ease of the reveal, e.g. "power2.out" or "expo.out". */
  ease?: string;
  /** When the first reveal plays: "visible" waits until the image is scrolled into view, "mount" plays straight away. */
  trigger?: "visible" | "mount";
  /** Clicking (or pressing Enter on) the image closes the puddle and opens it again from that spot. */
  replayOnClick?: boolean;
  /** Where the puddle starts, across the box: 0 is the left edge, 0.5 the middle, 1 the right edge. */
  centerX?: number;
  /** Where the puddle starts, down the box: 0 is the top edge, 0.5 the middle, 1 the bottom edge. */
  centerY?: number;
  /** How big the circle gets, in pixels. Leave empty to grow just enough to cover the whole image. */
  maxRadius?: number;
  /** How fine the wobble is. Small numbers give big slow waves, large numbers give small ripples. */
  turbulenceFrequency?: number;
  /** Layers of detail in the wobble. More looks richer but costs more on phones. 2 to 4 is plenty. */
  turbulenceOctaves?: number;
  /** How far the noise pushes the edge, in pixels. 0 is a clean circle. */
  displacementScale?: number;
  /** Called every time a reveal finishes. */
  onComplete?: () => void;
  /** Extra classes for the outer box, e.g. a radius. */
  className?: string;
};

const toCss = (value: number | string) => (typeof value === "number" ? `${value}px` : value);

export function LiquidImageReveal({
  src,
  alt,
  width = "100%",
  height = "100%",
  duration = 2.2,
  delay = 0,
  ease = "power2.out",
  trigger = "visible",
  replayOnClick = true,
  centerX = 0.5,
  centerY = 0.5,
  maxRadius,
  turbulenceFrequency = 0.02,
  turbulenceOctaves = 3,
  displacementScale = 80,
  onComplete,
  className = "",
}: LiquidImageRevealProps) {
  const boxRef = useRef<HTMLElement | null>(null);
  const setBox = useCallback((node: HTMLElement | null) => {
    boxRef.current = node;
  }, []);
  const maskRef = useRef<SVGMaskElement>(null);
  const filterRef = useRef<SVGFilterElement>(null);
  const circleRef = useRef<SVGCircleElement>(null);

  // SVG ids can't hold the colons React puts in useId, and two copies on a page need different ids
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const maskId = `liquid-mask-${uid}`;
  const filterId = `liquid-filter-${uid}`;

  // Values the animation reads while it runs. Only the callback is live; the look props restart the reveal
  // (see the dependencies below), so a new wobble is always shown by playing it, never hidden behind a finished mask.
  const live = useRef({ displacementScale, maxRadius, onComplete });
  live.current = { displacementScale, maxRadius, onComplete };

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const mask = maskRef.current;
      const filter = filterRef.current;
      const circle = circleRef.current;
      if (!box || !mask || !filter || !circle || !contextSafe) return;

      // progress: 0 is closed, 1 is fully open. The radius is worked out from it on every draw,
      // so a resize in the middle of a reveal still ends up covering the whole image.
      const state = { progress: 0 };
      const origin = { x: centerX, y: centerY };

      const draw = () => {
        const w = box.clientWidth;
        const h = box.clientHeight;
        const { displacementScale: scale, maxRadius: fixedRadius } = live.current;
        // The noise pulls pixels in from outside the box, so the filter and mask regions are a
        // little bigger than the box. Otherwise the edges would get transparent holes.
        const pad = Math.ceil(scale) + 2;
        for (const el of [mask, filter]) {
          el.setAttribute("x", String(-pad));
          el.setAttribute("y", String(-pad));
          el.setAttribute("width", String(w + pad * 2));
          el.setAttribute("height", String(h + pad * 2));
        }
        const cx = origin.x * w;
        const cy = origin.y * h;
        // The farthest corner, plus the most the noise can pull the edge back
        const farthest = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy)) + scale * 0.6;
        circle.setAttribute("cx", String(cx));
        circle.setAttribute("cy", String(cy));
        circle.setAttribute("r", String(state.progress * (fixedRadius ?? farthest)));
      };

      draw();
      const resizeObserver = new ResizeObserver(draw);
      resizeObserver.observe(box);

      // Reduced motion: no growing circle. The image is simply there.
      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      if (reduced) {
        state.progress = 1;
        draw();
        return () => resizeObserver.disconnect();
      }

      const open = (wait: number) => {
        gsap.to(state, {
          progress: 1,
          duration,
          delay: wait,
          ease,
          onUpdate: draw,
          onComplete: () => live.current.onComplete?.(),
        });
      };

      // Close quickly toward the old spot, then open again from the new one
      const reopenFrom = (x: number, y: number) => {
        gsap.killTweensOf(state);
        gsap.to(state, {
          progress: 0,
          duration: Math.min(0.6, duration * 0.3),
          ease: "power2.in",
          onUpdate: draw,
          onComplete: () => {
            origin.x = x;
            origin.y = y;
            open(0.05);
          },
        });
      };

      const onClick = contextSafe((event: MouseEvent) => {
        if (!replayOnClick) return;
        const rect = box.getBoundingClientRect();
        // A keyboard press has no position (detail is 0), so it reopens from the usual spot
        const fromKeyboard = event.detail === 0;
        const x = fromKeyboard ? centerX : (event.clientX - rect.left) / rect.width;
        const y = fromKeyboard ? centerY : (event.clientY - rect.top) / rect.height;
        reopenFrom(Math.min(1, Math.max(0, x)), Math.min(1, Math.max(0, y)));
      });
      box.addEventListener("click", onClick);

      // Wait until the image is on screen, so the reveal isn't spent while nobody is looking
      let visibility: IntersectionObserver | null = null;
      if (trigger === "visible" && typeof IntersectionObserver !== "undefined") {
        visibility = new IntersectionObserver(
          ([entry]) => {
            if (!entry?.isIntersecting) return;
            visibility?.disconnect();
            open(delay);
          },
          { threshold: 0.35 },
        );
        visibility.observe(box);
      } else {
        open(delay);
      }

      return () => {
        visibility?.disconnect();
        resizeObserver.disconnect();
        box.removeEventListener("click", onClick);
        gsap.killTweensOf(state);
      };
    },
    { scope: boxRef, dependencies: [duration, delay, ease, trigger, replayOnClick, centerX, centerY, displacementScale, turbulenceFrequency, turbulenceOctaves, maxRadius], revertOnUpdate: true },
  );

  const boxStyle: CSSProperties = { width: toCss(width), height: toCss(height) };

  const picture = (
    <svg className="absolute inset-0 block h-full w-full" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
      <defs>
        {/* The liquid edge: noise that pushes the circle's pixels around. sRGB keeps the push
            even in every direction. */}
        <filter id={filterId} filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB" ref={filterRef}>
          <feTurbulence type="fractalNoise" baseFrequency={turbulenceFrequency} numOctaves={turbulenceOctaves} result="noise" />
          <feDisplacementMap in="SourceGraphic" in2="noise" scale={displacementScale} xChannelSelector="R" yChannelSelector="G" />
        </filter>
        {/* White shows the image, black hides it. The growing circle is the white part. */}
        <mask id={maskId} maskUnits="userSpaceOnUse" ref={maskRef}>
          <circle ref={circleRef} r="0" fill="white" filter={`url(#${filterId})`} />
        </mask>
      </defs>
      <image href={src} width="100%" height="100%" preserveAspectRatio="xMidYMid slice" mask={`url(#${maskId})`} />
    </svg>
  );

  const shared = `relative block overflow-hidden ${className}`;

  if (replayOnClick) {
    return (
      <button
        type="button"
        ref={setBox}
        aria-label={`${alt}. Press to replay the reveal.`}
        className={`${shared} cursor-pointer border-0 bg-transparent p-0 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`}
        style={boxStyle}
      >
        {picture}
      </button>
    );
  }

  return (
    <div ref={setBox} role="img" aria-label={alt} className={shared} style={boxStyle}>
      {picture}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { LiquidImageReveal, type LiquidImageRevealProps } from "@/components/dimaac/liquid-image-reveal";

// Three portraits, each opening from a different spot and a beat after the one before it.
// Click any of them to close the puddle and open it again from where you clicked.
// Photos from Lummi (Lummi License: free for personal and commercial use).

const frames = [
  { file: "fashion/orange-visor-4x5.jpg", alt: "A woman in an orange visor and navy dress against a teal background", title: "Visor", note: "No. 01", x: 0.5, y: 0.5 },
  { file: "portraits/lime-blazer-4x5.jpg", alt: "A person with curly red hair in a lime green blazer against a pink background", title: "Blazer", note: "No. 02", x: 0.2, y: 0.8 },
  { file: "portraits/neon-profile-4x5.jpg", alt: "A woman in neon pink sunglasses in profile against a blue background", title: "Neon", note: "No. 03", x: 0.8, y: 0.25 },
];

export default function Demo(props: Partial<LiquidImageRevealProps>) {
  const { delay = 0, centerX = 0.5, centerY = 0.5, ...rest } = props;
  // Each frame has its own start spot. Moving the playground's Start sliders sends all three to the same one.
  const customStart = centerX !== 0.5 || centerY !== 0.5;
  const startX = (frame: (typeof frames)[number]) => (customStart ? centerX : frame.x);
  const startY = (frame: (typeof frames)[number]) => (customStart ? centerY : frame.y);

  return (
    <div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f3f0eb] px-5 py-5 text-[#1c1917] md:px-12 md:py-9 dark:bg-[#0f0d0c] dark:text-[#ece4dc]">
      <div className="grid min-h-0 flex-1 grid-cols-2 grid-rows-[1.2fr_1fr] gap-x-3 gap-y-3 md:grid-cols-3 md:grid-rows-1 md:gap-6">
        {frames.map((frame, index) => (
          <figure key={frame.file} className={`flex min-h-0 flex-col gap-2.5 md:gap-3 ${index === 0 ? "col-span-2 md:col-span-1" : ""}`}>
            <div className="min-h-0 flex-1 rounded-[3px] bg-black/[0.06] dark:bg-white/[0.06]">
              <LiquidImageReveal
                {...rest}
                src={media(frame.file)}
                alt={frame.alt}
                delay={delay + index * 0.35}
                centerX={startX(frame)}
                centerY={startY(frame)}
                className="rounded-[3px]"
              />
            </div>
            <figcaption className="flex shrink-0 justify-between font-mono text-[10px] tracking-wide uppercase opacity-70 md:text-[11px]">
              <span>{frame.title}</span>
              <span>{frame.note}</span>
            </figcaption>
          </figure>
        ))}
      </div>

      <p className="mt-4 shrink-0 text-center font-mono text-[11px] tracking-wide uppercase opacity-60 md:mt-6">Click a frame to pour it again</p>
    </div>
  );
}
```

## Agent prompt

```text
Add the Liquid image reveal component (<LiquidImageReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/liquid-image-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/liquid-image-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/liquid-image-reveal.json

Then use it like this:
import { LiquidImageReveal } from "@/components/dimaac/liquid-image-reveal";
<LiquidImageReveal src={src} alt={alt} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/liquid-image-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Magnetic Button
description: A button that leans toward the cursor and snaps back like a spring.
category: Buttons
tier: free
url: https://ui.dimaac.com/components/magnetic-button
registry: https://ui.dimaac.com/r/magnetic-button.json
---

# Magnetic Button

A real button (or link) that leans toward the pointer as it comes near, then snaps back with an elastic ease. The label leans a little further than the button, which gives it depth, and a circle of colour grows from the exact spot the pointer came in and closes where it left. Keyboards get a focus ring and a centred fill instead of the magnet, touch screens get the fill while a finger is down, and visitors who prefer reduced motion get a plain colour change. Three variants (solid, outline, ghost), renders as a <button> or an <a>, and the colours follow CSS variables so it fits light and dark themes.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/magnetic-button`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/magnetic-button.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { MagneticButton } from "@/components/dimaac/magnetic-button" and give its parent a size: it fills it.

## Usage

```tsx
import { MagneticButton } from "@/components/dimaac/magnetic-button";

<MagneticButton children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | - | The label. Text, or text with an icon. |
| `strength` | `number` | `0.35` | How far the button leans toward the cursor. 0 = not at all, 1 = all the way to the cursor. |
| `labelStrength` | `number` | `0.5` | How much further the label leans than the button, as a share of the button's own lean. |
| `radius` | `number` | `80` | How far outside the button, in pixels, the magnet starts to pull. |
| `fill` | `string` | `"#e8532f"` | The colour of the circle that fills the button. Any CSS colour. |
| `fillTextColor` | `string` | `"#ffffff"` | The label colour while the fill is showing. Any CSS colour. |
| `variant` | `"solid" \| "outline" \| "ghost"` | `"solid"` | "solid" is a filled pill, "outline" a ring, "ghost" a soft tint with no ring. |
| `as` | `"button" \| "a"` | `"button"` | Render a <button> or a link. A link needs an href. |
| `href` | `string` | - | Where the link goes. Only used when as="a". |
| `target` | `string` | - | Link target, e.g. "_blank". Only used when as="a". |
| `rel` | `string` | - | Link rel. A _blank link gets "noopener noreferrer" by default. |
| `type` | `"button" \| "submit" \| "reset"` | `"button"` | The button type. Only used when as="button". |
| `disabled` | `boolean` | `false` | Turns the button off. Only used when as="button". |
| `onClick` | `MouseEventHandler<HTMLElement>` | - | Runs on click or on Enter and Space. |
| `ariaLabel` | `string` | - | Accessible name, if the visible label is not enough. |
| `className` | `string` | `""` | Extra classes for the outer wrapper (layout: margins, width). |
| `buttonClassName` | `string` | `""` | Extra classes for the button itself (size, font, radius). |

## Credits

- Photo: Siren Media, Lummi (https://www.lummi.ai/photo/minimalist-stucco-building-gurez), Lummi License (free for personal and commercial use)

## Source

### magnetic-button.tsx → `components/dimaac/magnetic-button.tsx`

```tsx
"use client";

import { useRef, type CSSProperties, type MouseEventHandler, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A button that leans toward the cursor and snaps back with an elastic ease. The label leans a
// little further than the button (a parallax), and a circle of colour grows from the exact spot
// the pointer came in. Keyboards get no magnet (just a centred fill and a focus ring) and touch
// screens get the fill on press only. It is a real <button> or <a>, so it behaves like one.

export type MagneticButtonProps = {
  /** The label. Text, or text with an icon. */
  children: ReactNode;
  /** How far the button leans toward the cursor. 0 = not at all, 1 = all the way to the cursor. */
  strength?: number;
  /** How much further the label leans than the button, as a share of the button's own lean. */
  labelStrength?: number;
  /** How far outside the button, in pixels, the magnet starts to pull. */
  radius?: number;
  /** The colour of the circle that fills the button. Any CSS colour. */
  fill?: string;
  /** The label colour while the fill is showing. Any CSS colour. */
  fillTextColor?: string;
  /** "solid" is a filled pill, "outline" a ring, "ghost" a soft tint with no ring. */
  variant?: "solid" | "outline" | "ghost";
  /** Render a <button> or a link. A link needs an href. */
  as?: "button" | "a";
  /** Where the link goes. Only used when as="a". */
  href?: string;
  /** Link target, e.g. "_blank". Only used when as="a". */
  target?: string;
  /** Link rel. A _blank link gets "noopener noreferrer" by default. */
  rel?: string;
  /** The button type. Only used when as="button". */
  type?: "button" | "submit" | "reset";
  /** Turns the button off. Only used when as="button". */
  disabled?: boolean;
  /** Runs on click or on Enter and Space. */
  onClick?: MouseEventHandler<HTMLElement>;
  /** Accessible name, if the visible label is not enough. */
  ariaLabel?: string;
  /** Extra classes for the outer wrapper (layout: margins, width). */
  className?: string;
  /** Extra classes for the button itself (size, font, radius). */
  buttonClassName?: string;
};

// Colours come from CSS variables so a page can theme the button without touching the code:
//   --mb-solid-bg / --mb-solid-fg for the solid variant (default: dark pill, light text)
const variantClasses = {
  solid: "bg-[var(--mb-solid-bg,#171412)] text-[var(--mb-solid-fg,#f7f3ee)]",
  outline: "bg-transparent",
  ghost: "bg-[color-mix(in_srgb,currentColor_8%,transparent)]",
} as const;

export function MagneticButton({
  children,
  strength = 0.35,
  labelStrength = 0.5,
  radius = 80,
  fill = "#e8532f",
  fillTextColor = "#ffffff",
  variant = "solid",
  as = "button",
  href,
  target,
  rel,
  type = "button",
  disabled = false,
  onClick,
  ariaLabel,
  className = "",
  buttonClassName = "",
}: MagneticButtonProps) {
  const wrapperRef = useRef<HTMLSpanElement>(null);
  const buttonRef = useRef<HTMLElement | null>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const fillRef = useRef<HTMLSpanElement>(null);

  // Read on every pointer move, so changing them never rebuilds the effect
  const settings = useRef({ strength, labelStrength, radius });
  settings.current = { strength, labelStrength, radius };

  useGSAP(
    (_context, contextSafe) => {
      const wrapper = wrapperRef.current;
      const button = buttonRef.current;
      const label = labelRef.current;
      const circle = fillRef.current;
      if (!wrapper || !button || !label || !circle || !contextSafe) return;

      const reducedQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
      // The magnet is for a mouse or a trackpad. Touch screens have no hover to follow.
      const hoverQuery = window.matchMedia("(hover: hover) and (pointer: fine)");

      gsap.set(circle, { xPercent: -50, yPercent: -50, scale: 0 });

      // ---- The fill circle ----
      let filled = false;
      const grow = (x: number, y: number) => {
        if (filled || disabled) return;
        filled = true;
        // The label colour change is plain CSS (see data-filled in the class list)
        button.dataset.filled = "true";
        const { width, height } = button.getBoundingClientRect();
        // Big enough to cover the farthest corner, wherever the pointer came in
        const size = Math.hypot(Math.max(x, width - x), Math.max(y, height - y)) * 2;
        gsap.set(circle, { width: size, height: size, x, y });
        if (reducedQuery.matches) {
          gsap.to(circle, { scale: 1, opacity: 1, duration: 0.15, overwrite: true });
          return;
        }
        gsap.to(circle, { scale: 1, duration: 0.6, ease: "power3.out", overwrite: true });
      };
      const shrink = (x: number, y: number) => {
        if (!filled) return;
        filled = false;
        delete button.dataset.filled;
        if (reducedQuery.matches) {
          gsap.to(circle, { scale: 0, duration: 0.15, overwrite: true });
        } else {
          // The circle slides out to where the pointer left, then closes there
          gsap.to(circle, { x, y, scale: 0, duration: 0.45, ease: "power3.inOut", overwrite: true });
        }
      };

      const local = (event: PointerEvent) => {
        const rect = button.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
      };

      const onEnter = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        const p = local(event);
        grow(p.x, p.y);
      });
      const onLeave = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        const p = local(event);
        shrink(p.x, p.y);
      });

      // Touch: no hover, so the fill grows while the finger is down
      const onDown = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") {
          const p = local(event);
          grow(p.x, p.y);
        }
        if (!reducedQuery.matches && !disabled) gsap.to(button, { scale: 0.96, duration: 0.2, ease: "power2.out", overwrite: "auto" });
      });
      const onUp = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") {
          const p = local(event);
          shrink(p.x, p.y);
        }
        gsap.to(button, { scale: 1, duration: reducedQuery.matches ? 0 : 0.6, ease: "elastic.out(1, 0.4)", overwrite: "auto" });
      });

      // Keyboard: a focus ring (CSS) plus the fill from the middle
      const onFocus = contextSafe(() => {
        if (button.matches(":focus-visible")) grow(button.clientWidth / 2, button.clientHeight / 2);
      });
      const onBlur = contextSafe(() => shrink(button.clientWidth / 2, button.clientHeight / 2));

      // ---- The magnet ----
      let attracted = false;
      const limitX = gsap.utils.clamp(-20, 20);
      const limitY = gsap.utils.clamp(-9, 9);

      const release = () => {
        if (!attracted) return;
        attracted = false;
        const reduced = reducedQuery.matches;
        const options = reduced ? { duration: 0 } : { duration: 1.2, ease: "elastic.out(1, 0.3)" };
        gsap.to(button, { x: 0, y: 0, overwrite: "auto", ...options });
        gsap.to(label, { x: 0, y: 0, overwrite: "auto", ...options });
        // Stay above neighbours until the spring has mostly settled
        gsap.set(wrapper, { zIndex: 0, delay: reduced ? 0 : 0.7, overwrite: true });
      };

      const onMove = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch" || !hoverQuery.matches || reducedQuery.matches || disabled) return;
        // The wrapper never moves, so measuring it gives a steady centre to pull toward
        const rect = wrapper.getBoundingClientRect();
        const dx = event.clientX - (rect.left + rect.width / 2);
        const dy = event.clientY - (rect.top + rect.height / 2);
        // Distance from the pointer to the button's edge (0 when the pointer is over it)
        const outsideX = Math.max(Math.abs(dx) - rect.width / 2, 0);
        const outsideY = Math.max(Math.abs(dy) - rect.height / 2, 0);
        const { strength: pull, labelStrength: extra, radius: reach } = settings.current;
        if (Math.hypot(outsideX, outsideY) > reach) {
          release();
          return;
        }
        if (!attracted) gsap.set(wrapper, { zIndex: 1, overwrite: true });
        attracted = true;
        gsap.to(button, { x: dx * pull, y: dy * pull, duration: 0.45, ease: "power3.out", overwrite: "auto" });
        // The label's own lean is on top of the button's, so it travels further (but stays inside the pill)
        gsap.to(label, { x: limitX(dx * pull * extra), y: limitY(dy * pull * extra), duration: 0.45, ease: "power3.out", overwrite: "auto" });
      });
      const onWindowLeave = contextSafe((event: MouseEvent) => {
        if (!event.relatedTarget) release();
      });

      button.addEventListener("pointerenter", onEnter);
      button.addEventListener("pointerleave", onLeave);
      button.addEventListener("pointerdown", onDown);
      button.addEventListener("pointerup", onUp);
      button.addEventListener("pointercancel", onUp);
      button.addEventListener("focus", onFocus);
      button.addEventListener("blur", onBlur);
      window.addEventListener("pointermove", onMove, { passive: true });
      document.documentElement.addEventListener("mouseleave", onWindowLeave);

      return () => {
        button.removeEventListener("pointerenter", onEnter);
        button.removeEventListener("pointerleave", onLeave);
        button.removeEventListener("pointerdown", onDown);
        button.removeEventListener("pointerup", onUp);
        button.removeEventListener("pointercancel", onUp);
        button.removeEventListener("focus", onFocus);
        button.removeEventListener("blur", onBlur);
        window.removeEventListener("pointermove", onMove);
        document.documentElement.removeEventListener("mouseleave", onWindowLeave);
        delete button.dataset.filled;
      };
    },
    { scope: wrapperRef, dependencies: [as, disabled], revertOnUpdate: true },
  );

  const safeRel = rel ?? (target === "_blank" ? "noopener noreferrer" : undefined);
  const shared = {
    ref: (node: HTMLElement | null) => {
      buttonRef.current = node;
    },
    onClick,
    "aria-label": ariaLabel,
    className: `relative isolate inline-flex min-h-[52px] cursor-pointer items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-[15px] leading-none font-medium tracking-[-0.01em] whitespace-nowrap no-underline select-none outline-offset-4 transition-colors duration-300 data-[filled=true]:text-[var(--mb-fill-text)] focus-visible:outline-2 focus-visible:outline-[var(--mb-focus,currentColor)] ${variantClasses[variant]} ${disabled ? "cursor-not-allowed opacity-50" : ""} ${buttonClassName}`,
    style: { "--mb-fill-text": fillTextColor, touchAction: "manipulation", WebkitTapHighlightColor: "transparent", willChange: "transform" } as CSSProperties,
  };

  const content = (
    <>
      {/* The ring (outline variant). It sits under the fill so the fill covers it. */}
      {variant === "outline" && <span aria-hidden className="pointer-events-none absolute inset-0 -z-20 rounded-full border-[1.5px] border-current opacity-80" />}
      {/* The fill circle: decorative, so hidden from screen readers */}
      <span ref={fillRef} aria-hidden className="pointer-events-none absolute top-0 left-0 -z-10 rounded-full" style={{ background: fill, width: 0, height: 0, transform: "scale(0)" }} />
      <span ref={labelRef} className="relative inline-flex items-center gap-2" style={{ willChange: "transform" }}>
        {children}
      </span>
    </>
  );

  return (
    <span ref={wrapperRef} className={`relative inline-flex ${className}`}>
      {as === "a" ? (
        <a {...shared} href={disabled ? undefined : href} target={target} rel={safeRel} aria-disabled={disabled || undefined}>
          {content}
        </a>
      ) : (
        <button {...shared} type={type} disabled={disabled}>
          {content}
        </button>
      )}
    </span>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { MagneticButton, type MagneticButtonProps } from "@/components/dimaac/magnetic-button";

// A calm product page. The playground controls go to the big solid button; the two small ones
// share the strength, radius and colour so you can see the same feel on every variant.
// Photo: Siren Media on Lummi (Lummi License).

const arrow = (
  <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
    <path d="M3 8h10M9 4l4 4-4 4" />
  </svg>
);

export default function Demo(props: Partial<MagneticButtonProps>) {
  const { children, className, variant, as, href, ...shared } = props;
  return (
    <div
      className="relative flex h-full w-full flex-col overflow-hidden bg-[#f3efe9] text-[#1c1917] [--mb-focus:#1c1917] [--mb-solid-bg:#171412] [--mb-solid-fg:#f7f3ee] dark:bg-[#0f0d0c] dark:text-[#ece4dc] dark:[--mb-focus:#ece4dc] dark:[--mb-solid-bg:#ece4dc] dark:[--mb-solid-fg:#171412]"
      style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
    >
      <header className="flex items-center justify-between px-6 pt-6 text-[13px] tracking-wide uppercase opacity-70 md:px-12 md:pt-9">
        <span className="font-medium">DiMaac</span>
        <MagneticButton {...shared} variant="ghost" as="a" href="#components" radius={50} buttonClassName="!min-h-[40px] !px-5 !text-[13px]">
          Components
        </MagneticButton>
      </header>

      <main className="flex min-h-0 flex-1 flex-col justify-center gap-8 px-6 pb-8 md:grid md:grid-cols-[1.15fr_0.85fr] md:items-center md:gap-16 md:px-12 md:pb-12">
        <div className="max-w-[640px]">
          <h1 className="text-[clamp(40px,6.4vw,104px)] leading-[0.94] font-medium tracking-[-0.045em]">
            Quiet rooms,
            <br />
            made to be touched.
          </h1>
          <p className="mt-5 max-w-[38ch] text-[15px] leading-relaxed opacity-65 md:mt-7 md:text-base">Come closer. The buttons here lean in to meet you, and settle back when you go.</p>

          <div className="mt-8 flex flex-wrap items-center gap-x-10 gap-y-5 md:mt-12 md:gap-x-14">
            <MagneticButton {...shared} variant={variant ?? "solid"} as={as} href={as === "a" ? (href ?? "#project") : undefined} className={className}>
              {children ?? (
                <>
                  Start a project {arrow}
                </>
              )}
            </MagneticButton>
            <MagneticButton {...shared} variant="outline">
              View the work
            </MagneticButton>
          </div>
        </div>

        <div className="relative h-[200px] shrink-0 overflow-hidden rounded-[20px] md:h-full md:max-h-[640px] md:min-h-0">
          <img src="https://ui.dimaac.com/media/architecture/pink-stucco-2x3.jpg" alt="White and pink stucco walls with wooden windows under a deep blue sky" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
        </div>
      </main>
    </div>
  );
}
```

## Agent prompt

```text
Add the Magnetic Button component (<MagneticButton />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/magnetic-button
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/magnetic-button.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json

Then use it like this:
import { MagneticButton } from "@/components/dimaac/magnetic-button";
<MagneticButton children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/magnetic-button.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Mask Reveal on Hover
description: A round window that follows the cursor and shows different content underneath.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/mask-reveal-on-hover
registry: https://ui.dimaac.com/r/mask-reveal-on-hover.json
---

# Mask Reveal on Hover

A circular window follows the cursor and shows a second layer on top of the first: a bright colour with different words. The window stays small while you move through empty space and grows when you are over the content. It is a CSS radial-gradient mask driven by three variables that GSAP animates with quickTo, so it is cheap on every pointer move. It listens only inside its own box, follows a finger on touch screens, can be walked around with the arrow keys, keeps the hidden words available to screen readers once, and snaps without easing when the visitor prefers reduced motion. Ported from the first DiMaac UI and rebuilt: no tween pile-up, no page-wide listener, no overflow on phones.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/mask-reveal-on-hover.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/mask-reveal-on-hover.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/mask-reveal-on-hover.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/mask-reveal-on-hover`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/mask-reveal-on-hover.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { MaskRevealOnHover } from "@/components/dimaac/mask-reveal-on-hover" and give its parent a size: it fills it.

## Usage

```tsx
import { MaskRevealOnHover } from "@/components/dimaac/mask-reveal-on-hover";

<MaskRevealOnHover content={content} reveal={reveal} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `content` (required) | `ReactNode` | - | What people see normally. |
| `reveal` (required) | `ReactNode` | - | What shows inside the round window. It is laid on top of `content`, so give both the same width and they line up. |
| `size` | `number` | `20` | Radius of the window while it is only moving around, in pixels. |
| `hoverSize` | `number` | `100` | Radius of the window while it is over the content, in pixels. |
| `feather` | `number` | `0` | How soft the edge of the window is, in pixels. 0 is a clean circle. |
| `duration` | `number` | `0.6` | How long the window takes to catch up with the cursor, in seconds. |
| `ease` | `string` | `"back.out(1.7)"` | The GSAP ease of that catch-up, e.g. "power3.out" or "back.out(1.7)". |
| `growOn` | `"content" \| "stage"` | `"content"` | Where the window grows to its big size: only over the content, or anywhere in the box. |
| `maskBackground` | `string` | `"#DDFC3E"` | The colour of the layer inside the window. Any CSS colour or gradient. |
| `hideCursor` | `boolean` | `true` | Hide the normal cursor inside the box, so the window is the cursor. |
| `lockTouchScroll` | `boolean` | `true` | Stop a finger from scrolling the page while it drags the window. Turn it off if the box sits in a long page. |
| `label` | `string` | `"Move the cursor over the text to reveal what is underneath. With the keyboard, use the arrow keys."` | Describes the box for screen readers and keyboard users. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |

## Source

### mask-reveal-on-hover.tsx → `components/dimaac/mask-reveal-on-hover.tsx`

```tsx
"use client";

import { useRef, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A round window that follows the cursor. Through it you see a second layer (a colour and
// different content) that sits exactly on top of the first one. The window is a CSS mask:
// a radial gradient whose centre and radius are three CSS variables, and GSAP animates them.
// The window is small while you move over empty space and grows when you are over the content.
// On touch screens it follows your finger. With the keyboard, Tab to it and use the arrow keys.

export type MaskRevealOnHoverProps = {
  /** What people see normally. */
  content: ReactNode;
  /** What shows inside the round window. It is laid on top of `content`, so give both the same width and they line up. */
  reveal: ReactNode;
  /** Radius of the window while it is only moving around, in pixels. */
  size?: number;
  /** Radius of the window while it is over the content, in pixels. */
  hoverSize?: number;
  /** How soft the edge of the window is, in pixels. 0 is a clean circle. */
  feather?: number;
  /** How long the window takes to catch up with the cursor, in seconds. */
  duration?: number;
  /** The GSAP ease of that catch-up, e.g. "power3.out" or "back.out(1.7)". */
  ease?: string;
  /** Where the window grows to its big size: only over the content, or anywhere in the box. */
  growOn?: "content" | "stage";
  /** The colour of the layer inside the window. Any CSS colour or gradient. */
  maskBackground?: string;
  /** Hide the normal cursor inside the box, so the window is the cursor. */
  hideCursor?: boolean;
  /** Stop a finger from scrolling the page while it drags the window. Turn it off if the box sits in a long page. */
  lockTouchScroll?: boolean;
  /** Describes the box for screen readers and keyboard users. */
  label?: string;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// The window never needs to be sharper than one pixel, which also keeps the edge smooth
const HAIRLINE = 1;
const KEY_STEP = 56;

const hiddenStyle: CSSProperties = {
  position: "absolute",
  width: 1,
  height: 1,
  overflow: "hidden",
  clip: "rect(0 0 0 0)",
  whiteSpace: "nowrap",
};

export function MaskRevealOnHover({
  content,
  reveal,
  size = 20,
  hoverSize = 100,
  feather = 0,
  duration = 0.6,
  ease = "back.out(1.7)",
  growOn = "content",
  maskBackground = "#DDFC3E",
  hideCursor = true,
  lockTouchScroll = true,
  label = "Move the cursor over the text to reveal what is underneath. With the keyboard, use the arrow keys.",
  className = "",
}: MaskRevealOnHoverProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const maskRef = useRef<HTMLDivElement>(null);
  // What the window is doing, kept outside the effect so a prop change (the playground) doesn't make it vanish or jump.
  // x, y and size are what is painted right now; goalX and goalY are where it is heading.
  const lens = useRef({ x: 0, y: 0, size: 0, goalX: 0, goalY: 0, present: false, overContent: false, keyboard: false });

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const inner = contentRef.current;
      const mask = maskRef.current;
      if (!box || !inner || !mask || !contextSafe) return;

      const state = lens.current;
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");

      // Write the three numbers into the CSS variables the mask reads
      const paint = () => {
        mask.style.setProperty("--mask-x", `${state.x}px`);
        mask.style.setProperty("--mask-y", `${state.y}px`);
        mask.style.setProperty("--mask-size", `${Math.max(0, state.size)}px`);
      };

      // quickTo is made for this: cheap to call on every pointer move, always eased toward the newest value.
      // To make the window jump, the old ones are dropped and new ones are made.
      let moveX = gsap.quickTo(state, "x", { duration, ease, onUpdate: paint });
      let moveY = gsap.quickTo(state, "y", { duration, ease, onUpdate: paint });
      const makeFollowers = () => {
        gsap.killTweensOf(state, "x,y");
        moveX = gsap.quickTo(state, "x", { duration, ease, onUpdate: paint });
        moveY = gsap.quickTo(state, "y", { duration, ease, onUpdate: paint });
      };

      let sizeTween: gsap.core.Tween | null = null;

      const wantedSize = () => {
        if (!state.present) return 0;
        return growOn === "stage" || state.overContent || state.keyboard ? hoverSize : size;
      };

      const updateSize = () => {
        const wanted = wantedSize();
        sizeTween?.kill();
        if (reducedMotion.matches) {
          state.size = wanted;
          paint();
          return;
        }
        const growing = wanted > state.size;
        sizeTween = gsap.to(state, { size: wanted, duration: growing ? 0.4 : 0.3, ease: growing ? "power2.out" : "power2.in", onUpdate: paint });
      };

      // The first touch of a visit puts the window right under the finger, instead of sweeping in from where it was
      const jumpTo = (x: number, y: number) => {
        state.x = state.goalX = x;
        state.y = state.goalY = y;
        makeFollowers();
        paint();
      };

      const goTo = (x: number, y: number) => {
        if (reducedMotion.matches) {
          jumpTo(x, y);
          return;
        }
        state.goalX = x;
        state.goalY = y;
        moveX(x);
        moveY(y);
      };

      const follow = (event: PointerEvent) => {
        const rect = box.getBoundingClientRect();
        const x = event.clientX - rect.left;
        const y = event.clientY - rect.top;
        const wasPresent = state.present;
        const wasOver = state.overContent;
        state.keyboard = false;
        state.present = true;
        // A finger keeps the element it first touched as the event target for the whole drag, so for touch and pen
        // we ask what is under the finger right now. (The mask layer ignores pointers, so it never gets in the way.)
        const under = event.pointerType === "mouse" ? event.target : document.elementFromPoint(event.clientX, event.clientY);
        state.overContent = under instanceof Node && inner.contains(under);
        if (!wasPresent) jumpTo(x, y);
        else goTo(x, y);
        if (!wasPresent || wasOver !== state.overContent) updateSize();
      };

      const leave = () => {
        state.present = false;
        state.overContent = false;
        state.keyboard = false;
        updateSize();
      };

      const onPointerMove = contextSafe((event: PointerEvent) => {
        if (!event.isPrimary) return;
        follow(event);
      });
      const onPointerDown = contextSafe((event: PointerEvent) => {
        if (!event.isPrimary) return;
        follow(event);
      });
      const onPointerLeave = contextSafe((event: PointerEvent) => {
        if (!event.isPrimary) return;
        leave();
      });
      // A finger that lifts is gone; a mouse button that lifts is still over the box
      const onPointerUp = contextSafe((event: PointerEvent) => {
        if (event.isPrimary && event.pointerType !== "mouse") leave();
      });

      // Keyboard: Tab puts the big window in the middle, the arrow keys walk it around
      const onFocus = contextSafe(() => {
        if (!box.matches(":focus-visible")) return;
        state.keyboard = true;
        state.present = true;
        state.overContent = true;
        jumpTo(box.clientWidth / 2, box.clientHeight / 2);
        updateSize();
      });
      const onBlur = contextSafe(() => {
        if (state.keyboard) leave();
      });
      const onKeyDown = contextSafe((event: KeyboardEvent) => {
        if (!state.keyboard) return;
        const step = { ArrowLeft: [-KEY_STEP, 0], ArrowRight: [KEY_STEP, 0], ArrowUp: [0, -KEY_STEP], ArrowDown: [0, KEY_STEP] }[event.key];
        if (!step) return;
        event.preventDefault();
        // Step from where the window is heading, so quick key presses add up
        goTo(gsap.utils.clamp(0, box.clientWidth, state.goalX + step[0]!), gsap.utils.clamp(0, box.clientHeight, state.goalY + step[1]!));
      });

      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerdown", onPointerDown);
      box.addEventListener("pointerleave", onPointerLeave);
      box.addEventListener("pointerup", onPointerUp);
      box.addEventListener("pointercancel", onPointerUp);
      box.addEventListener("focus", onFocus);
      box.addEventListener("blur", onBlur);
      box.addEventListener("keydown", onKeyDown);

      // A prop change rebuilds this effect: carry on from where the window already is, at the new size
      state.size = wantedSize();
      paint();
      if (state.present && !reducedMotion.matches) {
        moveX(state.goalX);
        moveY(state.goalY);
      }

      return () => {
        box.removeEventListener("pointermove", onPointerMove);
        box.removeEventListener("pointerdown", onPointerDown);
        box.removeEventListener("pointerleave", onPointerLeave);
        box.removeEventListener("pointerup", onPointerUp);
        box.removeEventListener("pointercancel", onPointerUp);
        box.removeEventListener("focus", onFocus);
        box.removeEventListener("blur", onBlur);
        box.removeEventListener("keydown", onKeyDown);
        sizeTween?.kill();
        gsap.killTweensOf(state);
      };
    },
    { scope: boxRef, dependencies: [size, hoverSize, feather, duration, ease, growOn], revertOnUpdate: true },
  );

  // A circle of `--mask-size` around (`--mask-x`, `--mask-y`): solid inside, clear outside
  // A soft edge can be at most 60% of the radius, so the small idle dot stays solid in the middle
  const edge = `max(${HAIRLINE}px, min(${feather}px, var(--mask-size, 0px) * 0.6))`;
  const circle = `radial-gradient(circle at var(--mask-x, -100px) var(--mask-y, -100px), #000 calc(var(--mask-size, 0px) - ${edge}), transparent var(--mask-size, 0px))`;

  return (
    <div
      ref={boxRef}
      role="group"
      aria-label={label}
      tabIndex={0}
      className={`relative isolate grid h-full w-full place-items-center overflow-hidden outline-offset-[-3px] ${className}`}
      style={{ cursor: hideCursor ? "none" : undefined, touchAction: lockTouchScroll ? "none" : "pan-y" }}
    >
      <div ref={contentRef} className="relative z-0 max-w-full">
        {content}
      </div>

      {/* The same words as the layer above, but for screen readers only, so nothing is said twice */}
      <div style={hiddenStyle}>{reveal}</div>

      <div
        ref={maskRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-10 grid place-items-center"
        style={{ background: maskBackground, WebkitMaskImage: circle, maskImage: circle, willChange: "mask-image" }}
      >
        {reveal}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { MaskRevealOnHover, type MaskRevealOnHoverProps } from "@/components/dimaac/mask-reveal-on-hover";

// Both layers use exactly the same box (same width, size and line height), so the words in the
// window sit right on top of the words underneath.
const textBox = "w-full max-w-[1040px] px-7 text-[clamp(22px,3.6vw,48px)] leading-[1.18] font-medium tracking-[-0.025em]";

export default function Demo(props: Partial<MaskRevealOnHoverProps>) {
  return (
    <div className="relative h-full w-full overflow-hidden bg-[#1d1d1f] text-white">
      <MaskRevealOnHover
        hoverSize={110}
        {...props}
        className={`absolute inset-0 ${props.className ?? ""}`}
        content={
          <p className={`${textBox} text-white/35`}>
            Writing <span className="text-[#DDFC3E]">beautiful code</span> means thinking like an artist and debugging like a detective. Every function is a story, every variable a character. Master your craft through practice, patience, and endless curiosity about how things work.
          </p>
        }
        reveal={
          <p className={`${textBox} text-[#111]`}>
            Building <span className="text-[#e5251d]">great software</span> requires seeing beyond syntax into architecture and design. Test early, refactor often, document clearly. Success comes from collaboration, continuous learning, and caring deeply about user experience.
          </p>
        }
      />

      <footer className="pointer-events-none absolute inset-x-6 bottom-6 z-20 flex justify-center font-mono text-[11px] tracking-wide text-white/45 uppercase">
        <span>Move over the text · or touch and drag</span>
      </footer>
    </div>
  );
}
```

## Agent prompt

```text
Add the Mask Reveal on Hover component (<MaskRevealOnHover />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/mask-reveal-on-hover
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/mask-reveal-on-hover.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/mask-reveal-on-hover.json

Then use it like this:
import { MaskRevealOnHover } from "@/components/dimaac/mask-reveal-on-hover";
<MaskRevealOnHover content={content} reveal={reveal} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/mask-reveal-on-hover.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Mouse Tilt Card
description: A card that leans toward the cursor with a moving glare.
category: Cards
tier: free
url: https://ui.dimaac.com/components/mouse-tilt-card
registry: https://ui.dimaac.com/r/mouse-tilt-card.json
---

# Mouse Tilt Card

Wrap anything in it and it tilts in 3D toward the cursor while a soft light slides across its face. The pointer is read on a box that never moves, so the edge never flickers, and every value eases with GSAP quickTo so it stays smooth and settles back gently. Press and drag tilts it on touch, tabbing into it lifts and lights it for keyboard users, and it stays flat with only the light when the visitor prefers reduced motion. Children can use translateZ to float above the card and shift as it leans. Ported from the first DiMaac UI and rebuilt without re-rendering on every mouse move.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/mouse-tilt-card.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/mouse-tilt-card.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/mouse-tilt-card.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/mouse-tilt-card`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/mouse-tilt-card.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { MouseTiltCard } from "@/components/dimaac/mouse-tilt-card" and give its parent a size: it fills it.

## Usage

```tsx
import { MouseTiltCard } from "@/components/dimaac/mouse-tilt-card";

<MouseTiltCard children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | - | What sits on the card. Anything works: an image, text, buttons. |
| `maxTilt` | `number` | `12` | The most the card leans when the cursor is at an edge, in degrees. |
| `perspective` | `number` | `1000` | How strong the 3D is, in pixels. Smaller is more dramatic, larger is flatter. |
| `glare` | `number` | `0.35` | How bright the light on the card is, from 0 (no light) to 1 (a hard white spot). |
| `scale` | `number` | `1.03` | How much the card grows while it is active. 1 keeps its size. |
| `className` | `string` | `""` | Extra classes for the outer box. Add a rounded-* class so the light is clipped to your card's corners. |

## Credits

- Orange visor portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License (free for personal and commercial use)
- Amber perfume: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License (free for personal and commercial use)
- Misty hills: SHIHO, Lummi (https://www.lummi.ai/photo/lush-green-hills-mdvvk), Lummi License (free for personal and commercial use)

## Source

### mouse-tilt-card.tsx → `components/dimaac/mouse-tilt-card.tsx`

```tsx
"use client";

import { useRef, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A card that leans toward the cursor in 3D, with a soft light that slides across it.
// The pointer is read on a still outer box and only the inner layer tilts, so the edge of the card
// never flickers when it rotates under the cursor. Everything eases with GSAP quickTo, so a fast
// swipe stays smooth and the card settles back gently when the cursor leaves.
// Touch: press and drag on the card to tilt it. Keyboard: tabbing into the card lifts it and lights it.

export type MouseTiltCardProps = {
  /** What sits on the card. Anything works: an image, text, buttons. */
  children: ReactNode;
  /** The most the card leans when the cursor is at an edge, in degrees. */
  maxTilt?: number;
  /** How strong the 3D is, in pixels. Smaller is more dramatic, larger is flatter. */
  perspective?: number;
  /** How bright the light on the card is, from 0 (no light) to 1 (a hard white spot). */
  glare?: number;
  /** How much the card grows while it is active. 1 keeps its size. */
  scale?: number;
  /** Extra classes for the outer box. Add a rounded-* class so the light is clipped to your card's corners. */
  className?: string;
};

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

export function MouseTiltCard({ children, maxTilt = 12, perspective = 1000, glare = 0.35, scale = 1.03, className = "" }: MouseTiltCardProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const tiltRef = useRef<HTMLDivElement>(null);
  const glareRef = useRef<HTMLDivElement>(null);
  // Read when the pointer moves, so changing them never rebuilds the effect
  const settings = useRef({ maxTilt, scale });
  settings.current = { maxTilt, scale };

  useGSAP(
    () => {
      const box = boxRef.current;
      const tilt = tiltRef.current;
      const light = glareRef.current;
      if (!box || !tilt || !light) return;

      // matchMedia re-runs this when the visitor flips their motion setting while the page is open
      const mm = gsap.matchMedia();
      mm.add({ reduce: "(prefers-reduced-motion: reduce)", motion: "(prefers-reduced-motion: no-preference)" }, (context) => {
        // Reduced motion: the card stays flat and the same size. Only the light follows the pointer.
        const reduced = Boolean(context.conditions?.reduce);

        // The light's position and strength live on a plain object and are copied into CSS variables
        const spot = { x: 50, y: 50, strength: 0 };
        const paint = () => {
          light.style.setProperty("--tilt-x", `${spot.x}%`);
          light.style.setProperty("--tilt-y", `${spot.y}%`);
          light.style.opacity = String(spot.strength);
        };
        paint();

        // One quickTo per value: cheap to call on every move, eased toward the newest target
        const leanX = gsap.quickTo(tilt, "rotationX", { duration: 0.6, ease: "power3.out" });
        const leanY = gsap.quickTo(tilt, "rotationY", { duration: 0.6, ease: "power3.out" });
        // quickTo ignores the "scale" shortcut, so width and height are eased together instead
        const growX = gsap.quickTo(tilt, "scaleX", { duration: 0.6, ease: "power3.out" });
        const growY = gsap.quickTo(tilt, "scaleY", { duration: 0.6, ease: "power3.out" });
        const grow = (size: number) => {
          growX(size);
          growY(size);
        };
        const slideX = gsap.quickTo(spot, "x", { duration: 0.35, ease: "power3.out", onUpdate: paint });
        const slideY = gsap.quickTo(spot, "y", { duration: 0.35, ease: "power3.out", onUpdate: paint });
        const glow = gsap.quickTo(spot, "strength", { duration: 0.4, ease: "power2.out", onUpdate: paint });

        let hovering = false;
        let focused = false;

        const follow = (event: PointerEvent) => {
          const rect = box.getBoundingClientRect();
          if (rect.width === 0 || rect.height === 0) return;
          // 0 to 1 across the card, so the middle is 0.5
          const across = clamp((event.clientX - rect.left) / rect.width, 0, 1);
          const down = clamp((event.clientY - rect.top) / rect.height, 0, 1);
          if (!reduced) {
            leanY((across - 0.5) * 2 * settings.current.maxTilt);
            leanX(-(down - 0.5) * 2 * settings.current.maxTilt);
            grow(settings.current.scale);
          }
          slideX(across * 100);
          slideY(down * 100);
          glow(1);
          box.dataset.active = "true";
        };

        // Back to flat, unless the pointer or keyboard focus is still on the card
        const rest = () => {
          if (hovering || focused) return;
          leanX(0);
          leanY(0);
          grow(1);
          glow(0);
          delete box.dataset.active;
        };

        const onPointerEnter = (event: PointerEvent) => {
          hovering = true;
          follow(event);
        };
        const onPointerMove = (event: PointerEvent) => {
          hovering = true;
          follow(event);
        };
        const onPointerLeave = () => {
          hovering = false;
          rest();
        };
        // A finger lifting is the same as the cursor leaving
        const onPointerUp = (event: PointerEvent) => {
          if (event.pointerType === "mouse") return;
          hovering = false;
          rest();
        };

        // Keyboard: tabbing in lights the card up and lifts it, with no lean. A mouse click doesn't trigger this.
        const onFocusIn = (event: FocusEvent) => {
          if (!(event.target instanceof Element) || !event.target.matches(":focus-visible")) return;
          focused = true;
          if (!reduced) grow(settings.current.scale);
          slideX(50);
          slideY(30);
          glow(1);
          box.dataset.active = "true";
        };
        const onFocusOut = (event: FocusEvent) => {
          if (event.relatedTarget instanceof Node && box.contains(event.relatedTarget)) return;
          focused = false;
          rest();
        };

        box.addEventListener("pointerenter", onPointerEnter);
        box.addEventListener("pointermove", onPointerMove);
        box.addEventListener("pointerleave", onPointerLeave);
        box.addEventListener("pointercancel", onPointerLeave);
        box.addEventListener("pointerup", onPointerUp);
        box.addEventListener("focusin", onFocusIn);
        box.addEventListener("focusout", onFocusOut);

        return () => {
          box.removeEventListener("pointerenter", onPointerEnter);
          box.removeEventListener("pointermove", onPointerMove);
          box.removeEventListener("pointerleave", onPointerLeave);
          box.removeEventListener("pointercancel", onPointerLeave);
          box.removeEventListener("pointerup", onPointerUp);
          box.removeEventListener("focusin", onFocusIn);
          box.removeEventListener("focusout", onFocusOut);
          delete box.dataset.active;
          gsap.set(tilt, { clearProps: "transform" });
          light.style.opacity = "0";
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef },
  );

  return (
    // The outer box holds the perspective and listens for the pointer. It never moves.
    // touch-action: pan-y lets a finger scroll the page up and down, while a sideways drag tilts the card.
    <div ref={boxRef} className={`relative inline-block data-[active=true]:z-10 ${className}`} style={{ perspective, touchAction: "pan-y" }}>
      {/* preserve-3d lets children use translateZ to float above the card and shift as it leans */}
      <div ref={tiltRef} className="relative block h-full w-full rounded-[inherit]" style={{ transformStyle: "preserve-3d", willChange: "transform" }}>
        {children}
        {/* The light: one soft white spot that follows the pointer. 1px forward so it sits over the card face but under anything floated higher. */}
        <div
          ref={glareRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0"
          style={
            {
              "--tilt-glare": glare,
              "--tilt-x": "50%",
              "--tilt-y": "50%",
              transform: "translateZ(1px)",
              background: "radial-gradient(circle at var(--tilt-x) var(--tilt-y), rgba(255,255,255,var(--tilt-glare)) 0%, rgba(255,255,255,0) 65%)",
            } as CSSProperties
          }
        />
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { MouseTiltCard, type MouseTiltCardProps } from "@/components/dimaac/mouse-tilt-card";

// Three Lummi photos (Lummi License: free for personal and commercial use)
const cards = [
  { number: "01", image: "fashion/orange-visor-4x5.jpg", title: "Orange Visor", alt: "Portrait of a woman in an orange woven visor and a navy dress" },
  { number: "02", image: "objects/amber-perfume-4x5.jpg", title: "Amber Perfume", alt: "A glass perfume bottle with amber liquid in dappled light" },
  { number: "03", image: "nature/misty-hills-4x5.jpg", title: "Misty Hills", alt: "Green mountains with mist lying in the valleys" },
];

function Card({ card, ...tilt }: { card: (typeof cards)[number] } & Omit<MouseTiltCardProps, "children">) {
  const [saved, setSaved] = useState(false);

  return (
    <MouseTiltCard
      {...tilt}
      className={`w-[min(74vw,300px)] rounded-[22px] sm:w-[clamp(170px,25vw,330px)] ${tilt.className ?? ""}`}
    >
      <article
        className="relative aspect-[4/5.3] w-full rounded-[inherit] bg-[#111] shadow-[0_18px_40px_-18px_rgba(0,0,0,0.45)] transition-shadow duration-500 [[data-active=true]_&]:shadow-[0_44px_70px_-26px_rgba(0,0,0,0.6)]"
        style={{ transformStyle: "preserve-3d" }}
      >
        <div className="absolute inset-0 overflow-hidden rounded-[inherit]">
          <img src={media(card.image)} alt={card.alt} className="h-full w-full object-cover" draggable={false} decoding="async" />
          <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/35" />
        </div>

        {/* Floated 34px above the photo, so it shifts a little against it as the card leans */}
        <div className="absolute inset-0 flex flex-col justify-between p-[clamp(14px,1.8vw,22px)] text-white" style={{ transform: "translateZ(34px)" }}>
          <div className="flex items-center justify-between font-mono text-[10px] tracking-[0.14em] uppercase opacity-80">
            <span>No. {card.number}</span>
          </div>
          <div className="flex items-end justify-between gap-3">
            <h2 className="text-[clamp(17px,2vw,24px)] leading-[1.05] font-medium tracking-[-0.02em]">{card.title}</h2>
            <button
              type="button"
              aria-pressed={saved}
              aria-label={`Save ${card.title}`}
              onClick={() => setSaved((value) => !value)}
              className="grid h-9 w-9 shrink-0 cursor-pointer place-items-center rounded-full bg-white/15 ring-1 ring-white/30 backdrop-blur-md transition-colors hover:bg-white/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
            >
              <svg viewBox="0 0 24 24" width="16" height="16" fill={saved ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true">
                <path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.7c0 5.6-7.5 10.2-7.5 10.2Z" />
              </svg>
            </button>
          </div>
        </div>
      </article>
    </MouseTiltCard>
  );
}

export default function Demo(props: Partial<Omit<MouseTiltCardProps, "children">>) {
  return (
    <div className="relative h-full w-full overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <div className="relative flex h-full w-full flex-col items-center justify-center gap-[clamp(14px,3.4vh,34px)] px-6">
        <h1 className="text-center text-[clamp(30px,5vw,64px)] leading-[0.95] font-medium tracking-[-0.045em] opacity-90">Lean into the light</h1>
        <div className="flex items-center justify-center gap-[clamp(14px,2.6vw,36px)]">
          {cards.map((card, index) => (
            // On a phone only the first card fits, so the others are hidden
            <div key={card.number} className={index === 0 ? "" : "hidden sm:block"}>
              <Card card={card} {...props} />
            </div>
          ))}
        </div>
        <p className="max-w-[40ch] text-center text-[14px] opacity-60">Move across a card, or press and drag on touch.</p>
      </div>

    </div>
  );
}
```

## Agent prompt

```text
Add the Mouse Tilt Card component (<MouseTiltCard />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/mouse-tilt-card
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/mouse-tilt-card.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/mouse-tilt-card.json

Then use it like this:
import { MouseTiltCard } from "@/components/dimaac/mouse-tilt-card";
<MouseTiltCard children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/mouse-tilt-card.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Mouse trail
description: An image trail that follows the cursor.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/mouse-trail
registry: https://ui.dimaac.com/r/mouse-trail.json
---

# Mouse trail

A stack of photo cards that follows the pointer. Each card runs a moment behind the one above it, so the stack stretches into a trail when you move and gathers again when you stop. It listens only inside its own box (two on a page never touch each other), works with touch, drifts along a slow loop by itself on touch screens, and sits still, fanned out, when the visitor prefers reduced motion. Ported from the first DiMaac UI and rebuilt with scoped GSAP quickTo calls.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/mouse-trail.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/mouse-trail.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/mouse-trail.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/mouse-trail`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/mouse-trail.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { MouseTrail } from "@/components/dimaac/mouse-trail" and give its parent a size: it fills it.

## Usage

```tsx
import { MouseTrail } from "@/components/dimaac/mouse-trail";

<MouseTrail images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `string[]` | - | The photos, top card first. Six to twelve look best. |
| `imageWidth` | `number` | `170` | Width of each card, in pixels. |
| `imageHeight` | `number` | `238` | Height of each card, in pixels. |
| `stagger` | `number` | `0.04` | How far behind the card above it each card runs, in seconds. |
| `duration` | `number` | `0.5` | How long a card takes to catch up with its spot, in seconds. |
| `ease` | `string` | `"power3.out"` | The GSAP ease of that catch-up, e.g. "power2.out" or "expo.out". |
| `autoplay` | `"touch" \| "always" \| "never"` | `"touch"` | When the stack drifts by itself: on touch screens only, always (whenever no pointer is over it) or never. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `imageClassName` | `string` | `"rounded-[4px] shadow-[0_16px_34px_-14px_rgba(0,0,0,0.3)]"` | Extra classes for each card, e.g. a radius or a shadow. |

## Credits

- Photos: Lummi, Lummi (https://www.lummi.ai), Lummi License (free for personal and commercial use)

## Source

### mouse-trail.tsx → `components/dimaac/mouse-trail.tsx`

```tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A stack of photos that chases the cursor. Every card follows the same path a little later than
// the one above it, so the stack stretches into a trail when you move and gathers when you stop.
// On touch screens (or always, if you ask) it drifts along a slow loop by itself.

export type MouseTrailProps = {
  /** The photos, top card first. Six to twelve look best. */
  images: string[];
  /** Width of each card, in pixels. */
  imageWidth?: number;
  /** Height of each card, in pixels. */
  imageHeight?: number;
  /** How far behind the card above it each card runs, in seconds. */
  stagger?: number;
  /** How long a card takes to catch up with its spot, in seconds. */
  duration?: number;
  /** The GSAP ease of that catch-up, e.g. "power2.out" or "expo.out". */
  ease?: string;
  /** When the stack drifts by itself: on touch screens only, always (whenever no pointer is over it) or never. */
  autoplay?: "touch" | "always" | "never";
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Extra classes for each card, e.g. a radius or a shadow. */
  imageClassName?: string;
};

type Point = { t: number; x: number; y: number };

export function MouseTrail({
  images,
  imageWidth = 170,
  imageHeight = 238,
  stagger = 0.04,
  duration = 0.5,
  ease = "power3.out",
  autoplay = "touch",
  className = "",
  imageClassName = "rounded-[4px] shadow-[0_16px_34px_-14px_rgba(0,0,0,0.3)]",
}: MouseTrailProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
  // Read on every frame, so a new stagger doesn't rebuild anything
  const staggerRef = useRef(stagger);
  staggerRef.current = stagger;

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const cards = cardRefs.current.slice(0, images.length).filter((card): card is HTMLDivElement => card !== null);
      if (!box || cards.length === 0 || !contextSafe) return;

      gsap.set(cards, { xPercent: -50, yPercent: -50, x: box.clientWidth / 2, y: box.clientHeight / 2, force3D: true });

      // Reduced motion: no chase. The stack sits in the middle, fanned out a little, and stays put.
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: reduce)", () => {
        const middle = (cards.length - 1) / 2;
        gsap.set(cards.map((card) => card.firstElementChild), { opacity: 1 });
        gsap.set(cards, { opacity: 1, left: "50%", top: "50%", x: 0, y: 0, rotation: (i: number) => (i - middle) * 4, xPercent: (i: number) => -50 + (i - middle) * 6 });
      });

      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      if (reduced) return () => mm.revert();

      const touchScreen = window.matchMedia("(hover: none)").matches;
      const drifts = autoplay === "always" || (autoplay === "touch" && touchScreen);

      // One quickTo per card and axis: cheap to call every frame, eased toward the newest value
      const moveX = cards.map((card) => gsap.quickTo(card, "x", { duration, ease }));
      const moveY = cards.map((card) => gsap.quickTo(card, "y", { duration, ease }));
      // A card that sits right under the one above it is hidden, so ten shadows don't pile up
      // into a dark halo when the stack gathers. It fades in as it pulls away.
      const fade = cards.map((card) => gsap.quickSetter(card.firstElementChild, "opacity"));

      // Where the trail's head has been, so each card can be sent where the head was a moment ago
      const history: Point[] = [];
      const target = { x: box.clientWidth / 2, y: box.clientHeight / 2 };
      let pointerInside = false;
      let lastInput = -Infinity;
      let shown = false;
      let visible = true;
      let clock = 0;

      const show = (on: boolean) => {
        if (on === shown) return;
        shown = on;
        gsap.to(cards, { opacity: on ? 1 : 0, duration: on ? 0.25 : 0.4, ease: "power2.out", stagger: on ? staggerRef.current / 2 : 0, overwrite: "auto" });
      };

      const pointAt = (time: number): Point => {
        for (let i = history.length - 1; i >= 0; i--) if (history[i]!.t <= time) return history[i]!;
        return history[0] ?? { t: time, x: target.x, y: target.y };
      };

      const tick = (seconds: number) => {
        if (!visible) return;
        clock = seconds;
        const idle = seconds - lastInput > 1.2 && !pointerInside;
        if (drifts && idle) {
          // A slow figure of eight around the middle of the box
          const w = box.clientWidth;
          const h = box.clientHeight;
          target.x = w / 2 + Math.sin(seconds * 0.8) * w * 0.28;
          target.y = h / 2 + Math.sin(seconds * 1.6) * h * 0.2;
          show(true);
        }
        history.push({ t: seconds, x: target.x, y: target.y });
        const keep = cards.length * staggerRef.current + 0.5;
        while (history.length > 2 && history[0]!.t < seconds - keep) history.shift();
        cards.forEach((_, i) => {
          const point = pointAt(seconds - i * staggerRef.current);
          moveX[i]!(point.x);
          moveY[i]!(point.y);
          if (i === 0) return;
          const above = cards[i - 1]!;
          const gap = Math.hypot(Number(gsap.getProperty(cards[i]!, "x")) - Number(gsap.getProperty(above, "x")), Number(gsap.getProperty(cards[i]!, "y")) - Number(gsap.getProperty(above, "y")));
          fade[i]!(Math.min(1, gap / 20));
        });
      };
      gsap.ticker.add(tick);

      const place = (event: PointerEvent) => {
        const rect = box.getBoundingClientRect();
        target.x = event.clientX - rect.left;
        target.y = event.clientY - rect.top;
        lastInput = clock;
      };

      const onPointerMove = contextSafe((event: PointerEvent) => {
        if (!event.isPrimary) return;
        place(event);
        show(true);
      });
      const onPointerEnter = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "mouse" || event.pointerType === "pen") pointerInside = true;
      });
      const onPointerDown = contextSafe((event: PointerEvent) => {
        if (!event.isPrimary) return;
        place(event);
        show(true);
      });
      const onPointerLeave = contextSafe(() => {
        pointerInside = false;
        if (!drifts) show(false);
      });

      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerenter", onPointerEnter);
      box.addEventListener("pointerdown", onPointerDown);
      box.addEventListener("pointerleave", onPointerLeave);

      // No work while the box is off screen
      const visibility = new IntersectionObserver(([entry]) => {
        visible = entry?.isIntersecting ?? true;
      });
      visibility.observe(box);

      return () => {
        gsap.ticker.remove(tick);
        visibility.disconnect();
        box.removeEventListener("pointermove", onPointerMove);
        box.removeEventListener("pointerenter", onPointerEnter);
        box.removeEventListener("pointerdown", onPointerDown);
        box.removeEventListener("pointerleave", onPointerLeave);
        mm.revert();
      };
    },
    { scope: boxRef, dependencies: [images.length, duration, ease, autoplay], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`} style={{ touchAction: "pan-y" }}>
      {images.map((src, index) => (
        <div
          key={`${index}-${src}`}
          ref={(card) => {
            cardRefs.current[index] = card;
          }}
          className="pointer-events-none absolute top-0 left-0 opacity-0"
          style={{ width: imageWidth, height: imageHeight, zIndex: images.length - index, willChange: "transform, opacity" }}
        >
          <div className={`h-full w-full overflow-hidden ${imageClassName}`}>
            <img src={src} alt="" className="block h-full w-full object-cover" draggable={false} decoding="async" />
          </div>
        </div>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { MouseTrail, type MouseTrailProps } from "@/components/dimaac/mouse-trail";

// Ten Lummi photos (Lummi License: free for personal and commercial use)
const photos = [
  "QmedKm8YSdHaWnDdvLzjrsWgeiArndQkhcucsvaFjyVgXz",
  "QmcR7gTkJUJgUAfWjM31hbEMJ43txn6aTiJx7hb8LaVQ5X",
  "QmTSgovioSANEhNgNo9XxY3ug5iYfQ1wnjY2P72qZofRwP",
  "Qmb3rGQC9S1bGz7PC5cnQ3y2cX3GnDpUeBYx9vQvSn1H3v",
  "QmXzqMzfHMyvQBySMwJdgUbUYsTNUNbDD2bLCyDt5s7aQU",
  "QmbWtf1Mhr3JCLd7NS4XQ187cA7ukws3zuDXTcbd8fGo5u",
  "QmSoKRoG5ZTST6n9eWmLqggURiirGS3W4LfZ338Wj2puki",
  "Qme6mAjMnD8PLhRzHZ7soZG2cvZL5vHPhzoA5pm6JuaCSX",
  "QmUU9hRVuK34EFbA4DNMEMrQ6X4NnqTg2Dppqv6R3jCMQC",
  "QmStPNbdbM4Acy1UDESHguBMqWLCw53WWUvxXXdqKAcVvq",
].map((id) => `https://assets.lummi.ai/assets/${id}?auto=format&w=480`);

export default function Demo(props: Partial<MouseTrailProps>) {
  return (
    <div className="relative h-full w-full cursor-crosshair overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
        <h1 className="text-[clamp(44px,9vw,128px)] leading-[0.92] font-medium tracking-[-0.045em] opacity-90">
          Small things,
          <br />
          seen slowly
        </h1>
        <p className="max-w-[34ch] text-[15px] opacity-60">Move across the page to leave a trail of photographs.</p>
      </div>

      <MouseTrail images={photos} {...props} className={`absolute inset-0 z-10 ${props.className ?? ""}`} />
    </div>
  );
}
```

## Agent prompt

```text
Add the Mouse trail component (<MouseTrail />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/mouse-trail
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/mouse-trail.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/mouse-trail.json

Then use it like this:
import { MouseTrail } from "@/components/dimaac/mouse-trail";
<MouseTrail images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/mouse-trail.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Odometer
description: Numbers that roll digit by digit, like an odometer.
category: Text
tier: free
url: https://ui.dimaac.com/components/odometer
registry: https://ui.dimaac.com/r/odometer.json
---

# Odometer

A counter whose digits are wheels. When the number scrolls into view, loads, or changes, every digit rolls up to its new value, the left-most wheel settling first and the right-most spinning the longest. It formats like a real number (thousands separator, decimal places, a prefix and a suffix), uses tabular figures so nothing shifts while it turns, and keeps the real number in screen-reader text. A new value rolls from what is on screen, and with reduced motion it simply shows the number. Generalised from the rolling counter in the GSAP course image loader.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/odometer.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/odometer.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/odometer.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/odometer`

Dependencies:

```bash
pnpm add gsap
npm install gsap
```

By hand:

1. Install the packages it uses: gsap.
2. Copy the component file below into components/dimaac/odometer.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { Odometer } from "@/components/dimaac/odometer" and give its parent a size: it fills it.

## Usage

```tsx
import { Odometer } from "@/components/dimaac/odometer";

<Odometer />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | `12480` | The number to show once the wheels stop. |
| `from` | `number` | `0` | The number the wheels start from the first time they roll. |
| `duration` | `number` | `1.6` | How long one wheel takes to roll, in seconds. |
| `stagger` | `number` | `0.06` | How long each wheel waits after the one on its left, in seconds. |
| `prefix` | `string` | `""` | Text before the number, e.g. "$". |
| `suffix` | `string` | `""` | Text after the number, e.g. "%" or " subscribers". |
| `decimals` | `number` | `0` | How many digits to show after the decimal mark. |
| `separator` | `string` | `","` | What goes between groups of thousands, e.g. "," or " ". Empty for none. |
| `decimalMark` | `string` | `"."` | The decimal mark, e.g. "." or ",". |
| `trigger` | `"inView" \| "load" \| "change"` | `"inView"` | When it rolls: "inView" the first time it scrolls into view, "load" right away, "change" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen. |
| `ease` | `string` | `"power3.out"` | The GSAP ease of each wheel, e.g. "power3.out" or "expo.out". |
| `className` | `string` | `""` | Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. |
| `affixClassName` | `string` | `""` | Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]". |

## Source

### odometer.tsx → `components/dimaac/odometer.tsx`

```tsx
"use client";

import { useLayoutEffect, useRef, useState } from "react";
import { gsap } from "gsap";

// Numbers that roll digit by digit, like the wheels of an odometer. Every digit is a window one
// character tall with a tall strip of 0-9 behind it; rolling is just sliding that strip up.
// The strip is 40 digits long (four full 0-9 turns) so a digit can spin around a few times before
// it lands. Separators, the prefix and the suffix stay still while the wheels turn.

const CELLS = 40;

export type OdometerProps = {
  /** The number to show once the wheels stop. */
  value?: number;
  /** The number the wheels start from the first time they roll. */
  from?: number;
  /** How long one wheel takes to roll, in seconds. */
  duration?: number;
  /** How long each wheel waits after the one on its left, in seconds. */
  stagger?: number;
  /** Text before the number, e.g. "$". */
  prefix?: string;
  /** Text after the number, e.g. "%" or " subscribers". */
  suffix?: string;
  /** How many digits to show after the decimal mark. */
  decimals?: number;
  /** What goes between groups of thousands, e.g. "," or " ". Empty for none. */
  separator?: string;
  /** The decimal mark, e.g. "." or ",". */
  decimalMark?: string;
  /** When it rolls: "inView" the first time it scrolls into view, "load" right away, "change" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen. */
  trigger?: "inView" | "load" | "change";
  /** The GSAP ease of each wheel, e.g. "power3.out" or "expo.out". */
  ease?: string;
  /** Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. */
  className?: string;
  /** Extra classes for the prefix and the suffix, e.g. "opacity-50" or "text-[0.5em]". */
  affixClassName?: string;
};

type Piece = { kind: "digit"; digit: number; fromRight: number } | { kind: "text"; char: string };

// "12480" → "12,480" as a list of digit wheels and fixed characters, left to right
function buildPieces(value: number, decimals: number, separator: string, decimalMark: string): Piece[] {
  const places = Math.min(6, Math.max(0, Math.round(decimals)));
  const [whole = "0", fraction = ""] = Math.abs(value).toFixed(places).split(".");
  const grouped = separator ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, "\u0000") : whole;
  const characters = [...(value < 0 ? "-" : ""), ...grouped, ...(places > 0 ? ["\u0001", ...fraction] : [])];
  const digitCount = characters.filter((c) => c >= "0" && c <= "9").length;
  let seen = 0;
  return characters.map((c): Piece => {
    if (c >= "0" && c <= "9") {
      seen += 1;
      return { kind: "digit", digit: Number(c), fromRight: digitCount - seen };
    }
    return { kind: "text", char: c === "\u0000" ? separator : c === "\u0001" ? decimalMark : c };
  });
}

// The digits of `from`, left-padded with zeros (or trimmed) to fit the wheels we have
function startDigits(from: number, decimals: number, count: number): number[] {
  const places = Math.min(6, Math.max(0, Math.round(decimals)));
  const digits = Math.abs(from).toFixed(places).replace(".", "").split("").map(Number);
  while (digits.length < count) digits.unshift(0);
  return digits.slice(digits.length - count);
}

// The wheels fade out at the top and bottom edge of their window, so digits slide in softly.
// The digit at rest sits in the clear middle.
const fade = { maskImage: "linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)", WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)" };

const wrap = (n: number) => ((n % 10) + 10) % 10;
const percentFor = (index: number) => -(index / CELLS) * 100;

export function Odometer({
  value = 12480,
  from = 0,
  duration = 1.6,
  stagger = 0.06,
  prefix = "",
  suffix = "",
  decimals = 0,
  separator = ",",
  decimalMark = ".",
  trigger = "inView",
  ease = "power3.out",
  className = "",
  affixClassName = "",
}: OdometerProps) {
  const boxRef = useRef<HTMLSpanElement>(null);
  // The number is drawn as plain text on the server and until the first layout effect, so it
  // is readable without JavaScript and never flashes the wrong digits
  const [ready, setReady] = useState(false);
  // False until the first full roll has finished. Until then every wheel spins, even a 0 that stays 0.
  const firstRollPending = useRef(true);

  const pieces = buildPieces(value, decimals, separator, decimalMark);
  const plain = pieces.map((p) => (p.kind === "digit" ? p.digit : p.char)).join("");
  const spoken = `${prefix}${plain}${suffix}`;
  const digitCount = pieces.filter((p) => p.kind === "digit").length;
  // New format = new wheels (a different number of digits, separator or decimals)
  const layoutKey = `${digitCount}|${decimals}|${separator}|${decimalMark}`;

  useLayoutEffect(() => setReady(true), []);

  useLayoutEffect(() => {
    const box = boxRef.current;
    if (!ready || !box) return;
    const strips = Array.from(box.querySelectorAll<HTMLElement>("[data-wheel]"));
    if (strips.length === 0) return;

    const end = pieces.filter((p) => p.kind === "digit").map((p) => p.digit);
    const start = startDigits(from, decimals, end.length);
    const indexOf = (strip: HTMLElement) => (-Number(gsap.getProperty(strip, "yPercent")) / 100) * CELLS;
    const show = (strip: HTMLElement, index: number) => gsap.set(strip, { yPercent: percentFor(index) });

    // Wheels that were just created sit on their start digit (or on the value, if it only rolls on change)
    const fresh = strips.some((strip) => !strip.dataset.set);
    if (fresh) {
      strips.forEach((strip, k) => {
        strip.dataset.set = "1";
        show(strip, trigger === "change" ? end[k]! : start[k]!);
      });
      firstRollPending.current = trigger !== "change";
    }

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) {
      strips.forEach((strip, k) => show(strip, end[k]!));
      firstRollPending.current = false;
      return;
    }

    let timeline: gsap.core.Timeline | null = null;
    let observer: IntersectionObserver | null = null;

    const roll = () => {
      const forced = firstRollPending.current;
      const current = strips.map((strip) => wrap(indexOf(strip)));
      const distance = end.map((digit, k) => wrap(digit - current[k]!));
      // Like a real odometer, every wheel to the right of the first one that changes turns too
      let first = distance.findIndex((d) => d > 0.001);
      if (forced) first = 0;
      if (first === -1) return;

      timeline = gsap.timeline({
        onComplete: () => {
          firstRollPending.current = false;
        },
      });
      strips.forEach((strip, k) => {
        if (k < first) return;
        const fromRight = strips.length - 1 - k;
        // How many extra full turns this wheel makes: the right-most wheels spin the most
        const turns = forced ? (fromRight === 0 ? 2 : 1) : distance[k]! > 0.001 ? (fromRight === 0 && k > first ? 1 : 0) : 1;
        const travel = distance[k]! + turns * 10;
        show(strip, current[k]!);
        timeline!.to(
          strip,
          {
            yPercent: percentFor(current[k]! + travel),
            duration,
            ease,
            // Back onto the first 0-9 so the strip never runs out
            onComplete: () => show(strip, end[k]!),
          },
          (k - first) * stagger,
        );
      });
    };

    if (firstRollPending.current && trigger === "inView") {
      observer = new IntersectionObserver(
        ([entry]) => {
          if (!entry?.isIntersecting) return;
          observer?.disconnect();
          roll();
        },
        { threshold: 0.3 },
      );
      observer.observe(box);
    } else {
      roll();
    }

    return () => {
      observer?.disconnect();
      timeline?.kill();
    };
    // `pieces` changes every render but plain/layoutKey cover everything that matters in it
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, plain, layoutKey, from, trigger, duration, stagger, ease]);

  const affix = (text: string) =>
    text ? (
      <span aria-hidden className={`whitespace-pre leading-none ${affixClassName}`}>
        {text}
      </span>
    ) : null;

  return (
    <span ref={boxRef} className={`inline-flex items-center leading-none whitespace-pre ${className}`} style={{ fontVariantNumeric: "tabular-nums", fontFeatureSettings: '"tnum"' }}>
      <span className="sr-only">{spoken}</span>
      {affix(prefix)}
      <span key={ready ? layoutKey : "static"} aria-hidden className="inline-flex items-center leading-none">
        {ready
          ? pieces.map((piece, i) =>
              piece.kind === "text" ? (
                <span key={`t${i}`} className="inline-block leading-none">
                  {piece.char}
                </span>
              ) : (
                // The window: one digit tall. The 0-9 strip slides behind it.
                <span key={`d${piece.fromRight}`} className="-mx-[0.04em] inline-block h-[1em] overflow-hidden px-[0.04em] leading-none" style={fade}>
                  <span data-wheel className="block will-change-transform">
                    {Array.from({ length: CELLS }, (_, n) => (
                      <span key={n} className="block h-[1em] leading-none">
                        {n % 10}
                      </span>
                    ))}
                  </span>
                </span>
              ),
            )
          : plain}
      </span>
      {affix(suffix)}
    </span>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { Odometer, type OdometerProps } from "@/components/dimaac/odometer";

// One big number and a row of stats. The playground drives the big number;
// the stats share its timing, separator and trigger.

const stats = [
  { label: "Installs", value: 6495, suffix: "", decimals: 0 },
  { label: "Releases", value: 41, suffix: "", decimals: 0 },
  { label: "Uptime", value: 99.9, suffix: "%", decimals: 1 },
];

export default function Demo(props: Partial<OdometerProps>) {
  // "Roll again" bumps the numbers when the trigger is "change", and replays the roll otherwise
  const [round, setRound] = useState(0);
  const changes = props.trigger === "change";
  const bump = changes ? round : 0;
  const { value = 12480, className, ...shared } = props;

  // The big number shrinks to fit the stage however long the playground makes it
  const shownValue = value + bump * 1337;
  const wholeDigits = Math.floor(Math.abs(shownValue)).toString().length;
  const length = wholeDigits + (props.separator === "" ? 0 : Math.floor((wholeDigits - 1) / 3)) + (props.decimals ? props.decimals + 1 : 0) + (props.prefix?.length ?? 0) + (props.suffix?.length ?? 0);
  const heroSize = `min(240px, ${(88 / (length * 0.56)).toFixed(2)}cqw)`;

  return (
    <div
      className="relative flex h-full w-full flex-col overflow-hidden bg-[#f3f0eb] px-6 py-6 text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4] md:px-12 md:py-9"
      style={{ fontFamily: '"Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif' }}
    >
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&display=swap" precedence="default" />

      <main className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 text-center" style={{ containerType: "inline-size" }}>
        <p className="font-mono text-[11px] tracking-wide uppercase opacity-60">Installs this month</p>
        <div style={{ fontSize: heroSize }} className="flex max-w-full justify-center leading-none">
          <Odometer
            key={changes ? "change" : round}
            {...shared}
            value={shownValue}
            className={`font-medium tracking-[-0.05em] ${className ?? ""}`}
            affixClassName="opacity-45"
          />
        </div>
      </main>

      <div className="grid grid-cols-3 border-t border-current/15">
        {stats.map((stat, i) => (
          <div key={stat.label} className={`flex flex-col gap-2 py-5 md:py-7 ${i > 0 ? "border-l border-current/15 pl-4 md:pl-8" : ""}`}>
            <Odometer
              key={changes ? stat.label : `${stat.label}${round}`}
              value={stat.value + (stat.decimals ? 0 : bump * (i + 1) * 17)}
              decimals={stat.decimals}
              suffix={stat.suffix}
              trigger={props.trigger}
              duration={props.duration}
              stagger={props.stagger}
              ease={props.ease}
              separator={props.separator}
              className="text-[clamp(26px,5.4vw,72px)] font-medium tracking-[-0.04em]"
              affixClassName="opacity-45"
            />
            <span className="font-mono text-[10px] tracking-wide uppercase opacity-60 md:text-[11px]">{stat.label}</span>
          </div>
        ))}
      </div>

      <button
        type="button"
        onClick={() => setRound((r) => r + 1)}
        className="mx-auto mt-5 cursor-pointer rounded-full border border-current/25 px-4 py-2 font-mono text-[11px] tracking-wide uppercase opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
      >
        {changes ? "Change the numbers" : "Roll again"}
      </button>
    </div>
  );
}
```

## Agent prompt

```text
Add the Odometer component (<Odometer />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/odometer
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/odometer.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/odometer.json

Then use it like this:
import { Odometer } from "@/components/dimaac/odometer";
<Odometer />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/odometer.tsx and install gsap.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Perspective Card Stack
description: A pile of photos that swings open into a 3D fan.
category: Cards
tier: free
url: https://ui.dimaac.com/components/perspective-card-stack
registry: https://ui.dimaac.com/r/perspective-card-stack.json
---

# Perspective Card Stack

A small deck of photos in perspective. At rest the cards sit in a tidy pile, each peeking out above the one in front. Hover it, tap it or focus it with the keyboard and the cards swing out sideways, turned toward you like the pages of a book, and the card under the cursor rises. It is plain CSS transitions on one transform per card, so there is no animation library, and the 3D is tuned by a handful of numbers: the camera distance, the turn, the spread and the lift. It works with two to five photos, fans to either side, can open on click instead of hover, can open and close by itself, shrinks to fit narrow screens, and shows the fan open and still when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/perspective-card-stack.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/perspective-card-stack.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/perspective-card-stack.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/perspective-card-stack`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/perspective-card-stack.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PerspectiveCardStack } from "@/components/dimaac/perspective-card-stack" and give its parent a size: it fills it.

## Usage

```tsx
import { PerspectiveCardStack } from "@/components/dimaac/perspective-card-stack";

<PerspectiveCardStack cards={cards} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cards` (required) | `PerspectiveCard[]` | - | The photos, front card first. Up to five are used; three look best. |
| `cardWidth` | `number` | `360` | Width of each card, in pixels. On a narrow screen the cards shrink to fit. |
| `cardHeight` | `number` | `280` | Height of each card, in pixels. |
| `perspective` | `number` | `500` | How strong the 3D is, in pixels: the distance of the "camera". Smaller is more dramatic. |
| `tilt` | `number` | `48` | How far the front card turns when the stack fans out, in degrees. The cards behind it turn less. |
| `spread` | `number` | `1` | How far the cards spread sideways when fanned. 1 is the normal spread. |
| `lift` | `number` | `40` | How far the card under the cursor rises, in pixels. |
| `duration` | `number` | `0.4` | How long the fan takes, in seconds. |
| `direction` | `"left" \| "right"` | `"left"` | Which side the cards fan out toward. |
| `trigger` | `"hover" \| "click"` | `"hover"` | What opens the stack: the cursor resting on it, or a click or tap. Touch screens always use taps. |
| `autoplay` | `boolean` | `false` | Open and close the stack by itself while nobody is using it. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `cardClassName` | `string` | `"rounded-[10px] shadow-[0_26px_50px_-20px_rgba(0,0,0,0.5)]"` | Extra classes for each card, e.g. a radius or a shadow. |

## Credits

- Photo: Binks AI, Lummi (https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c), Lummi License
- Photo: Siren Media, Lummi (https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl), Lummi License
- Photo: Ricardo Matos, Lummi (https://www.lummi.ai/photo/minimalist-urban-design-abiri), Lummi License

## Source

### perspective-card-stack.tsx → `components/dimaac/perspective-card-stack.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, useSyncExternalStore, type PointerEvent } from "react";

// A small deck of photos in perspective. At rest the cards sit in a tidy pile, each one peeking
// out above the card in front. Hover the pile (or tap it, or focus it with the keyboard) and the
// cards swing out sideways like pages of a book, turned toward you, and the card under the cursor
// lifts. It is plain CSS transitions on a transform, so there is no animation library to load.

export type PerspectiveCard = {
  /** The photo's address. */
  image: string;
  /** Describes the photo for screen readers. */
  alt?: string;
};

export type PerspectiveCardStackProps = {
  /** The photos, front card first. Up to five are used; three look best. */
  cards: PerspectiveCard[];
  /** Width of each card, in pixels. On a narrow screen the cards shrink to fit. */
  cardWidth?: number;
  /** Height of each card, in pixels. */
  cardHeight?: number;
  /** How strong the 3D is, in pixels: the distance of the "camera". Smaller is more dramatic. */
  perspective?: number;
  /** How far the front card turns when the stack fans out, in degrees. The cards behind it turn less. */
  tilt?: number;
  /** How far the cards spread sideways when fanned. 1 is the normal spread. */
  spread?: number;
  /** How far the card under the cursor rises, in pixels. */
  lift?: number;
  /** How long the fan takes, in seconds. */
  duration?: number;
  /** Which side the cards fan out toward. */
  direction?: "left" | "right";
  /** What opens the stack: the cursor resting on it, or a click or tap. Touch screens always use taps. */
  trigger?: "hover" | "click";
  /** Open and close the stack by itself while nobody is using it. */
  autoplay?: boolean;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Extra classes for each card, e.g. a radius or a shadow. */
  cardClassName?: string;
};

function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const MAX_CARDS = 5;

// Where card number `index` sits. `t` runs from 0 (front card) to 1 (last card), so the whole
// pile always covers the same area, however many cards there are. For three cards these are
// the numbers of the original design. Percentages are shares of the card's own size.
function cardTransform(index: number, count: number, open: boolean, tilt: number, spread: number, lift: number, direction: 1 | -1) {
  const t = count > 1 ? index / (count - 1) : 0;
  if (!open) {
    // A pile: each card a step higher and a step smaller than the one in front
    return `translate(-50%, ${-35 - 40 * t}%) scale(${1 - 0.2 * t})`;
  }
  // A fan: the front card swings out furthest and is the biggest, the rest follow, smaller and
  // turned less, so the row seems to run away from you
  // (measured from the middle of the box; the mirror image for a fan to the right)
  const x = -50 + (-22 + 40 * t) * spread * direction;
  const turn = -(tilt - tilt * 0.5 * t) * direction;
  return `translate(${x}%, -50%) translateY(${-lift}px) scale(${0.8 - 0.2 * t}) rotateY(${turn}deg)`;
}

export function PerspectiveCardStack({
  cards,
  cardWidth = 360,
  cardHeight = 280,
  perspective = 500,
  tilt = 48,
  spread = 1,
  lift = 40,
  duration = 0.4,
  direction = "left",
  trigger = "hover",
  autoplay = false,
  className = "",
  cardClassName = "rounded-[10px] shadow-[0_26px_50px_-20px_rgba(0,0,0,0.5)]",
}: PerspectiveCardStackProps) {
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
  const [hovering, setHovering] = useState(false);
  const [focused, setFocused] = useState(false);
  const [pinned, setPinned] = useState(false);
  const [auto, setAuto] = useState(false);
  const [liftedCard, setLiftedCard] = useState<number | null>(null);
  // "mouse", "touch" or "pen": a mouse can hover, a finger can only tap
  const pointerType = useRef("mouse");

  const shown = cards.slice(0, MAX_CARDS);
  const inUse = hovering || focused || pinned;

  // Opened by the cursor or the keyboard in hover mode, by a click in click mode, by the
  // autoplay loop when nobody is around. With reduced motion there is nothing to open: the fan
  // is simply how it looks.
  const open = reducedMotion || (trigger === "hover" ? inUse : pinned) || (autoplay && !inUse && auto);

  // Autoplay is just a timer flipping a flag, and it gives way as soon as someone uses the stack
  useEffect(() => {
    if (!autoplay || reducedMotion) return;
    const timer = window.setInterval(() => setAuto((on) => !on), 2400);
    return () => window.clearInterval(timer);
  }, [autoplay, reducedMotion]);

  const sign = direction === "left" ? 1 : -1;
  const seconds = reducedMotion ? 0 : duration;

  const onHoverChange = (event: PointerEvent, on: boolean) => {
    pointerType.current = event.pointerType;
    // Touch fires enter and leave around every tap; only a real mouse should open on hover
    if (event.pointerType !== "mouse" || trigger !== "hover") return;
    setHovering(on);
    if (!on) setLiftedCard(null);
  };

  const onCardClick = () => {
    // A mouse in hover mode already sees the fan; everything else toggles it
    if (trigger === "click" || pointerType.current !== "mouse") setPinned((on) => !on);
  };

  return (
    <div
      className={`relative w-full [container-type:inline-size] ${className}`}
      style={{ perspective, minHeight: cardHeight * 1.4 }}
      onPointerEnter={(event) => onHoverChange(event, true)}
      onPointerLeave={(event) => onHoverChange(event, false)}
      onKeyDown={(event) => {
        if (event.key === "Escape") setPinned(false);
      }}
    >
      {shown.map((card, index) => (
        <button
          key={index}
          type="button"
          // One stop for the keyboard is enough: the front card
          tabIndex={index === 0 ? 0 : -1}
          aria-label={card.alt ?? `Card ${index + 1}`}
          aria-expanded={open}
          onClick={onCardClick}
          onFocus={(event) => {
            if (event.currentTarget.matches(":focus-visible")) setFocused(true);
          }}
          onBlur={() => setFocused(false)}
          onPointerEnter={(event) => {
            if (event.pointerType === "mouse") setLiftedCard(index);
          }}
          onPointerLeave={() => setLiftedCard((current) => (current === index ? null : current))}
          className="group absolute top-1/2 left-1/2 m-0 block cursor-pointer border-0 bg-transparent p-0 [backface-visibility:hidden] will-change-transform focus:outline-none"
          style={{
            width: `min(${cardWidth}px, 74cqw)`,
            aspectRatio: `${cardWidth} / ${cardHeight}`,
            zIndex: shown.length - index,
            transform: cardTransform(index, shown.length, open, tilt, spread, liftedCard === index && !reducedMotion ? lift : 0, sign),
            transition: `transform ${seconds}s cubic-bezier(0.22, 1, 0.36, 1), filter ${seconds}s ease`,
            // The cards at the back sit in a little shade, so the pile has depth
            filter: `brightness(${1 - (shown.length > 1 ? index / (shown.length - 1) : 0) * (open ? 0.1 : 0.18)})`,
          }}
        >
          {/* The look of the card (radius, shadow) lives on this inner box, so the button itself stays a plain rectangle */}
          <span className={`pointer-events-none block h-full w-full overflow-hidden group-focus-visible:outline-2 group-focus-visible:outline-offset-4 group-focus-visible:outline-current ${cardClassName}`}>
            {/* The button already carries the name, so the picture inside stays quiet */}
            <img src={card.image} alt="" draggable={false} className="block h-full w-full object-cover select-none" />
          </span>
          {/* When the card rises it leaves a gap under the cursor. This invisible strip fills it, so the card doesn't flicker up and down at its edge */}
          <span aria-hidden className="absolute inset-x-0 top-full" style={{ height: lift }} />
        </button>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { PerspectiveCardStack, type PerspectiveCard, type PerspectiveCardStackProps } from "@/components/dimaac/perspective-card-stack";

// One pile of photos in the middle of a quiet page. Photos: Lummi (Lummi License).

const cards: PerspectiveCard[] = [
  { image: "https://ui.dimaac.com/media/architecture/palm-pool-3x2.jpg", alt: "A white pavilion and two palm trees on a still pool in the mist" },
  { image: "https://ui.dimaac.com/media/architecture/red-wall-interior-3x2.jpg", alt: "A red tiled wall with two doorways that look onto a desert" },
  { image: "https://ui.dimaac.com/media/architecture/concrete-corner-3x2.jpg", alt: "A rough concrete corner above a black window and dark gravel" },
];

export default function Demo(props: Partial<PerspectiveCardStackProps>) {
  return (
    <div className="relative flex h-full w-full flex-col overflow-hidden bg-[#ecebe6] text-[#17201f] dark:bg-[#0b0d0d] dark:text-[#e6eeec]">
      {/* A soft pool of light behind the pile, so the photos have something to sit on */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0"
        style={{ background: "radial-gradient(ellipse 55% 50% at 50% 52%, color-mix(in srgb, currentColor 11%, transparent), transparent 70%)" }}
      />

      <div className="relative flex min-h-0 flex-1 items-center justify-center px-4 pt-6 pb-4">
        <PerspectiveCardStack cards={cards} cardWidth={440} cardHeight={310} className="max-w-[820px]" {...props} />
      </div>

      <footer className="relative z-10 flex items-end justify-between gap-4 px-5 pb-5 sm:px-9 sm:pb-8">
        <h1 className="m-0 max-w-[14ch] text-[clamp(26px,4vw,48px)] leading-[0.98] font-medium tracking-[-0.04em]">Three photos, one pile.</h1>
        <p className="m-0 font-mono text-[11px] tracking-[0.14em] uppercase opacity-60">
          <span className="[@media(hover:none)]:hidden">Hover to fan out</span>
          <span className="hidden [@media(hover:none)]:inline">Tap to fan out</span>
        </p>
      </footer>
    </div>
  );
}
```

## Agent prompt

```text
Add the Perspective Card Stack component (<PerspectiveCardStack />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/perspective-card-stack
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/perspective-card-stack.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/perspective-card-stack.json

Then use it like this:
import { PerspectiveCardStack } from "@/components/dimaac/perspective-card-stack";
<PerspectiveCardStack cards={cards} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/perspective-card-stack.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Pin rotate sections
description: Pinned sections that turn in 3D.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/pin-rotate-sections
registry: https://ui.dimaac.com/r/pin-rotate-sections.json
---

# Pin rotate sections

A stack of full-screen sections. Each one pins to the top of the page, and while the next section slides up over it the pinned one shrinks, tips back in 3D, turns a few degrees and fades to dark, like a deck of cards being pushed away. The tilt alternates left and right from card to card. The animation is scrubbed to the scrollbar, so it runs back when you scroll up. It works on the page or inside any scrolling element through the scroller prop, handles cards taller than the screen on phones, measures again when pictures load, and falls back to an ordinary page with every section visible when the visitor prefers reduced motion. Four small parts: PinRotateSections, PinRotateIntro, PinRotateSection and PinRotateOutro.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/pin-rotate-sections.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/pin-rotate-sections.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/pin-rotate-sections.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/pin-rotate-sections`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/pin-rotate-sections.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PinRotateSections } from "@/components/dimaac/pin-rotate-sections" and give its parent a size: it fills it.

## Usage

```tsx
import { PinRotateSections } from "@/components/dimaac/pin-rotate-sections";

<PinRotateSections children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | - | The intro, the sections and the outro, in order. Sections are found by <PinRotateSection>. |
| `shrink` | `number` | `0.25` | How much each section shrinks while it is pushed away. 0.25 ends at 75% of its size. |
| `tilt` | `number` | `40` | How far each section tips back, in degrees. The direction alternates from card to card. |
| `twist` | `number` | `5` | How far each section turns in the page plane, in degrees. Also alternates. |
| `dim` | `number` | `0.4` | How dark a pushed-away section gets, from 0 (not at all) to 1 (black). |
| `perspective` | `number` | `2200` | Depth of the tilt, in pixels. Smaller is more dramatic. Below about 800 the corners can glitch. |
| `backdrop` | `string` | `"#1a1a1a"` | The colour that shows behind and around the shrinking sections. |
| `scroller` | `string \| HTMLElement` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes for the outer wrapper. |

## Credits

- Concrete Corner: Ricardo Matos, Lummi (https://www.lummi.ai/photo/minimalist-urban-design-abiri), Lummi License
- Balcony Study: Mariate, Lummi (https://www.lummi.ai/photo/minimalist-architectural-design-b8bbc), Lummi License
- The Red Wall: Siren Media, Lummi (https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl), Lummi License
- White Corner: Mikiwa, Lummi (https://www.lummi.ai/photo/modern-building-corner-dr5dz), Lummi License
- Palm Court: Binks AI, Lummi (https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c), Lummi License

## Source

### pin-rotate-sections.tsx → `components/dimaac/pin-rotate-sections.tsx`

```tsx
"use client";

import { Children, useRef, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// Full-screen sections that stack like a deck of cards. Each one pins to the top of the page, and
// while the next section slides up over it, the pinned one shrinks, tilts back and fades to dark,
// as if it were being pushed away. Four small pieces:
//   <PinRotateSections>  wraps everything and runs the animation
//   <PinRotateIntro> and <PinRotateOutro>  the plain dark openers and closers
//   <PinRotateSection>  one card in the stack. The animated ones are the sections.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);

export type PinRotateSectionsProps = {
  /** The intro, the sections and the outro, in order. Sections are found by <PinRotateSection>. */
  children: ReactNode;
  /** How much each section shrinks while it is pushed away. 0.25 ends at 75% of its size. */
  shrink?: number;
  /** How far each section tips back, in degrees. The direction alternates from card to card. */
  tilt?: number;
  /** How far each section turns in the page plane, in degrees. Also alternates. */
  twist?: number;
  /** How dark a pushed-away section gets, from 0 (not at all) to 1 (black). */
  dim?: number;
  /** Depth of the tilt, in pixels. Smaller is more dramatic. Below about 800 the corners can glitch. */
  perspective?: number;
  /** The colour that shows behind and around the shrinking sections. */
  backdrop?: string;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes for the outer wrapper. */
  className?: string;
};

export function PinRotateSections({
  children,
  shrink = 0.25,
  tilt = 40,
  twist = 5,
  dim = 0.4,
  perspective = 2200,
  backdrop = "#1a1a1a",
  scroller,
  className = "",
}: PinRotateSectionsProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  // The effect has to start again when sections are added or removed
  const childCount = Children.count(children);

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;

      const mm = gsap.matchMedia();

      // Reduced motion: no pinning and no tilting. The sections are just a normal page.
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const sections = gsap.utils.toArray<HTMLElement>("[data-pin-rotate-section]", root);
        const last = sections[sections.length - 1];
        if (!last) return;

        // The scroller can be a selector or an element: find the element once. A selector must be looked up
        // here, outside the scope, because the scope only searches inside this wrapper and the scroller is
        // always outside it. Using the element also lets us measure the scroller's height, not the window's.
        const scrollEl = (typeof scroller === "string" ? document.querySelector<HTMLElement>(scroller) : scroller) ?? undefined;
        // The height we measure sections against: the scroller's, or the window's
        const viewHeight = () => (scrollEl ? scrollEl.clientHeight : window.innerHeight);

        sections.forEach((section, index) => {
          const next = sections[index + 1];
          // The last card doesn't move: it is the one everything is pushed away by
          if (!next) return;

          const direction = index % 2 === 0 ? 1 : -1;
          const overlay = section.querySelector<HTMLElement>("[data-pin-rotate-overlay]");

          // Pin the card until the last one arrives, so the next card slides over the top of it.
          // A card taller than the screen (common on phones) pins by its bottom edge instead,
          // so its whole content gets read before it is covered.
          ScrollTrigger.create({
            trigger: section,
            scroller: scrollEl,
            start: () => (section.offsetHeight > viewHeight() ? "bottom bottom" : "top top"),
            endTrigger: last,
            end: "top top",
            pin: true,
            pinSpacing: false,
          });

          // While the next card climbs from the bottom edge to the top, the pinned card is pushed away.
          // scrub ties the animation to the scrollbar, so it runs back when you scroll up.
          const away = gsap.timeline({
            defaults: { ease: "none" },
            scrollTrigger: { trigger: next, scroller: scrollEl, start: "top bottom", end: "top top", scrub: true },
          });
          away.to(section, { scale: 1 - shrink, rotation: direction * twist, rotationX: direction * tilt, transformPerspective: perspective || 0, force3D: true }, 0);
          if (overlay) away.to(overlay, { opacity: dim }, 0);
        });

        // Pictures arriving late change the page height, so measure the pins again.
        // A short delay turns a burst of loads into one measurement.
        let measure: gsap.core.Tween | undefined;
        const observer = new ResizeObserver(() => {
          measure?.kill();
          measure = gsap.delayedCall(0.2, () => ScrollTrigger.refresh());
        });
        // Only the wrapper: a pinned card changes its own box while it sticks, which must not re-trigger this
        observer.observe(root);

        return () => {
          measure?.kill();
          observer.disconnect();
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [childCount, shrink, tilt, twist, dim, perspective, scroller], revertOnUpdate: true },
  );

  return (
    <div ref={rootRef} className={`relative w-full overflow-x-clip ${className}`} style={{ backgroundColor: backdrop }}>
      {children}
    </div>
  );
}

export type PinRotateIntroProps = {
  /** The opening words: usually a heading and a paragraph. */
  children: ReactNode;
  /** Extra classes: change the colours, the height or the padding here. */
  className?: string;
};

/** A full-height dark opener. It scrolls normally. */
export function PinRotateIntro({ children, className = "" }: PinRotateIntroProps) {
  return (
    <section data-pin-rotate-intro className={`flex min-h-[100svh] flex-col items-center justify-center bg-[#1a1a1a] px-[6vw] text-center text-white md:px-[8vw] ${className}`}>
      {children}
    </section>
  );
}

export type PinRotateSectionProps = {
  /** What the card shows. */
  children: ReactNode;
  /** Extra classes: the background, text colour and padding. Cards are paper-coloured by default. */
  className?: string;
  /** Inline styles for the card, e.g. a background image. */
  style?: CSSProperties;
};

/** One card of the stack. It pins, then shrinks and darkens as the next card covers it. */
export function PinRotateSection({ children, className = "", style }: PinRotateSectionProps) {
  return (
    <section
      data-pin-rotate-section
      className={`relative flex min-h-[85svh] flex-col bg-[#fcfcfc] px-[6vw] py-[5vh] text-[#1a1a1a] md:flex-row md:justify-between md:px-[8vw] md:py-[10vh] ${className}`}
      style={{ borderBottom: "1px solid rgba(0,0,0,0.24)", willChange: "transform", transformStyle: "flat", ...style }}
    >
      {children}
      {/* The shadow that grows over the card as it is pushed away */}
      <div data-pin-rotate-overlay aria-hidden="true" className="pointer-events-none absolute inset-0 bg-black opacity-0 [backface-visibility:hidden]" />
    </section>
  );
}

export type PinRotateOutroProps = {
  /** The closing words. */
  children: ReactNode;
  /** Extra classes: change the colours, the height or the padding here. */
  className?: string;
};

/** A full-height dark closer. The last card slides up over the stack and ends on this. */
export function PinRotateOutro({ children, className = "" }: PinRotateOutroProps) {
  return (
    <section data-pin-rotate-outro className={`relative flex min-h-[100svh] flex-col items-center justify-center bg-[#1a1a1a] px-[6vw] text-center text-white md:px-[8vw] ${className}`}>
      {children}
    </section>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { PinRotateIntro, PinRotateOutro, PinRotateSection, PinRotateSections, type PinRotateSectionsProps } from "@/components/dimaac/pin-rotate-sections";

// A short guide to five buildings. The cards are paper, the page behind them is ink, so the push-away
// reads clearly in both the light and the dark site theme.

const buildings = [
  {
    title: "Concrete Corner",
    image: "architecture/concrete-corner-3x2.jpg",
    alt: "A minimalist architectural corner with rough concrete walls, a black-framed window and gravel at its foot",
    text: "Rough board-marked concrete meets one black window frame. Nothing is hidden and nothing is added, so the light on the wall does all the talking.",
  },
  {
    title: "Balcony Study",
    image: "architecture/concrete-balconies-3x2.jpg",
    alt: "Concrete balconies stacked against a pale sky",
    text: "Stacked balconies cut hard shadows into the facade. Walk past at noon and again at dusk: it is a different building each time.",
  },
  {
    title: "The Red Wall",
    image: "architecture/red-wall-interior-3x2.jpg",
    alt: "A modern interior with a bold red textured wall and large windows onto a desert",
    text: "One saturated wall, a few pieces of furniture, and a window that frames the desert. The room asks very little of you and gives a lot back.",
  },
  {
    title: "White Corner",
    image: "architecture/white-corner-16x9.jpg",
    alt: "A white building corner with a teal-tinted window against a teal sky",
    text: "Flat white panels and a single tinted window turn a street corner into a sculpture. Best seen on a cloudless morning.",
  },
  {
    title: "Palm Court",
    image: "architecture/palm-pool-3x2.jpg",
    alt: "Minimalist architecture with palm trees standing on a platform of water",
    text: "Palms stand in a still sheet of water and the architecture simply gets out of the way. It is the quietest stop of the five.",
  },
];

const body = "font-[family-name:Raleway,sans-serif]";

const Pulse = () => (
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="ml-[0.14em] inline h-[0.9em] w-[0.9em] self-center">
    <path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" strokeWidth="0.6" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

const Heart = () => (
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="ml-[0.14em] inline h-[0.9em] w-[0.9em] self-center">
    <path
      d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
      stroke="currentColor"
      strokeWidth="0.6"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  </svg>
);

export default function Demo(props: Partial<PinRotateSectionsProps>) {
  const { children: _children, className = "", ...settings } = props;
  return (
    <div className={`${body} h-full w-full`}>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Raleway:wght@100..900&display=swap" precedence="default" />

      <PinRotateSections {...settings} className={className}>
        <PinRotateIntro className="bg-[#1a1a1a]">
          <h2 className="flex flex-wrap items-center justify-center text-[clamp(34px,6.4vw,92px)] leading-[1.05] font-medium tracking-[-0.03em]">
            <span>Five</span>
            <span className="mx-[0.25em] flex items-center font-thin text-[#ffd9b3]">
              Buildings
              <Pulse />
            </span>
            <span>Worth the Trip</span>
          </h2>
          <p className="mt-5 max-w-[34rem] text-[clamp(15px,1.4vw,19px)] leading-relaxed opacity-75">Scroll down. Each stop pins to the page, then gets pushed back as the next one slides over it.</p>
        </PinRotateIntro>

        {buildings.map((building, index) => (
          <PinRotateSection key={building.title} className="gap-6 md:gap-0">
            <span className="text-[clamp(44px,12vw,64px)] leading-none font-semibold tracking-[-0.03em] md:text-[7vw]">({String(index + 1).padStart(2, "0")})</span>
            <div className="flex w-full flex-col items-start md:w-[60%]">
              <h3 className="mb-4 text-[clamp(30px,8vw,48px)] leading-none font-medium tracking-[-0.04em] uppercase md:mb-7 md:text-[3.6vw]">{building.title}</h3>
              <div className="w-full overflow-hidden rounded-[4px] bg-black/10">
                <img src={media(building.image)} alt={building.alt} loading="eager" decoding="async" draggable={false} className="block aspect-[16/9] max-h-[44svh] w-full object-cover md:max-h-[46svh]" />
              </div>
              <p className="mt-4 max-w-full text-[15px] leading-snug opacity-80 md:mt-6 md:max-w-[72%] md:text-base md:leading-relaxed">{building.text}</p>
            </div>
          </PinRotateSection>
        ))}

        <PinRotateOutro className="bg-[#1a1a1a]">
          <h2 className="flex flex-wrap items-center justify-center text-[clamp(34px,6.4vw,92px)] leading-[1.05] font-medium tracking-[-0.03em]">
            <span>Safe</span>
            <span className="mx-[0.25em] flex items-center font-thin text-[#ffd9b3]">
              Travels
              <Heart />
            </span>
            <span>Out There</span>
          </h2>
          <p className="mt-5 max-w-[34rem] text-[clamp(15px,1.4vw,19px)] leading-relaxed opacity-75">Go slowly, look up, and bring a camera.</p>
        </PinRotateOutro>
      </PinRotateSections>
    </div>
  );
}
```

## Agent prompt

```text
Add the Pin rotate sections component (<PinRotateSections />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/pin-rotate-sections
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/pin-rotate-sections.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/pin-rotate-sections.json

Then use it like this:
import { PinRotateSections } from "@/components/dimaac/pin-rotate-sections";
<PinRotateSections children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/pin-rotate-sections.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Pixel Hover Reveal
description: Hover a name and its picture breaks in, pixel by pixel.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/pixel-hover-reveal
registry: https://ui.dimaac.com/r/pixel-hover-reveal.json
---

# Pixel Hover Reveal

A list of big names. Hover one and its picture breaks in cell by cell on a small canvas that glides after the cursor; leave and the cells scatter out again. The picture is cut into a grid of square cells that fade in on their own, scattered, line by line or growing out from the middle, and the whole reveal always lasts the duration you set. Keyboard focus opens the picture beside the name, a tap opens it on touch screens (which also play through the list by themselves until the first tap), and with reduced motion the picture appears at once, beside the name, without chasing the cursor. One 2D canvas and GSAP, no WebGL.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/pixel-hover-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/pixel-hover-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/pixel-hover-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/pixel-hover-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/pixel-hover-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PixelHoverReveal } from "@/components/dimaac/pixel-hover-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { PixelHoverReveal } from "@/components/dimaac/pixel-hover-reveal";

<PixelHoverReveal items={items} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `PixelHoverRevealItem[]` | - | The rows, top first. Three to six read best. |
| `cellsPerRow` | `number` | `12` | How many cells across the picture. Fewer means bigger, chunkier pixels. |
| `duration` | `number` | `0.8` | How long the whole picture takes to break in, in seconds. |
| `smoothness` | `number` | `1` | How many cells fade at once. 0 pops each cell in, 4 is a soft dissolve. |
| `order` | `"random" \| "row" \| "center"` | `"random"` | The order the cells appear in: scattered, line by line, or growing out from the middle. |
| `previewWidth` | `number` | `360` | Width of the picture, in pixels. It shrinks on small screens. |
| `previewHeight` | `number` | `540` | Height of the picture, in pixels. |
| `follow` | `number` | `0.5` | How long the picture takes to catch up with the cursor, in seconds. |
| `autoplay` | `"touch" \| "always" \| "never"` | `"touch"` | When the rows open by themselves: on touch screens until the first tap, always (until the pointer arrives) or never. |
| `className` | `string` | `""` | Extra classes for the outer box. It sizes to its rows. |
| `rowClassName` | `string` | `""` | Extra classes for each row. |
| `labelClassName` | `string` | `"text-[clamp(40px,9vw,132px)]"` | Extra classes for each name, e.g. its font size. |

## Credits

- Gradient sky: Nika, Lummi (https://www.lummi.ai/photo/serene-gradient-landscape-80b2l), Lummi License
- Architecture: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Cylinders: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Portrait: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Coastline: Cayetano Gros, Lummi (https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7), Lummi License

## Source

### pixel-hover-reveal.tsx → `components/dimaac/pixel-hover-reveal.tsx`

```tsx
"use client";

import { useRef, useState, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A list of names. Hover one and its picture breaks in cell by cell, like pixels, on a small
// canvas that follows the cursor. The picture is drawn once into a hidden "frame" canvas, cut
// into a grid of square cells, and every cell fades in on its own, a moment after the last one.
// Keyboard focus and taps on touch screens open the picture next to the name instead.

export type PixelHoverRevealItem = {
  /** The name shown in the list */
  label: string;
  /** The picture that breaks in when the name is hovered or focused */
  src: string;
  /** Describes the picture for screen readers */
  alt?: string;
  /** A short line on the right of the row, e.g. "Portrait · 2026" */
  meta?: string;
  /** Makes the row a link instead of a button */
  href?: string;
};

export type PixelHoverRevealProps = {
  /** The rows, top first. Three to six read best. */
  items: PixelHoverRevealItem[];
  /** How many cells across the picture. Fewer means bigger, chunkier pixels. */
  cellsPerRow?: number;
  /** How long the whole picture takes to break in, in seconds. */
  duration?: number;
  /** How many cells fade at once. 0 pops each cell in, 4 is a soft dissolve. */
  smoothness?: number;
  /** The order the cells appear in: scattered, line by line, or growing out from the middle. */
  order?: "random" | "row" | "center";
  /** Width of the picture, in pixels. It shrinks on small screens. */
  previewWidth?: number;
  /** Height of the picture, in pixels. */
  previewHeight?: number;
  /** How long the picture takes to catch up with the cursor, in seconds. */
  follow?: number;
  /** When the rows open by themselves: on touch screens until the first tap, always (until the pointer arrives) or never. */
  autoplay?: "touch" | "always" | "never";
  /** Extra classes for the outer box. It sizes to its rows. */
  className?: string;
  /** Extra classes for each row. */
  rowClassName?: string;
  /** Extra classes for each name, e.g. its font size. */
  labelClassName?: string;
};

type Cell = { x: number; y: number; width: number; height: number; alpha: number };

// The handlers the rows call. They are made inside the GSAP context, so their tweens are cleaned up with it.
type Handlers = {
  enter: (index: number, event: ReactPointerEvent) => void;
  leave: (index: number) => void;
  focus: (index: number, row: HTMLElement) => void;
  blur: (index: number) => void;
  tap: (index: number) => void;
  close: () => void;
};

const GAP = 16;
// How far an open name slides right (the label's motion-safe:translate-x-6), so the picture clears it
const SHIFT = 24;

export function PixelHoverReveal({
  items,
  cellsPerRow = 12,
  duration = 0.8,
  smoothness = 1,
  order = "random",
  previewWidth = 360,
  previewHeight = 540,
  follow = 0.5,
  autoplay = "touch",
  className = "",
  rowClassName = "",
  labelClassName = "text-[clamp(40px,9vw,132px)]",
}: PixelHoverRevealProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const previewRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const handlers = useRef<Handlers | null>(null);
  // The type of the last pointer that pressed a row, so a tap can be told from a mouse click
  const pressedWith = useRef("mouse");
  // Kept across rebuilds (a prop change rebuilds everything), so the open row plays again with the new values
  const open = useRef({ index: -1, mode: "pointer" as "pointer" | "anchor", x: 0, y: 0 });
  const [active, setActive] = useState(-1);
  // A change of pictures rebuilds, a new array with the same pictures doesn't
  const sources = items.map((item) => item.src).join("\n");

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const preview = previewRef.current;
      const canvas = canvasRef.current;
      const context = canvas?.getContext("2d");
      if (!box || !preview || !canvas || !context || !contextSafe) return;

      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const touchScreen = window.matchMedia("(hover: none)").matches;

      // The picture, drawn to fit (object-fit: cover) into a hidden canvas the cells copy from
      const frame = document.createElement("canvas");
      const frameContext = frame.getContext("2d");
      if (!frameContext) return;

      let cells: Cell[] = [];
      let width = 0;
      let height = 0;
      let current = -1;
      let hovered = -1;
      let pointer = { x: 0, y: 0 };
      // The cursor in window coordinates, so the picture can find it again after the box moves (resize, scroll)
      let client: { clientX: number; clientY: number } | null = null;
      let laidOutFor = "";

      // Load every picture up front so the first hover doesn't wait on the network
      const images = items.map((item, index) => {
        const image = new Image();
        image.decoding = "async";
        image.onload = () => {
          if (index !== current) return;
          paintFrame();
          draw();
        };
        image.src = item.src;
        return image;
      });

      const moveX = gsap.quickTo(preview, "x", { duration: follow, ease: "power3" });
      const moveY = gsap.quickTo(preview, "y", { duration: follow, ease: "power3" });

      function draw() {
        context!.clearRect(0, 0, canvas!.width, canvas!.height);
        for (const cell of cells) {
          if (cell.alpha <= 0) continue;
          context!.globalAlpha = cell.alpha;
          context!.drawImage(frame, cell.x, cell.y, cell.width, cell.height, cell.x, cell.y, cell.width, cell.height);
        }
        context!.globalAlpha = 1;
      }

      function paintFrame() {
        frameContext!.clearRect(0, 0, frame.width, frame.height);
        const image = images[current];
        if (!image || !image.complete || image.naturalWidth === 0) return;
        const scale = Math.max(frame.width / image.naturalWidth, frame.height / image.naturalHeight);
        const drawWidth = image.naturalWidth * scale;
        const drawHeight = image.naturalHeight * scale;
        frameContext!.drawImage(image, (frame.width - drawWidth) / 2, (frame.height - drawHeight) / 2, drawWidth, drawHeight);
      }

      // Size the picture (smaller on phones and short screens) and cut it into square cells. Cell
      // edges are rounded to whole pixels, so no hairline gaps show between them.
      function layout() {
        laidOutFor = `${box!.clientWidth}x${window.innerHeight}`;
        const ratio = previewHeight / previewWidth;
        width = Math.round(Math.min(previewWidth, box!.clientWidth * 0.46, (window.innerHeight * 0.66) / ratio));
        height = Math.round(width * ratio);
        preview!.style.width = `${width}px`;
        preview!.style.height = `${height}px`;
        const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
        canvas!.width = frame.width = Math.max(1, Math.round(width * pixelRatio));
        canvas!.height = frame.height = Math.max(1, Math.round(height * pixelRatio));

        gsap.killTweensOf(cells);
        const size = canvas!.width / cellsPerRow;
        const rows = Math.ceil(canvas!.height / size);
        cells = [];
        for (let row = 0; row < rows; row++) {
          for (let column = 0; column < cellsPerRow; column++) {
            const x = Math.round(column * size);
            const y = Math.round(row * size);
            cells.push({ x, y, width: Math.round((column + 1) * size) - x, height: Math.round((row + 1) * size) - y, alpha: current >= 0 ? 1 : 0 });
          }
        }
        paintFrame();
        draw();
        if (current >= 0) replace();
      }

      // The rows reflowed or the box moved under a still cursor: put the open picture back where it belongs
      function replace() {
        if (current < 0) return;
        if (open.current.mode === "pointer" && !reduced) {
          if (!client) return;
          pointer = pointerFrom(client);
          place(pointer, false);
        } else {
          place(anchorFor(current), false);
        }
      }

      // How long each cell waits, in "steps": the whole reveal is the last cell's wait plus one fade
      function waits() {
        const count = cells.length;
        if (order === "row") return { waits: cells.map((_, index) => index), last: count - 1 };
        if (order === "center") {
          const rows = Math.ceil(count / cellsPerRow);
          const middleX = (cellsPerRow - 1) / 2;
          const middleY = (rows - 1) / 2;
          const distances = cells.map((_, index) => Math.hypot((index % cellsPerRow) - middleX, Math.floor(index / cellsPerRow) - middleY));
          return { waits: distances, last: Math.max(1, ...distances) };
        }
        const shuffled = gsap.utils.shuffle(cells.map((_, index) => index));
        const waits: number[] = [];
        shuffled.forEach((cell, position) => (waits[cell] = position));
        return { waits, last: count - 1 };
      }

      function reveal() {
        paintFrame();
        if (reduced) {
          gsap.killTweensOf(cells);
          cells.forEach((cell) => (cell.alpha = 1));
          draw();
          return;
        }
        // Spread the reveal so the last cell finishes right at `duration`
        const { waits: wait, last } = waits();
        const step = duration / (last + smoothness);
        // A zero-length tween would jump straight to its end, so "no fade" is a fade too short to see
        const fade = Math.max(smoothness * step, 0.001);
        gsap.fromTo(
          cells,
          { alpha: 0 },
          { alpha: 1, duration: fade, ease: "none", stagger: (index: number) => wait[index]! * step, overwrite: true, onUpdate: draw, onComplete: draw },
        );
      }

      // A quick scatter out, so closing feels like the same effect run backwards
      function conceal() {
        if (reduced) {
          gsap.killTweensOf(cells);
          cells.forEach((cell) => (cell.alpha = 0));
          draw();
          return;
        }
        gsap.to(cells, { alpha: 0, duration: 0.1, ease: "none", stagger: { amount: 0.18, from: "random" }, overwrite: true, onUpdate: draw, onComplete: draw });
      }

      // Where the picture sits when it isn't following the cursor: just after the name, inside the box.
      // When the name is too long for the picture to fit beside it (phones), the picture moves just
      // above or below the row instead, at the right edge, so the name it belongs to stays readable.
      function anchorFor(index: number) {
        const label = labelRefs.current[index];
        const row = label?.closest("li");
        const boxRect = box!.getBoundingClientRect();
        const rect = label?.getBoundingClientRect() ?? boxRect;
        const rowRect = row?.getBoundingClientRect() ?? rect;
        const maxX = Math.max(width / 2 + GAP, box!.clientWidth - width / 2 - GAP);
        // The name's right edge once it has slid over: its resting edge (the slide taken out, it may be
        // half way) plus the full slide
        const sliding = label ? parseFloat(getComputedStyle(label).translate) || 0 : 0;
        const right = rect.right - sliding + (reduced ? 0 : SHIFT);
        const besideX = right - boxRect.left + GAP * 2 + width / 2;
        if (besideX <= maxX) {
          return {
            x: Math.max(width / 2 + GAP, besideX),
            y: gsap.utils.clamp(height / 2 + GAP, Math.max(height / 2 + GAP, box!.clientHeight - height / 2 - GAP), rect.top - boxRect.top + rect.height / 2),
          };
        }
        const top = rowRect.top - boxRect.top;
        const bottom = rowRect.bottom - boxRect.top;
        // Rows in the top half show the picture below them, rows in the bottom half above
        const below = top + (bottom - top) / 2 < box!.clientHeight / 2;
        return {
          x: Math.max(width / 2, box!.clientWidth - width / 2),
          y: below ? bottom + GAP / 2 + height / 2 : top - GAP / 2 - height / 2,
        };
      }

      // Moves the picture's middle to a point in the box: a glide if it's already showing, a jump if not
      function place(point: { x: number; y: number }, glide: boolean) {
        open.current.x = point.x;
        open.current.y = point.y;
        // Kept inside the box sideways, so a hover at the end of a full-width row never adds a
        // sideways scrollbar to the page (up and down it may pass the box, like the original)
        const x = gsap.utils.clamp(0, Math.max(0, box!.clientWidth - width), point.x - width / 2);
        const y = point.y - height / 2;
        if (glide && !reduced) {
          moveX(x);
          moveY(y);
        } else {
          // Start and end at the same spot: a jump that also stops any glide still running
          moveX(x, x);
          moveY(y, y);
        }
      }

      function show(index: number, mode: "pointer" | "anchor") {
        const wasOpen = current >= 0;
        const sameRow = index === current;
        current = index;
        open.current.index = index;
        open.current.mode = mode;
        setActive(index);
        // Reduced motion: no chasing the cursor, the picture sits beside the name
        const point = mode === "pointer" && !reduced ? pointer : anchorFor(index);
        place(point, wasOpen);
        if (!sameRow || !wasOpen) reveal();
      }

      function hide() {
        if (current < 0) return;
        current = -1;
        open.current.index = -1;
        setActive(-1);
        conceal();
      }

      const pointerFrom = (event: { clientX: number; clientY: number }) => {
        const rect = box.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
      };

      // Rows open by themselves on touch screens (or always) until someone uses the list
      let autoplayCall: gsap.core.Tween | null = null;
      let autoplayIndex = -1;
      const autoplays = !reduced && items.length > 0 && (autoplay === "always" || (autoplay === "touch" && touchScreen));
      const next = contextSafe(() => {
        autoplayIndex = (autoplayIndex + 1) % items.length;
        show(autoplayIndex, "anchor");
        autoplayCall = gsap.delayedCall(Math.max(2.2, duration + 1.4), next);
      });
      const stopAutoplay = () => {
        autoplayCall?.kill();
        autoplayCall = null;
      };

      handlers.current = {
        enter: contextSafe((index: number, event: ReactPointerEvent) => {
          if (event.pointerType === "touch") return;
          stopAutoplay();
          hovered = index;
          client = { clientX: event.clientX, clientY: event.clientY };
          pointer = pointerFrom(client);
          show(index, "pointer");
        }),
        leave: contextSafe((index: number) => {
          if (hovered === index) hovered = -1;
          if (current === index && open.current.mode === "pointer") hide();
        }),
        focus: contextSafe((index: number, row: HTMLElement) => {
          // A mouse click also focuses the row; only keyboard focus moves the picture beside the name
          if (!row.matches(":focus-visible")) return;
          stopAutoplay();
          show(index, "anchor");
        }),
        blur: contextSafe((index: number) => {
          if (current === index && hovered !== index) hide();
        }),
        tap: contextSafe((index: number) => {
          stopAutoplay();
          if (current === index) hide();
          else show(index, "anchor");
        }),
        close: contextSafe(() => {
          stopAutoplay();
          hide();
        }),
      };

      const onPointerMove = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        client = { clientX: event.clientX, clientY: event.clientY };
        pointer = pointerFrom(client);
        if (current >= 0 && open.current.mode === "pointer" && !reduced) place(pointer, true);
      });
      // A tap anywhere that isn't a row closes the picture
      const onPointerDown = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "touch") return;
        if (!(event.target as Element).closest("[data-pixel-row]")) handlers.current?.close();
      });
      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerdown", onPointerDown);

      // The observer also fires once on start; only a real change of size cuts the cells again,
      // or it would cut short the reveal that's playing
      const onResize = contextSafe(() => {
        const size = `${box.clientWidth}x${window.innerHeight}`;
        if (size !== laidOutFor) layout();
        // Same size but the rows may still have moved (a font loaded, the page above changed)
        else replace();
      });
      const resizeObserver = new ResizeObserver(onResize);
      layout();
      resizeObserver.observe(box);
      // The size cap follows the window's height, which the box alone doesn't report
      window.addEventListener("resize", onResize);
      // The picture is placed in the box, so a scroll under a still cursor would leave it behind
      // (a picture beside a name moves with the box already, so only the cursor's picture needs this)
      const onScroll = contextSafe(() => {
        if (open.current.mode === "pointer") replace();
      });
      window.addEventListener("scroll", onScroll, { capture: true, passive: true });

      // After a rebuild (a prop changed), play the open row again with the new values
      const reopen = open.current;
      if (reopen.index >= 0 && reopen.index < items.length) {
        current = -1;
        pointer = { x: reopen.x, y: reopen.y };
        show(reopen.index, reopen.mode);
      } else if (autoplays) {
        autoplayCall = gsap.delayedCall(0.8, next);
      }

      return () => {
        stopAutoplay();
        handlers.current = null;
        resizeObserver.disconnect();
        window.removeEventListener("resize", onResize);
        window.removeEventListener("scroll", onScroll, { capture: true });
        box.removeEventListener("pointermove", onPointerMove);
        box.removeEventListener("pointerdown", onPointerDown);
        gsap.killTweensOf(cells);
        for (const image of images) {
          image.onload = null;
          image.src = "";
        }
      };
    },
    { scope: boxRef, dependencies: [sources, cellsPerRow, duration, smoothness, order, previewWidth, previewHeight, follow, autoplay], revertOnUpdate: true },
  );

  const shown = active >= 0 ? items[active] : undefined;

  return (
    // No overflow: hidden, so the picture can follow the cursor past the first and last rows
    <div ref={boxRef} className={`relative w-full ${className}`}>
      <ul className="m-0 flex list-none flex-col border-0 border-t border-solid border-current/15 p-0">
        {items.map((item, index) => {
          const isActive = index === active;
          const rowClasses = `group flex w-full cursor-pointer items-baseline justify-between gap-6 border-0 border-b border-solid border-current/15 bg-transparent px-0 py-2 text-left md:py-3 text-inherit no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current focus-visible:outline-solid ${rowClassName}`;
          const inside = (
            <>
              <span
                ref={(label) => {
                  labelRefs.current[index] = label;
                }}
                className={`inline-block pt-[0.04em] pb-[0.1em] leading-[0.9] font-extrabold tracking-[-0.04em] transition-[opacity,translate] duration-[350ms] ease-out ${isActive ? "opacity-35 motion-safe:translate-x-6" : ""} ${labelClassName}`}
              >
                {item.label}
              </span>
              {item.meta ? (
                <span className={`shrink-0 text-[11px] tracking-[0.08em] uppercase transition-opacity duration-[350ms] ${isActive ? "opacity-100" : "opacity-55"}`}>{item.meta}</span>
              ) : null}
            </>
          );
          const shared = {
            "data-pixel-row": "",
            className: rowClasses,
            onPointerEnter: (event: ReactPointerEvent) => handlers.current?.enter(index, event),
            onPointerLeave: () => handlers.current?.leave(index),
            onPointerDown: (event: ReactPointerEvent) => {
              pressedWith.current = event.pointerType;
            },
            onFocus: (event: FocusEvent<HTMLElement>) => handlers.current?.focus(index, event.currentTarget),
            onBlur: () => handlers.current?.blur(index),
            onKeyDown: (event: KeyboardEvent) => {
              pressedWith.current = "keyboard";
              if (event.key === "Escape") handlers.current?.close();
            },
          };
          return (
            <li key={`${index}-${item.label}`}>
              {item.href ? (
                <a href={item.href} {...shared}>
                  {inside}
                </a>
              ) : (
                <button
                  type="button"
                  aria-pressed={isActive}
                  {...shared}
                  // A tap or Enter / Space (a click with no detail) toggles the picture, so the toggle button
                  // really toggles; a mouse click leaves it to hover, which has already opened it
                  onClick={(event) => {
                    if (pressedWith.current === "touch" || event.detail === 0) handlers.current?.tap(index);
                  }}
                >
                  {inside}
                </button>
              )}
            </li>
          );
        })}
      </ul>

      <div
        ref={previewRef}
        role={shown?.alt ? "img" : undefined}
        aria-label={shown?.alt || undefined}
        aria-hidden={shown?.alt ? undefined : true}
        className="pointer-events-none absolute top-0 left-0 z-10"
        style={{ willChange: "transform" }}
      >
        <canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { PixelHoverReveal, type PixelHoverRevealItem, type PixelHoverRevealProps } from "@/components/dimaac/pixel-hover-reveal";

// The list from the video, as an index of five photos: hover a name and its photo breaks in.
// Photos from Lummi (Lummi License: free for personal and commercial use).

const photos: PixelHoverRevealItem[] = [
  { label: "Sunrise", meta: "No. 01", src: "https://ui.dimaac.com/media/art/red-gradient-4x5.jpg", alt: "A red-to-pink gradient sky over shadowy mountain silhouettes" },
  { label: "Yellow planes", meta: "No. 02", src: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg", alt: "Minimal architecture with yellow and beige walls under a clear blue sky" },
  { label: "Colour blocks", meta: "No. 03", src: "https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg", alt: "Orange, red, blue and green cylinders on a bold geometric background" },
  { label: "Neon shades", meta: "No. 04", src: "https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg", alt: "A woman in profile wearing neon pink sunglasses against a bright blue background" },
  { label: "Low tide", meta: "No. 05", src: "https://ui.dimaac.com/media/nature/coastline-aerial-4x5.jpg", alt: "Turquoise waves seen from above, breaking on a sandy beach below rugged cliffs" },
];

export default function Demo(props: Partial<PixelHoverRevealProps>) {
  return (
    <>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;800&display=swap" precedence="default" />
      <div
        className="relative flex h-full w-full flex-col overflow-hidden bg-[#f2f1ee] px-5 py-5 text-[#14110f] md:px-12 md:py-9 dark:bg-[#0f0e0d] dark:text-[#efece6]"
        style={{ fontFamily: '"Inter Tight", ui-sans-serif, system-ui, sans-serif' }}
      >
        <PixelHoverReveal
          items={photos}
          {...props}
          className={`my-auto py-6 ${props.className ?? ""}`}
          labelClassName="text-[clamp(44px,min(10vw,12.5vh),148px)]"
        />

        <p className="text-center font-mono text-[11px] tracking-[0.08em] uppercase opacity-60">
          <span className="[@media(hover:none)]:hidden">Hover or tab through the names</span>
          <span className="hidden [@media(hover:none)]:inline">Tap a name</span>
        </p>
      </div>
    </>
  );
}
```

## Agent prompt

```text
Add the Pixel Hover Reveal component (<PixelHoverReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/pixel-hover-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/pixel-hover-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/pixel-hover-reveal.json

Then use it like this:
import { PixelHoverReveal } from "@/components/dimaac/pixel-hover-reveal";
<PixelHoverReveal items={items} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/pixel-hover-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Portrait Burst
description: Hover a word and a burst of portraits flies out, then tucks back in.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/portrait-burst
registry: https://ui.dimaac.com/r/portrait-burst.json
---

# Portrait Burst

Keywords inside a sentence that throw out a burst of portraits. Hover a word (or tab to it) and its portraits fly to random spots around it, tilted a little, then tuck back into the word when you leave. Each word has its own set of photos, and you choose how many portraits, how far they fly, how much they tilt and how long it takes. Portraits stay inside the box, a tap opens a word on touch screens, and with reduced motion they simply fade in around the word. Rebuilt from a YouTube tutorial demo as one GSAP timeline per word.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/portrait-burst`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/portrait-burst.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PortraitBurst } from "@/components/dimaac/portrait-burst" and give its parent a size: it fills it.

## Usage

```tsx
import { PortraitBurst } from "@/components/dimaac/portrait-burst";

<PortraitBurst items={items} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `BurstItem[]` | - | The words, in reading order, each with its own portraits. |
| `ending` | `string` | `""` | Plain text after the last word. |
| `count` | `number` | `6` | How many portraits burst out of one word. |
| `spread` | `number` | `260` | How far the portraits fly from the word, in pixels. |
| `rotate` | `number` | `12` | The most a portrait is tilted either way, in degrees. |
| `duration` | `number` | `0.8` | How long the burst takes, in seconds. The tuck back takes a bit less. |
| `imageWidth` | `number` | `150` | Width of each portrait, in pixels (they are 4:5). It shrinks on narrow screens. |
| `className` | `string` | `""` | Extra classes for the outer box. It centres the sentence and keeps the portraits inside it, so give it a size. |
| `textClassName` | `string` | `"text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28]"` | Extra classes for the sentence, e.g. its font size and colour. |
| `wordClassName` | `string` | `""` | Extra classes for each word, e.g. a hover colour. |
| `imageClassName` | `string` | `"rounded-[3px] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.45)]"` | Extra classes for each portrait, e.g. a radius or a shadow. |

## Credits

- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-trio-style-va9g1), Lummi License
- Portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License
- Portrait: Axel Photography, Lummi (https://www.lummi.ai/photo/modern-fashion-pose-zsdav), Lummi License
- Portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/stylish-studio-portrait-d9xnl), Lummi License
- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-portrait-rjvlr), Lummi License
- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/futuristic-gold-profile-jnio5), Lummi License
- Portrait: Bernardo Lickfold, Lummi (https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i), Lummi License
- Portrait: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/chic-fashion-portrait-wojpk), Lummi License
- Portrait: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Portrait: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait: Edita, Lummi (https://www.lummi.ai/photo/striking-portrait-kfent), Lummi License
- Portrait: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Portrait: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License
- Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Portrait: Amino, Lummi (https://www.lummi.ai/photo/stylish-portrait-8inth), Lummi License
- Portrait: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s), Lummi License
- Portrait: Benginur Hajjaj, Lummi (https://www.lummi.ai/photo/calm-portrait-d1zqo), Lummi License
- Portrait: Dynamic Wang, Lummi (https://www.lummi.ai/photo/profile-portrait-of-woman-xyyue), Lummi License
- Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/mystery-portrait-cffyo), Lummi License

## Source

### portrait-burst.tsx → `components/dimaac/portrait-burst.tsx`

```tsx
"use client";

import { Fragment, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// Words in a sentence that throw out a burst of portraits when you hover (or tab to) them. The
// portraits fly from the word to random spots around it, then tuck back into it when you leave.
// On touch screens a tap on the word opens it, and a tap anywhere else closes it.

export type BurstItem = {
  /** The hoverable word (or short phrase) */
  word: string;
  /** Portraits that burst out of this word. If `count` is higher than this list, the list repeats. */
  images: string[];
  /** Plain text that comes before the word. Use "\n" for a line break. */
  before?: string;
};

export type PortraitBurstProps = {
  /** The words, in reading order, each with its own portraits. */
  items: BurstItem[];
  /** Plain text after the last word. */
  ending?: string;
  /** How many portraits burst out of one word. */
  count?: number;
  /** How far the portraits fly from the word, in pixels. */
  spread?: number;
  /** The most a portrait is tilted either way, in degrees. */
  rotate?: number;
  /** How long the burst takes, in seconds. The tuck back takes a bit less. */
  duration?: number;
  /** Width of each portrait, in pixels (they are 4:5). It shrinks on narrow screens. */
  imageWidth?: number;
  /** Extra classes for the outer box. It centres the sentence and keeps the portraits inside it, so give it a size. */
  className?: string;
  /** Extra classes for the sentence, e.g. its font size and colour. */
  textClassName?: string;
  /** Extra classes for each word, e.g. a hover colour. */
  wordClassName?: string;
  /** Extra classes for each portrait, e.g. a radius or a shadow. */
  imageClassName?: string;
};

type Spot = { x: number; y: number; rotation: number };

// Spread the portraits round the word like petals: an even split of the circle, nudged randomly,
// each at a random distance. The circle is squashed on a wide box, because a screen is wider than it is tall.
// Spots are kept inside the box so nothing flies off the edge.
function pickSpots(count: number, spread: number, rotate: number, centre: { x: number; y: number }, box: { width: number; height: number }, size: { width: number; height: number }): Spot[] {
  const startAngle = Math.random() * Math.PI * 2;
  const slice = (Math.PI * 2) / count;
  const squash = box.height > box.width ? 1 : 0.7;
  const marginX = size.width / 2 + 8;
  const marginY = size.height / 2 + 8;
  return Array.from({ length: count }, (_, index) => {
    const angle = startAngle + index * slice + (Math.random() - 0.5) * slice * 0.6;
    const reach = spread * (0.6 + Math.random() * 0.4);
    const x = centre.x + Math.cos(angle) * reach;
    const y = centre.y + Math.sin(angle) * reach * squash;
    return {
      x: Math.min(Math.max(x, marginX), Math.max(marginX, box.width - marginX)),
      y: Math.min(Math.max(y, marginY), Math.max(marginY, box.height - marginY)),
      rotation: (Math.random() * 2 - 1) * rotate,
    };
  });
}

export function PortraitBurst({
  items,
  ending = "",
  count = 6,
  spread = 260,
  rotate = 12,
  duration = 0.8,
  imageWidth = 150,
  className = "",
  textClassName = "text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28]",
  wordClassName = "",
  imageClassName = "rounded-[3px] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.45)]",
}: PortraitBurstProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const portraitCount = Math.max(1, Math.round(count));

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      if (!box || !contextSafe) return;

      const mm = gsap.matchMedia();
      mm.add({ reduced: "(prefers-reduced-motion: reduce)", normal: "(prefers-reduced-motion: no-preference)" }, (media) => {
        const reduced = Boolean(media.conditions?.reduced);
        const safe = media.contextSafe ?? contextSafe;

        const words = Array.from(box.querySelectorAll<HTMLButtonElement>("[data-burst-word]"));
        const portraitsOf = (index: number) => Array.from(box.querySelectorAll<HTMLElement>(`[data-burst-portrait="${index}"]`));

        // Park every portrait, hidden and tiny, at the box's top left. A burst moves it to its word first.
        gsap.set(box.querySelectorAll("[data-burst-portrait]"), { xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0.2, rotation: 0, opacity: 0, force3D: true });

        const timelines = new Map<number, gsap.core.Timeline>();
        const opened = new Set<number>();

        // Where the word's middle is, measured from the box's top left corner
        const centreOf = (word: HTMLElement) => {
          const wordRect = word.getBoundingClientRect();
          const boxRect = box.getBoundingClientRect();
          return { x: wordRect.left - boxRect.left + wordRect.width / 2, y: wordRect.top - boxRect.top + wordRect.height / 2 };
        };

        const open = (index: number) => {
          const word = words[index];
          const portraits = portraitsOf(index);
          if (!word || portraits.length === 0 || opened.has(index)) return;
          opened.add(index);
          timelines.get(index)?.kill();

          const centre = centreOf(word);
          const boxRect = box.getBoundingClientRect();
          const size = { width: portraits[0]!.offsetWidth, height: portraits[0]!.offsetHeight };
          const spots = pickSpots(portraits.length, spread, reduced ? 0 : rotate, centre, { width: boxRect.width, height: boxRect.height }, size);

          // A portrait that is still on its way back in carries on from where it is. A hidden one starts inside the word.
          portraits.forEach((portrait, k) => {
            if (Number(gsap.getProperty(portrait, "opacity")) > 0.02) return;
            gsap.set(portrait, { x: reduced ? spots[k]!.x : centre.x, y: reduced ? spots[k]!.y : centre.y, scale: reduced ? 1 : 0.2, rotation: 0, zIndex: k });
          });

          const timeline = gsap.timeline();
          timelines.set(index, timeline);
          if (reduced) {
            timeline.to(portraits, { opacity: 1, x: (k: number) => spots[k]!.x, y: (k: number) => spots[k]!.y, scale: 1, rotation: 0, duration: 0.25, ease: "none" });
            return;
          }
          const each = 0.04;
          timeline
            .to(portraits, { x: (k: number) => spots[k]!.x, y: (k: number) => spots[k]!.y, duration, ease: "power4.out", stagger: each }, 0)
            .to(portraits, { scale: 1, rotation: (k: number) => spots[k]!.rotation, duration: duration * 0.9, ease: "back.out(1.6)", stagger: each }, 0)
            .to(portraits, { opacity: 1, duration: duration * 0.3, ease: "power1.out", stagger: each }, 0);
        };

        const close = (index: number) => {
          const word = words[index];
          const portraits = portraitsOf(index);
          if (!word || portraits.length === 0 || !opened.has(index)) return;
          opened.delete(index);
          timelines.get(index)?.kill();

          const timeline = gsap.timeline();
          timelines.set(index, timeline);
          if (reduced) {
            timeline.to(portraits, { opacity: 0, duration: 0.15, ease: "none" });
            return;
          }
          // The last portrait out goes back in first, like a hand sweeping them up
          const centre = centreOf(word);
          const each = { each: 0.025, from: "end" } as const;
          const back = duration * 0.7;
          timeline
            .to(portraits, { x: centre.x, y: centre.y, scale: 0.2, rotation: 0, duration: back, ease: "power3.in", stagger: each }, 0)
            .to(portraits, { opacity: 0, duration: back * 0.4, ease: "power1.in", stagger: each }, back * 0.6);
        };

        const closeAll = () => opened.forEach((index) => close(index));

        // The ways a word can be reached: a mouse hovers, a keyboard focuses, a finger taps
        let tapped = false;
        const removers = words.map((word, index) => {
          const onEnter = safe((event: PointerEvent) => {
            if (event.pointerType !== "touch") open(index);
          });
          const onLeave = safe((event: PointerEvent) => {
            // A word focused with the keyboard stays open until you tab away from it
            if (event.pointerType !== "touch" && !word.matches(":focus-visible")) close(index);
          });
          const onFocus = safe(() => {
            if (word.matches(":focus-visible")) open(index);
          });
          const onBlur = safe(() => close(index));
          const onPointerDown = safe((event: PointerEvent) => {
            tapped = event.pointerType === "touch";
          });
          const onClick = safe(() => {
            // Only a tap toggles. Enter on a focused word is a click too, and it is already open.
            if (!tapped) return;
            tapped = false;
            if (opened.has(index)) close(index);
            else {
              closeAll();
              open(index);
            }
          });
          word.addEventListener("pointerenter", onEnter);
          word.addEventListener("pointerleave", onLeave);
          word.addEventListener("focus", onFocus);
          word.addEventListener("blur", onBlur);
          word.addEventListener("pointerdown", onPointerDown);
          word.addEventListener("click", onClick);
          return () => {
            word.removeEventListener("pointerenter", onEnter);
            word.removeEventListener("pointerleave", onLeave);
            word.removeEventListener("focus", onFocus);
            word.removeEventListener("blur", onBlur);
            word.removeEventListener("pointerdown", onPointerDown);
            word.removeEventListener("click", onClick);
          };
        });

        // A finger tapping anywhere that is not a word puts the portraits away
        const onDocumentPointerDown = safe((event: PointerEvent) => {
          if (event.pointerType === "touch" && !(event.target instanceof Node && words.some((word) => word.contains(event.target as Node)))) closeAll();
        });
        document.addEventListener("pointerdown", onDocumentPointerDown);

        // The spots were worked out for one box size, so a resize puts everything away
        let firstCall = true;
        const resizeObserver = new ResizeObserver(() => {
          if (firstCall) {
            firstCall = false;
            return;
          }
          closeAll();
        });
        resizeObserver.observe(box);

        return () => {
          removers.forEach((remove) => remove());
          document.removeEventListener("pointerdown", onDocumentPointerDown);
          resizeObserver.disconnect();
          timelines.forEach((timeline) => timeline.kill());
          timelines.clear();
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [items, portraitCount, spread, rotate, duration, imageWidth], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} className={`relative isolate flex h-full w-full items-center justify-center ${className}`}>
      <p className={`m-0 ${textClassName}`} style={{ whiteSpace: "pre-line" }}>
        {items.map((item, index) => (
          <Fragment key={`${index}-${item.word}`}>
            {item.before}
            <button
              type="button"
              data-burst-word={index}
              className={`relative z-[2] m-0 cursor-default border-0 bg-transparent p-0 align-baseline font-[inherit] leading-[inherit] tracking-[inherit] whitespace-nowrap text-[inherit] underline decoration-dotted decoration-1 underline-offset-[0.22em] focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-dotted ${wordClassName}`}
              style={{ touchAction: "manipulation", WebkitTapHighlightColor: "transparent" }}
            >
              {item.word}
            </button>
          </Fragment>
        ))}
        {ending}
      </p>

      {/* The portraits are decoration, so a screen reader skips them. They sit above the sentence and below the words. */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-hidden">
        {items.map((item, index) =>
          Array.from({ length: portraitCount }, (_, k) => (
            <img
              key={`${index}-${k}`}
              data-burst-portrait={index}
              src={item.images[k % Math.max(1, item.images.length)]}
              alt=""
              draggable={false}
              decoding="async"
              className={`absolute top-0 left-0 block aspect-[4/5] object-cover opacity-0 ${imageClassName}`}
              style={{ width: `min(${imageWidth}px, 30vw)`, willChange: "transform, opacity" }}
            />
          )),
        )}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { PortraitBurst, type BurstItem, type PortraitBurstProps } from "@/components/dimaac/portrait-burst";

// Photos from the DiMaac media set (Lummi License: free for personal and commercial use)
const fashion = ["black-trio", "grey-suit-stride", "mid-air-suit", "chunky-knit", "varsity-jacket", "orange-visor", "woven-corset", "gold-visor", "orange-ring", "purple-hoodie"].map((name) => media(`fashion/${name}-4x5.jpg`));
const faces = ["light-streaks", "platinum-blonde", "glossy-lowkey", "neon-profile", "tinted-glasses", "lime-blazer", "red-brushstroke", "red-coat", "burgundy-blazer", "mono-profile", "calm-studio", "warm-cool-profile"].map((name) => media(`portraits/${name}-4x5.jpg`));
const mixed = [
  "https://ui.dimaac.com/media/portraits/red-brushstroke-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/orange-ring-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/burgundy-blazer-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/tinted-glasses-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/varsity-jacket-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/mono-profile-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/gold-visor-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/purple-hoodie-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg",
  "https://ui.dimaac.com/media/fashion/woven-corset-4x5.jpg",
  "https://ui.dimaac.com/media/portraits/platinum-blonde-4x5.jpg",
];

const items: BurstItem[] = [
  { before: "We cut in ", word: "small runs", images: fashion },
  { before: ",\nrefuse the ", word: "throwaway rhythm", images: faces },
  { before: ",\nand ", word: "design", images: mixed },
];

export default function Demo(props: Partial<PortraitBurstProps>) {
  return (
    <div className="relative h-full w-full overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0a0a0a] dark:text-[#ece8e1]">
      {/* Google Fonts stylesheet; React hoists it into the head */}
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@500&display=swap" precedence="default" />
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_55%_at_50%_40%,rgba(255,255,255,0.7),transparent_70%)] dark:bg-[radial-gradient(ellipse_80%_55%_at_50%_40%,rgba(255,255,255,0.05),transparent_70%)]" />

      <PortraitBurst
        items={items}
        ending=" for years on the body"
        {...props}
        className={`absolute inset-0 px-6 ${props.className ?? ""}`}
        textClassName="font-['DM_Serif_Display',Georgia,serif] text-center text-[clamp(2rem,5vw,3.25rem)] leading-[1.28] tracking-[-0.02em]"
        wordClassName="transition-colors duration-200 hover:text-[#8a6f4d] focus-visible:text-[#8a6f4d] dark:hover:text-[#e8dcc8] dark:focus-visible:text-[#e8dcc8]"
      />

      <p
        className="pointer-events-none absolute inset-x-6 bottom-[clamp(1.5rem,5vh,3rem)] z-[3] m-0 text-center text-[11px] tracking-[0.12em] uppercase opacity-45"
        style={{ fontFamily: '"DM Sans", ui-sans-serif, system-ui, sans-serif' }}
      >
        Hover or tab to a dotted word · tap on touch
      </p>
    </div>
  );
}
```

## Agent prompt

```text
Add the Portrait Burst component (<PortraitBurst />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/portrait-burst
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/portrait-burst.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/portrait-burst.json

Then use it like this:
import { PortraitBurst } from "@/components/dimaac/portrait-burst";
<PortraitBurst items={items} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/portrait-burst.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Post Swiper
description: A deck of social posts you swipe through, card by card.
category: Cards
tier: free
url: https://ui.dimaac.com/components/post-swiper
registry: https://ui.dimaac.com/r/post-swiper.json
---

# Post Swiper

A stack of social post cards that you drag away one by one, built on Swiper's cards effect. It ships three card looks (a short-text post, a feed post and a photo post) that mix freely in one deck, and any post can bring its own picture area for quote cards or charts. Like, repost and save buttons work and pop. It loops, drifts on its own if you ask, answers the arrow keys, follows a finger on touch screens, and only lets the front card take focus so tabbing never lands on a hidden button. Card colours switch between dark and light, the cards scale with their width, and reduced motion turns off autoplay and the slide animation. Ported from the first DiMaac UI and reworked so it takes plain post data instead of ready-made elements.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/post-swiper.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/post-swiper.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/post-swiper.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/post-swiper`

Dependencies:

```bash
pnpm add swiper lucide-react
npm install swiper lucide-react
```

By hand:

1. Install the packages it uses: swiper, lucide-react.
2. Copy the component file below into components/dimaac/post-swiper.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PostSwiper } from "@/components/dimaac/post-swiper" and give its parent a size: it fills it.

## Usage

```tsx
import { PostSwiper } from "@/components/dimaac/post-swiper";

<PostSwiper posts={posts} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `posts` (required) | `SocialPost[]` | - | The posts, front card first. Four to ten work best. |
| `loop` | `boolean` | `true` | Wraps around: the last card is followed by the first. Needs at least three posts. |
| `grabCursor` | `boolean` | `true` | Shows a grab cursor over the deck. |
| `autoplayDelay` | `number` | `0` | Seconds between automatic swipes. 0 turns autoplay off. It pauses while the pointer is over the deck. |
| `tilt` | `number` | `2` | How much each card behind the front one is turned, in degrees. |
| `spread` | `number` | `10` | How far each card behind the front one sits out from it, in pixels. |
| `shadows` | `boolean` | `true` | Darkens the cards behind the front one, for depth. |
| `appearance` | `"dark" \| "light"` | `"dark"` | Card colours: "dark" or "light". |
| `cardWidth` | `number` | `380` | The card width in pixels. It shrinks to fit a narrow screen. |
| `cardRatio` | `number` | `0.74` | Card width divided by card height. Lower is taller. |
| `showNav` | `boolean` | `true` | Shows the arrow buttons and the counter under the deck. |
| `label` | `string` | `"Social posts"` | The name screen readers give the deck. |
| `className` | `string` | `""` | Extra classes for the outer box. |
| `cardClassName` | `string` | `""` | Extra classes for each card, e.g. a different radius. |

## Credits

- Photo: Benginur Hajjaj, Lummi (https://www.lummi.ai/photo/calm-portrait-d1zqo), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/stylish-portrait-upjeu), Lummi License
- Photo: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Photo: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License
- Photo: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Photo: Amino, Lummi (https://www.lummi.ai/photo/stylish-portrait-8inth), Lummi License
- Photo: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License
- Photo: Beth Design Studio, Lummi (https://www.lummi.ai/photo/desert-wanderer-9puhu), Lummi License
- Photo: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Photo: Ricardo Matos, Lummi (https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw), Lummi License
- Photo: Krystyna, Lummi (https://www.lummi.ai/photo/moody-solitary-figure-x1avl), Lummi License

## Source

### post-swiper.tsx → `components/dimaac/post-swiper.tsx`

```tsx
"use client";

import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import { A11y, Autoplay, EffectCards, Keyboard } from "swiper/modules";
import type { Swiper as SwiperInstance } from "swiper";
import { ArrowLeft, ArrowRight, BadgeCheck, Bookmark, ChartNoAxesColumn, Ellipsis, Globe, Heart, MessageCircle, Repeat2, Send, Share, ThumbsUp } from "lucide-react";
import "swiper/css";
import "swiper/css/effect-cards";
import "swiper/css/a11y";

// A deck of social posts you swipe through. Swiper's "cards" effect does the stacking and the
// drag; the posts are three small card looks (a short-text post, a photo-first post and a
// feed post with actions) so one deck can mix them. Each card has working like and save buttons.

export type SocialPost = {
  /** A stable key for the post. */
  id: string;
  /** Which card look to use: "x" (short text post), "facebook" (feed post) or "instagram" (photo post). */
  platform: "x" | "facebook" | "instagram";
  /** The author's display name. */
  name: string;
  /** The author's handle without the @ (used by the "x" look). */
  handle?: string;
  /** URL of the author's round profile photo. */
  avatar: string;
  /** When it was posted, as short text: "1h", "Yesterday". */
  time: string;
  /** Shows the verified badge next to the name. */
  verified?: boolean;
  /** The post text. */
  text?: string;
  /** URL of the post's photo. It fills the space the text leaves. */
  image?: string;
  /** Describes the photo for screen readers. */
  imageAlt?: string;
  /** Your own picture area in place of `image`: a quote card, a chart, anything. It fills the same space. */
  media?: ReactNode;
  /** The like count. The like button adds one. */
  likes?: number;
  /** The comment count. */
  comments?: number;
  /** The share or repost count. */
  shares?: number;
};

export type PostSwiperProps = {
  /** The posts, front card first. Four to ten work best. */
  posts: SocialPost[];
  /** Wraps around: the last card is followed by the first. Needs at least three posts. */
  loop?: boolean;
  /** Shows a grab cursor over the deck. */
  grabCursor?: boolean;
  /** Seconds between automatic swipes. 0 turns autoplay off. It pauses while the pointer is over the deck. */
  autoplayDelay?: number;
  /** How much each card behind the front one is turned, in degrees. */
  tilt?: number;
  /** How far each card behind the front one sits out from it, in pixels. */
  spread?: number;
  /** Darkens the cards behind the front one, for depth. */
  shadows?: boolean;
  /** Card colours: "dark" or "light". */
  appearance?: "dark" | "light";
  /** The card width in pixels. It shrinks to fit a narrow screen. */
  cardWidth?: number;
  /** Card width divided by card height. Lower is taller. */
  cardRatio?: number;
  /** Shows the arrow buttons and the counter under the deck. */
  showNav?: boolean;
  /** The name screen readers give the deck. */
  label?: string;
  /** Extra classes for the outer box. */
  className?: string;
  /** Extra classes for each card, e.g. a different radius. */
  cardClassName?: string;
};

// Colours of the two card looks. The card reads them as CSS variables, so a change here is the only edit.
const palettes: Record<NonNullable<PostSwiperProps["appearance"]>, CSSProperties> = {
  dark: { "--pc-bg": "#202224", "--pc-text": "#f1f2f3", "--pc-muted": "#9ea3a9", "--pc-line": "rgba(255,255,255,0.1)", "--pc-hover": "rgba(255,255,255,0.07)" } as CSSProperties,
  light: { "--pc-bg": "#ffffff", "--pc-text": "#14171a", "--pc-muted": "#667078", "--pc-line": "rgba(20,23,26,0.12)", "--pc-hover": "rgba(20,23,26,0.06)" } as CSSProperties,
};

const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
const full = new Intl.NumberFormat("en");

// A quick scale pop on the icon that was pressed. The Web Animations API cleans itself up and
// needs no library. Skipped when the visitor prefers reduced motion.
function pop(element: Element | null) {
  if (!element || typeof element.animate !== "function") return;
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  element.animate([{ transform: "scale(1)" }, { transform: "scale(1.35)" }, { transform: "scale(1)" }], { duration: 320, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" });
}

export function PostSwiper({
  posts,
  loop = true,
  grabCursor = true,
  autoplayDelay = 0,
  tilt = 2,
  spread = 10,
  shadows = true,
  appearance = "dark",
  cardWidth = 380,
  cardRatio = 0.74,
  showNav = true,
  label = "Social posts",
  className = "",
  cardClassName = "",
}: PostSwiperProps) {
  const [swiper, setSwiper] = useState<SwiperInstance | null>(null);
  const [active, setActive] = useState(0);
  const [reduced, setReduced] = useState(false);

  // Reduced motion is read after mount, so the server and the first client render match
  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduced(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);

  // Swiper reads the card effect options once, so after a change we hand it the new ones and redraw
  useEffect(() => {
    if (!swiper || swiper.destroyed) return;
    Object.assign(swiper.params.cardsEffect ?? {}, { perSlideRotate: tilt, perSlideOffset: spread, slideShadows: shadows });
    swiper.update();
  }, [swiper, tilt, spread, shadows]);

  // Autoplay starts and stops from the props (and never runs for visitors who prefer less motion)
  const autoplayOn = autoplayDelay > 0 && !reduced;
  useEffect(() => {
    if (!swiper || swiper.destroyed || !swiper.autoplay) return;
    if (autoplayOn) {
      swiper.params.autoplay = { ...(swiper.params.autoplay as object), delay: autoplayDelay * 1000, disableOnInteraction: false, pauseOnMouseEnter: true };
      swiper.autoplay.start();
    } else {
      swiper.autoplay.stop();
    }
  }, [swiper, autoplayOn, autoplayDelay]);

  // Only the front card can take focus. The cards behind it (and the loop's duplicates) are inert,
  // so tabbing never lands on a button you can't see.
  const markFront = (instance: SwiperInstance) => {
    instance.slides.forEach((slide) => slide.toggleAttribute("inert", !slide.classList.contains("swiper-slide-active")));
  };

  const canLoop = loop && posts.length > 2;
  const go = (direction: 1 | -1) => {
    if (!swiper || swiper.destroyed) return;
    if (direction === 1) swiper.slideNext();
    else swiper.slidePrev();
  };

  return (
    <div
      role="region"
      aria-roledescription="carousel"
      aria-label={label}
      className={`flex w-full flex-col items-center gap-7 ${className}`}
    >
      {/* Remounted when loop changes, because Swiper builds its loop copies once */}
      <Swiper
        key={String(canLoop)}
        modules={[EffectCards, Keyboard, A11y, Autoplay]}
        effect="cards"
        cardsEffect={{ slideShadows: shadows, perSlideRotate: tilt, perSlideOffset: spread }}
        grabCursor={grabCursor}
        loop={canLoop}
        speed={reduced ? 0 : 500}
        keyboard={{ enabled: true, onlyInViewport: true }}
        a11y={{ enabled: true }}
        autoplay={{ delay: Math.max(autoplayDelay, 1) * 1000, disableOnInteraction: false, pauseOnMouseEnter: true }}
        onSwiper={(instance) => {
          setSwiper(instance);
          setActive(instance.realIndex);
          markFront(instance);
          // Autoplay begins on its own; the effect above turns it off when the prop says so
          if (!autoplayOn) instance.autoplay?.stop();
        }}
        onDestroy={() => setSwiper(null)}
        onRealIndexChange={(instance) => setActive(instance.realIndex)}
        onSlideChange={markFront}
        onTransitionEnd={markFront}
        onSlidesUpdated={markFront}
        className="!overflow-visible"
        style={{ width: cardWidth, maxWidth: "100%", aspectRatio: String(cardRatio), margin: 0 }}
      >
        {posts.map((post) => (
          <SwiperSlide key={post.id} className={`!rounded-[1.6rem] bg-transparent shadow-[0_30px_60px_-30px_rgba(0,0,0,0.55)] ${cardClassName}`}>
            <PostCard post={post} appearance={appearance} />
          </SwiperSlide>
        ))}
      </Swiper>

      {showNav && (
        <div className="flex items-center gap-4 font-mono text-[12px] tracking-[0.12em] uppercase tabular-nums">
          <NavButton label="Previous post" onClick={() => go(-1)}>
            <ArrowLeft size={16} aria-hidden="true" />
          </NavButton>
          <span aria-hidden="true" className="min-w-[5.5ch] text-center opacity-70">
            {String(active + 1).padStart(2, "0")} / {String(posts.length).padStart(2, "0")}
          </span>
          <NavButton label="Next post" onClick={() => go(1)}>
            <ArrowRight size={16} aria-hidden="true" />
          </NavButton>
        </div>
      )}
    </div>
  );
}

function NavButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      className="grid size-10 cursor-pointer place-items-center rounded-full border border-current/25 transition-[background-color,transform] duration-200 hover:bg-current/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current active:scale-95"
    >
      {children}
    </button>
  );
}

// ---------------------------------------------------------------------------------------------
// The card. Everything inside is sized in em, and the card sets its own font size from its width
// (a container query), so the same post reads well at 330px and at 420px.
// ---------------------------------------------------------------------------------------------

function PostCard({ post, appearance }: { post: SocialPost; appearance: NonNullable<PostSwiperProps["appearance"]> }) {
  return (
    <div className="h-full" style={{ containerType: "inline-size" }}>
      <article
        className="flex h-full flex-col overflow-hidden rounded-[inherit] border border-[color:var(--pc-line)] bg-[var(--pc-bg)] text-[color:var(--pc-text)] select-none"
        style={{ ...palettes[appearance], fontSize: "clamp(12.5px, 4cqw, 16px)", fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' }}
      >
        {post.platform === "x" && <ShortPost post={post} />}
        {post.platform === "facebook" && <FeedPost post={post} />}
        {post.platform === "instagram" && <PhotoPost post={post} />}
      </article>
    </div>
  );
}

function Avatar({ post, className = "" }: { post: SocialPost; className?: string }) {
  // The name sits right beside it, so the photo is decoration
  return <img src={post.avatar} alt="" draggable={false} loading="lazy" className={`size-[2.9em] shrink-0 rounded-full object-cover ${className}`} />;
}

function Picture({ post, className = "" }: { post: SocialPost; className?: string }) {
  if (!post.media && !post.image) return null;
  return (
    <div className={`relative min-h-0 flex-1 overflow-hidden ${className}`}>
      {post.media ?? (
              <img src={post.image} alt={post.imageAlt ?? ""} draggable={false} loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover" />
      )}
    </div>
  );
}

function Name({ post }: { post: SocialPost }) {
  return (
    <span className="flex min-w-0 items-center gap-[0.3em]">
      <span className="truncate font-bold">{post.name}</span>
      {post.verified && <BadgeCheck aria-label="Verified" size="1.2em" strokeWidth={2.2} className="shrink-0 fill-[#1d9bf0] text-[var(--pc-bg)]" />}
    </span>
  );
}

// A small round or pill button. `on` tints it with `colour` and keeps it pressed.
function Action({ label, text, count, on = false, colour, onPress, children, className = "" }: { label: string; text?: string; count?: number; on?: boolean; colour?: string; onPress?: (icon: HTMLElement | null) => void; children: ReactNode; className?: string }) {
  return (
    <button
      type="button"
      aria-label={label}
      aria-pressed={onPress ? on : undefined}
      onClick={(event) => onPress?.(event.currentTarget.querySelector<HTMLElement>("[data-icon]"))}
      style={on && colour ? { color: colour } : undefined}
      className={`group flex cursor-pointer items-center gap-[0.45em] rounded-full p-[0.5em] text-[color:var(--pc-muted)] transition-colors duration-200 hover:bg-[var(--pc-hover)] hover:text-[color:var(--pc-text)] focus-visible:outline-2 focus-visible:outline-[#1d9bf0] ${className}`}
    >
      <span data-icon className="grid place-items-center">
        {children}
      </span>
      {text && <span className="text-[0.92em] font-semibold">{text}</span>}
      {count !== undefined && <span className="text-[0.88em] tabular-nums">{compact.format(count)}</span>}
    </button>
  );
}

// The short-text look: avatar beside a header, big type when there is no picture, a stats row below.
function ShortPost({ post }: { post: SocialPost }) {
  const [liked, setLiked] = useState(false);
  const [reposted, setReposted] = useState(false);
  const [saved, setSaved] = useState(false);
  const hasPicture = Boolean(post.image || post.media);

  return (
    <div className="flex h-full flex-col gap-[0.8em] p-[1.1em] pb-[0.6em]">
      <header className="flex items-center gap-[0.75em]">
        <Avatar post={post} />
        <div className="flex min-w-0 flex-1 flex-col leading-tight">
          <Name post={post} />
          <span className="truncate text-[0.9em] text-[color:var(--pc-muted)]">
            @{post.handle ?? post.name.toLowerCase().replace(/\s+/g, "")} · {post.time}
          </span>
        </div>
        <Ellipsis size="1.4em" aria-hidden="true" className="text-[color:var(--pc-muted)]" />
      </header>

      {post.text && (
        <p className={`whitespace-pre-wrap break-words ${hasPicture ? "text-[1em] leading-[1.45]" : "my-auto text-[1.6em] leading-[1.22] font-medium tracking-[-0.015em]"}`}>{post.text}</p>
      )}
      <Picture post={post} className="rounded-[1em] border border-[color:var(--pc-line)]" />

      <div className="-mx-[0.5em] mt-auto flex items-center justify-between">
        <Action label="Reply" count={post.comments}>
          <MessageCircle size="1.35em" aria-hidden="true" />
        </Action>
        <Action label="Repost" count={(post.shares ?? 0) + (reposted ? 1 : 0)} on={reposted} colour="#00ba7c" onPress={(icon) => { setReposted(!reposted); if (!reposted) pop(icon); }}>
          <Repeat2 size="1.4em" aria-hidden="true" />
        </Action>
        <Action label="Like" count={(post.likes ?? 0) + (liked ? 1 : 0)} on={liked} colour="#f91880" onPress={(icon) => { setLiked(!liked); if (!liked) pop(icon); }}>
          <Heart size="1.3em" aria-hidden="true" fill={liked ? "currentColor" : "none"} />
        </Action>
        <Action label="Views">
          <ChartNoAxesColumn size="1.35em" aria-hidden="true" />
        </Action>
        <Action label="Save" on={saved} colour="#1d9bf0" onPress={(icon) => { setSaved(!saved); if (!saved) pop(icon); }}>
          <Bookmark size="1.3em" aria-hidden="true" fill={saved ? "currentColor" : "none"} />
        </Action>
      </div>
    </div>
  );
}

// The feed look: header row, text, a full-width picture, a reactions line and three wide buttons.
function FeedPost({ post }: { post: SocialPost }) {
  const [liked, setLiked] = useState(false);
  const hasPicture = Boolean(post.image || post.media);
  const likes = (post.likes ?? 0) + (liked ? 1 : 0);

  return (
    <div className="flex h-full flex-col">
      <header className="flex items-center gap-[0.7em] p-[0.9em] pb-[0.7em]">
        <Avatar post={post} className="size-[2.7em]" />
        <div className="flex min-w-0 flex-1 flex-col leading-tight">
          <Name post={post} />
          <span className="flex items-center gap-[0.35em] text-[0.82em] text-[color:var(--pc-muted)]">
            {post.time} · <Globe size="1em" aria-label="Public" />
          </span>
        </div>
        <Ellipsis size="1.4em" aria-hidden="true" className="text-[color:var(--pc-muted)]" />
      </header>

      {post.text && (
        <p className={`px-[0.95em] break-words whitespace-pre-wrap ${hasPicture ? "pb-[0.8em] text-[0.97em] leading-[1.45]" : "my-auto pb-[0.8em] text-[1.55em] leading-[1.25] font-medium tracking-[-0.015em]"}`}>{post.text}</p>
      )}
      <Picture post={post} />

      <div className="flex items-center justify-between px-[0.95em] pt-[0.7em] pb-[0.5em] text-[0.86em] text-[color:var(--pc-muted)]">
        <span className="flex items-center gap-[0.5em]">
          <span className="grid size-[1.7em] place-items-center rounded-full bg-[#1877f2] text-white">
            <ThumbsUp size="0.95em" fill="currentColor" aria-hidden="true" />
          </span>
          <span className="tabular-nums">{full.format(likes)}</span>
        </span>
        <span className="tabular-nums">
          {full.format(post.comments ?? 0)} comments · {full.format(post.shares ?? 0)} shares
        </span>
      </div>

      <div className="mx-[0.95em] mb-[0.5em] grid grid-cols-3 gap-[0.3em] border-t border-[color:var(--pc-line)] pt-[0.4em]">
        <Action label="Like" text="Like" on={liked} colour="#1877f2" onPress={(icon) => { setLiked(!liked); if (!liked) pop(icon); }} className="justify-center rounded-[0.6em]">
          <ThumbsUp size="1.25em" aria-hidden="true" fill={liked ? "currentColor" : "none"} />
        </Action>
        <Action label="Comment" text="Comment" className="justify-center rounded-[0.6em]">
          <MessageCircle size="1.25em" aria-hidden="true" />
        </Action>
        <Action label="Share" text="Share" className="justify-center rounded-[0.6em]">
          <Share size="1.25em" aria-hidden="true" />
        </Action>
      </div>
    </div>
  );
}

// The photo look: a ringed avatar, the picture taking most of the card, actions and a caption.
function PhotoPost({ post }: { post: SocialPost }) {
  const [liked, setLiked] = useState(false);
  const [saved, setSaved] = useState(false);
  const likes = (post.likes ?? 0) + (liked ? 1 : 0);

  return (
    <div className="flex h-full flex-col">
      <header className="flex items-center gap-[0.7em] p-[0.8em]">
        {/* The gradient ring is the avatar's padding showing through */}
        <span className="rounded-full bg-[conic-gradient(from_200deg,#ffcb03,#e6683c,#dc2743,#cc2366,#d300c5,#ffcb03)] p-[2px]">
          <Avatar post={post} className="size-[2.5em] border-2 border-[var(--pc-bg)]" />
        </span>
        <div className="flex min-w-0 flex-1 items-center gap-[0.4em] text-[0.95em] leading-tight">
          <Name post={post} />
          <span className="text-[color:var(--pc-muted)]">· {post.time}</span>
        </div>
        <Ellipsis size="1.4em" aria-hidden="true" className="text-[color:var(--pc-muted)]" />
      </header>

      <Picture post={post} />

      <div className="flex items-center gap-[0.1em] px-[0.5em] pt-[0.45em]">
        <Action label="Like" on={liked} colour="#ef4444" onPress={(icon) => { setLiked(!liked); if (!liked) pop(icon); }} className="text-[color:var(--pc-text)]">
          <Heart size="1.65em" aria-hidden="true" fill={liked ? "currentColor" : "none"} />
        </Action>
        <Action label="Comment" className="text-[color:var(--pc-text)]">
          <MessageCircle size="1.55em" aria-hidden="true" />
        </Action>
        <Action label="Send" className="text-[color:var(--pc-text)]">
          <Send size="1.5em" aria-hidden="true" />
        </Action>
        <Action label="Save" on={saved} onPress={(icon) => { setSaved(!saved); if (!saved) pop(icon); }} className="ml-auto text-[color:var(--pc-text)]">
          <Bookmark size="1.55em" aria-hidden="true" fill={saved ? "currentColor" : "none"} />
        </Action>
      </div>

      <div className="px-[0.95em] pt-[0.1em] pb-[0.9em] text-[0.93em] leading-[1.4]">
        <p className="font-bold tabular-nums">{full.format(likes)} likes</p>
        {post.text && (
          <p className="mt-[0.15em] line-clamp-2">
            <span className="font-bold">{post.name}</span> {post.text}
          </p>
        )}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { PostSwiper, type PostSwiperProps, type SocialPost } from "@/components/dimaac/post-swiper";

// A small feed of seven posts. Every photo is from Lummi (Lummi License: free for personal and
// commercial use).

function QuoteCard() {
  // A picture area you draw yourself: it fills the same space a photo would
  return (
    <div className="absolute inset-0 grid place-items-center bg-[linear-gradient(to_top,#923857,#ffafdf)] p-[2em] text-center text-white">
      <div className="absolute inset-0 opacity-[0.07] [background-image:linear-gradient(to_right,#fff_1px,transparent_1px),linear-gradient(to_bottom,#fff_1px,transparent_1px)] [background-size:20px_20px]" />
      <div className="relative">
        <p className="text-[2.4em] leading-none font-bold tracking-wide">No. 06</p>
        <p className="mt-[0.9em] text-[1.05em] font-light opacity-90">small things,</p>
        <p className="text-[1.3em] font-medium">made well</p>
        <span className="mx-auto mt-[1.4em] block h-px w-[4em] bg-white/50" />
      </div>
    </div>
  );
}

const posts: SocialPost[] = [
  {
    id: "maya",
    platform: "x",
    name: "Maya",
    handle: "maya",
    avatar: "https://ui.dimaac.com/media/portraits/sunglasses-1x1.jpg",
    time: "1h",
    verified: true,
    text: "Spent the week on a deck of cards you can swipe. A drag, a little tilt, a soft stack. It feels better than it has any right to.",
    image: "https://ui.dimaac.com/media/art/floating-spheres-4x5.jpg",
    imageAlt: "A grey moon-like sphere with small orange spheres floating above it",
    likes: 2410,
    comments: 86,
    shares: 312,
  },
  {
    id: "dunes",
    platform: "instagram",
    name: "DiMaac",
    verified: true,
    avatar: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg",
    time: "3h",
    text: "Golden hour on the dunes. No filter, just good timing.",
    image: "https://ui.dimaac.com/media/nature/desert-wanderer-4x5.jpg",
    imageAlt: "A figure in a long coat walking across sand dunes at sunset",
    likes: 8412,
    comments: 143,
  },
  {
    id: "rooftop",
    platform: "facebook",
    name: "DiMaac",
    verified: true,
    avatar: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg",
    time: "5h",
    text: "Pink wall, lilac stairs, a little bit of sky. A palette we keep coming back to.",
    image: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg",
    imageAlt: "A small pink and lilac building against a pale blue sky",
    likes: 1286,
    comments: 94,
    shares: 21,
  },
  {
    id: "note",
    platform: "x",
    name: "DiMaac",
    handle: "dimaac",
    verified: true,
    avatar: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg",
    time: "6h",
    text: "Good interfaces feel obvious in hindsight. The work is making them feel that way the first time.",
    likes: 5320,
    comments: 241,
    shares: 904,
  },
  {
    id: "amber",
    platform: "instagram",
    name: "DiMaac",
    verified: true,
    avatar: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg",
    time: "9h",
    text: "Amber glass, green leaf shadows and one soft light.",
    image: "https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg",
    imageAlt: "A glass perfume bottle with amber liquid on a green backdrop",
    likes: 12908,
    comments: 322,
  },
  {
    id: "quote",
    platform: "facebook",
    name: "DiMaac",
    verified: true,
    avatar: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg",
    time: "12h",
    media: <QuoteCard />,
    text: "Small things, made well.",
    likes: 3170,
    comments: 58,
    shares: 140,
  },
  {
    id: "teal",
    platform: "x",
    name: "DiMaac",
    handle: "dimaac",
    verified: true,
    avatar: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg",
    time: "1d",
    text: "Teal glass, five quiet minutes.",
    image: "https://ui.dimaac.com/media/dark/teal-glass-4x5.jpg",
    imageAlt: "A person sitting on a bench in front of teal glass",
    likes: 980,
    comments: 17,
    shares: 33,
  },
];

export default function Demo(props: Partial<PostSwiperProps>) {
  // The deck is as tall as its width allows, so on a short stage it shrinks to leave room for the text
  const stageRef = useRef<HTMLDivElement>(null);
  const [stageHeight, setStageHeight] = useState(0);
  const [wide, setWide] = useState(false);
  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    const measure = () => {
      setStageHeight(stage.clientHeight);
      setWide(stage.clientWidth >= 1024);
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(stage);
    return () => observer.disconnect();
  }, []);

  const ratio = props.cardRatio ?? 0.74;
  const wanted = props.cardWidth ?? 380;
  // Room kept for the stage padding, the text on a phone and the arrows under the deck
  const reserved = wide ? 190 : 330;
  const fits = stageHeight > 0 ? Math.max(220, (stageHeight - reserved) * ratio) : wanted;

  return (
    <div
      ref={stageRef}
      className="relative flex h-full w-full flex-col items-center justify-center gap-6 overflow-hidden bg-[#f1ede6] px-5 py-8 text-[#1c1917] lg:flex-row lg:justify-center lg:gap-[8vw] lg:px-16 dark:bg-[#0d0e0f] dark:text-[#ece7df]"
    >
      <div className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60%_60%_at_72%_50%,rgba(176,148,112,0.28),transparent_70%)] dark:[background:radial-gradient(60%_60%_at_72%_50%,rgba(94,120,150,0.22),transparent_70%)]" />

      <header className="relative z-10 max-w-[30rem] text-center lg:text-left">
        <h1 className="text-[clamp(30px,5.4vw,76px)] leading-[0.98] font-medium tracking-[-0.04em] ">
          A feed,
          <br />
          one deck.
        </h1>
        <p className="mx-auto mt-3 max-w-[34ch] text-[14px] leading-[1.5] opacity-65 lg:mx-0 lg:mt-5 lg:text-[16px]">
          Drag a card away, press the arrow keys, or tap next. Like and save work on every post.
        </p>
      </header>

      <div className="relative z-10 flex w-full max-w-[460px] shrink-0 justify-center lg:w-auto">
        <PostSwiper posts={posts} {...props} cardWidth={Math.min(wanted, fits)} className={props.className ?? ""} />
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Post Swiper component (<PostSwiper />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/post-swiper
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/post-swiper.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/post-swiper.json

Then use it like this:
import { PostSwiper } from "@/components/dimaac/post-swiper";
<PostSwiper posts={posts} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/post-swiper.tsx and install swiper, lucide-react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Project hover
description: Thumbnails float with the cursor.
category: Cursor
tier: free
url: https://ui.dimaac.com/components/project-hover-section
registry: https://ui.dimaac.com/r/project-hover-section.json
---

# Project hover

A list of projects for a portfolio. Hover a row and a photo grows at the cursor, trails it with a soft delay, leans into the direction you move and slides to that row's picture while the row's title and subtitle drift apart. Tab to a row and the photo appears beside it, so it works from the keyboard. It listens only inside its own list, takes its colours from the text colour around it, and on touch screens turns into a tap-to-open accordion that shows each photo inline. With reduced motion the photo just fades in, still, at the cursor. Ported from the first DiMaac UI and rebuilt with scoped GSAP calls, real links or buttons for the rows, and a CSS-only choice between mouse and touch.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/project-hover-section.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/project-hover-section.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/project-hover-section.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/project-hover-section`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/project-hover-section.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ProjectHoverSection } from "@/components/dimaac/project-hover-section" and give its parent a size: it fills it.

## Usage

```tsx
import { ProjectHoverSection } from "@/components/dimaac/project-hover-section";

<ProjectHoverSection projects={projects} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `projects` (required) | `ProjectHoverItem[]` | - | The rows, top to bottom. Three to eight look best. |
| `thumbnailWidth` | `number` | `380` | Width of the floating photo, in pixels. |
| `thumbnailHeight` | `number` | `250` | Height of the floating photo, in pixels. |
| `thumbnailRadius` | `number` | `10` | Corner radius of the floating photo, in pixels. |
| `followDuration` | `number` | `0.4` | How long the photo takes to catch up with the cursor, in seconds. |
| `transition` | `"slide" \| "fade"` | `"slide"` | How the photo changes between rows: "slide" scrolls the strip, "fade" cross-fades. |
| `shift` | `number` | `15` | How far the title and subtitle of the hovered row move apart, in pixels. |
| `dimOpacity` | `number` | `0.5` | The opacity of the hovered row, from 0 to 1. 1 leaves it alone. |
| `tilt` | `number` | `5` | The most the photo leans into the direction of the cursor, in degrees. 0 keeps it straight. |
| `onSelect` | `(project: ProjectHoverItem, index: number) => void` | - | Called when a row is clicked or pressed. |
| `className` | `string` | `""` | Extra classes for the outer box. It is as wide as its parent and as tall as the list. |

## Credits

- Concrete corner: Ricardo Matos, Lummi (https://www.lummi.ai/photo/minimalist-urban-design-abiri), Lummi License (free for personal and commercial use)
- Red wall interior: Siren Media, Lummi (https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl), Lummi License (free for personal and commercial use)
- Palm pool: Binks AI, Lummi (https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c), Lummi License (free for personal and commercial use)
- White corner: Mikiwa, Lummi (https://www.lummi.ai/photo/modern-building-corner-dr5dz), Lummi License (free for personal and commercial use)
- Concrete balconies: Mariate, Lummi (https://www.lummi.ai/photo/minimalist-architectural-design-b8bbc), Lummi License (free for personal and commercial use)

## Source

### project-hover-section.tsx → `components/dimaac/project-hover-section.tsx`

```tsx
"use client";

import { useId, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A list of projects. Hover a row and a photo grows at the cursor, follows it around and slides to
// that row's picture. Tab to a row and the photo shows up beside it, so keyboard users get it too.
// On touch screens there is no cursor, so each row opens like an accordion and shows its photo inline.
// The look comes from `currentColor`: the list takes the text colour of whatever it sits in.

export type ProjectHoverItem = {
  /** The big name on the left of the row. */
  title: string;
  /** The small line on the right (a year, a discipline). */
  subtitle: string;
  /** The photo shown for this row. */
  image: string;
  /** Describes the photo for screen readers. Defaults to the title. */
  alt?: string;
  /** Makes the row a link. Without it the row is a button. */
  href?: string;
};

export type ProjectHoverSectionProps = {
  /** The rows, top to bottom. Three to eight look best. */
  projects: ProjectHoverItem[];
  /** Width of the floating photo, in pixels. */
  thumbnailWidth?: number;
  /** Height of the floating photo, in pixels. */
  thumbnailHeight?: number;
  /** Corner radius of the floating photo, in pixels. */
  thumbnailRadius?: number;
  /** How long the photo takes to catch up with the cursor, in seconds. */
  followDuration?: number;
  /** How the photo changes between rows: "slide" scrolls the strip, "fade" cross-fades. */
  transition?: "slide" | "fade";
  /** How far the title and subtitle of the hovered row move apart, in pixels. */
  shift?: number;
  /** The opacity of the hovered row, from 0 to 1. 1 leaves it alone. */
  dimOpacity?: number;
  /** The most the photo leans into the direction of the cursor, in degrees. 0 keeps it straight. */
  tilt?: number;
  /** Called when a row is clicked or pressed. */
  onSelect?: (project: ProjectHoverItem, index: number) => void;
  /** Extra classes for the outer box. It is as wide as its parent and as tall as the list. */
  className?: string;
};

// A mouse (or a pen) that can hover. Everything else gets the tap-to-open accordion.
const FINE_POINTER = "(hover: hover) and (pointer: fine)";

function subscribeToPointer(onChange: () => void) {
  const query = window.matchMedia(FINE_POINTER);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

// The server and the first client render say "mouse", then this settles on the real answer.
// The layout itself is decided in CSS, so nothing jumps; this only drives the aria attributes.
function useTouchLayout() {
  return useSyncExternalStore(
    subscribeToPointer,
    () => !window.matchMedia(FINE_POINTER).matches,
    () => false,
  );
}

export function ProjectHoverSection({
  projects,
  thumbnailWidth = 380,
  thumbnailHeight = 250,
  thumbnailRadius = 10,
  followDuration = 0.4,
  transition = "slide",
  shift = 15,
  dimOpacity = 0.5,
  tilt = 5,
  onSelect,
  className = "",
}: ProjectHoverSectionProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const stripRef = useRef<HTMLDivElement>(null);
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  const touchLayout = useTouchLayout();
  const panelId = useId();

  useGSAP(
    () => {
      const box = boxRef.current;
      const list = listRef.current;
      const thumb = thumbRef.current;
      const strip = stripRef.current;
      if (!box || !list || !thumb || !strip) return;

      const rows = gsap.utils.toArray<HTMLElement>("[data-row]", list);
      const slides = gsap.utils.toArray<HTMLElement>("[data-slide]", strip);
      const last = rows.length - 1;
      if (last < 0) return;

      // matchMedia re-runs this block if the visitor plugs in a mouse or flips the motion setting
      const mm = gsap.matchMedia();
      mm.add({ fine: FINE_POINTER, calm: "(prefers-reduced-motion: reduce)" }, (context, contextSafe) => {
        const { fine, calm } = context.conditions as { fine: boolean; calm: boolean };
        if (!fine || !contextSafe) return;
        // contextSafe returns a plain Function, so give the handler its own type back
        const safe = <Handler extends (...args: never[]) => void>(handler: Handler) => contextSafe(handler) as Handler;

        // The photo starts hidden, centred on its own middle so x and y are the cursor position.
        // With reduced motion it never scales or travels: it just appears, and fades.
        gsap.set(thumb, { xPercent: -50, yPercent: -50, scale: calm ? 1 : 0, autoAlpha: 0, force3D: true });
        // Slide: pictures sit one below the other and the strip moves. Fade: they sit on top of each other.
        gsap.set(slides, { yPercent: (i: number) => (transition === "slide" ? i * 100 : 0), autoAlpha: (i: number) => (transition === "slide" || i === 0 ? 1 : 0) });
        gsap.set(strip, { yPercent: 0 });

        const moveX = calm ? null : gsap.quickTo(thumb, "x", { duration: followDuration, ease: "power3.out" });
        const moveY = calm ? null : gsap.quickTo(thumb, "y", { duration: followDuration, ease: "power3.out" });
        const lean = calm || tilt === 0 ? null : gsap.quickTo(thumb, "rotation", { duration: 0.6, ease: "power3.out" });
        // When the cursor stops, the photo straightens up
        const straighten = lean ? gsap.delayedCall(0.08, () => lean(0)).pause() : null;

        let shown = false;
        let pointerInside = false;
        let activeIndex = -1;
        let lastClientX = 0;

        const markActive = (index: number) => {
          rows.forEach((row, i) => {
            if (i === index) {
              row.dataset.active = "true";
              if (!calm) row.dataset.shift = "true";
            } else {
              delete row.dataset.active;
              delete row.dataset.shift;
            }
          });
        };

        const showPicture = (index: number, instant: boolean) => {
          if (transition === "slide") {
            gsap.to(strip, { yPercent: -100 * index, duration: instant || calm ? 0 : 0.4, ease: "power2.out", overwrite: "auto" });
          } else {
            gsap.to(slides, { autoAlpha: (i: number) => (i === index ? 1 : 0), duration: instant || calm ? 0 : 0.4, ease: "power2.out", overwrite: "auto" });
          }
        };

        // x and y are measured from the top left corner of the box the photo lives in
        const reveal = (index: number, x: number, y: number) => {
          const wasShown = shown;
          if (!wasShown) {
            gsap.set(thumb, { x, y });
            // One call in the quickTo functions' own tween, so they start from here too
            moveX?.(x);
            moveY?.(y);
          }
          if (index !== activeIndex) showPicture(index, !wasShown);
          activeIndex = index;
          markActive(index);
          shown = true;
          gsap.to(thumb, { scale: 1, autoAlpha: 1, duration: calm ? 0.15 : 0.4, ease: "power2.out", overwrite: "auto" });
        };

        const hide = () => {
          if (!shown) return;
          shown = false;
          activeIndex = -1;
          markActive(-1);
          lean?.(0);
          gsap.to(thumb, { scale: calm ? 1 : 0, autoAlpha: 0, duration: calm ? 0.1 : 0.3, ease: "power2.out", overwrite: "auto" });
        };

        const toBox = (clientX: number, clientY: number) => {
          const rect = box.getBoundingClientRect();
          return { x: clientX - rect.left, y: clientY - rect.top };
        };

        const isMouse = (event: PointerEvent) => event.pointerType === "mouse" || event.pointerType === "pen";

        const onListEnter = safe((event: PointerEvent) => {
          if (isMouse(event)) pointerInside = true;
        });
        const onListMove = safe((event: PointerEvent) => {
          if (!isMouse(event) || !shown) return;
          const { x, y } = toBox(event.clientX, event.clientY);
          if (moveX && moveY) {
            moveX(x);
            moveY(y);
          } else {
            gsap.set(thumb, { x, y });
          }
          if (lean && straighten) {
            lean(gsap.utils.clamp(-tilt, tilt, (event.clientX - lastClientX) * 0.5));
            straighten.restart(true);
          }
          lastClientX = event.clientX;
        });
        const onListLeave = safe((event: PointerEvent) => {
          if (!isMouse(event)) return;
          pointerInside = false;
          hide();
        });

        const cleanups: (() => void)[] = [];
        list.addEventListener("pointerenter", onListEnter);
        list.addEventListener("pointermove", onListMove);
        list.addEventListener("pointerleave", onListLeave);
        cleanups.push(() => {
          list.removeEventListener("pointerenter", onListEnter);
          list.removeEventListener("pointermove", onListMove);
          list.removeEventListener("pointerleave", onListLeave);
        });

        rows.forEach((row, index) => {
          const onRowEnter = safe((event: PointerEvent) => {
            if (!isMouse(event)) return;
            lastClientX = event.clientX;
            const { x, y } = toBox(event.clientX, event.clientY);
            reveal(index, x, y);
          });
          // Keyboard focus: the photo appears beside the row. A mouse click also focuses a row,
          // but then :focus-visible is false and the cursor is already driving.
          const onRowFocus = safe(() => {
            if (pointerInside || !row.matches(":focus-visible")) return;
            const rect = row.getBoundingClientRect();
            const { x, y } = toBox(rect.left + rect.width * 0.62, rect.top + rect.height / 2);
            reveal(index, x, y);
            moveX?.(x);
            moveY?.(y);
          });
          const onRowBlur = safe((event: FocusEvent) => {
            if (pointerInside || rows.includes(event.relatedTarget as HTMLElement)) return;
            hide();
          });
          row.addEventListener("pointerenter", onRowEnter);
          row.addEventListener("focus", onRowFocus);
          row.addEventListener("blur", onRowBlur);
          cleanups.push(() => {
            row.removeEventListener("pointerenter", onRowEnter);
            row.removeEventListener("focus", onRowFocus);
            row.removeEventListener("blur", onRowBlur);
          });
        });

        return () => {
          cleanups.forEach((cleanup) => cleanup());
          straighten?.kill();
          markActive(-1);
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [projects.length, transition, followDuration, tilt], revertOnUpdate: true },
  );

  const handleClick = (event: MouseEvent<HTMLElement>, project: ProjectHoverItem, index: number) => {
    onSelect?.(project, index);
    if (!touchLayout) return;
    // On touch the row opens its picture. A linked row gets a "View project" link inside the panel.
    if (project.href) event.preventDefault();
    setOpenIndex((current) => (current === index ? null : index));
  };

  return (
    <div
      ref={boxRef}
      className={`relative w-full ${className}`}
      style={{ "--phs-shift": `${shift}px`, "--phs-dim": dimOpacity } as React.CSSProperties}
    >
      <ul ref={listRef} className="m-0 mx-auto flex w-full max-w-[1000px] list-none flex-col border-b border-current/20 p-0">
        {projects.map((project, index) => {
          const open = touchLayout && openIndex === index;
          const panelName = `${panelId}-${index}`;
          const rowProps = {
            "data-row": "",
            className:
              "group relative flex w-full cursor-pointer flex-col items-start gap-1 border-t border-current/20 bg-transparent px-4 py-5 text-left text-inherit no-underline outline-none transition-opacity duration-300 focus-visible:bg-current/[0.04] data-[active=true]:opacity-[var(--phs-dim)] sm:flex-row sm:items-center sm:justify-between sm:gap-6 md:px-14 md:py-9 [@media(hover:hover)_and_(pointer:fine)]:focus-visible:outline-none",
            onClick: (event: MouseEvent<HTMLElement>) => handleClick(event, project, index),
            "aria-expanded": touchLayout ? open : undefined,
            "aria-controls": touchLayout ? panelName : undefined,
          };
          const content = (
            <>
              <span className="block text-[clamp(1.6rem,4.4vw,3.5rem)] leading-[1.05] font-medium tracking-[-0.035em] transition-transform duration-500 ease-out group-data-[shift=true]:-translate-x-[var(--phs-shift)]">
                {project.title}
              </span>
              <span className="flex items-center gap-4 text-sm opacity-70 transition-transform duration-500 ease-out group-data-[shift=true]:translate-x-[var(--phs-shift)] md:text-base [@media(hover:none)]:sm:mr-8">
                {project.subtitle}
              </span>
              {/* Touch only: a plus that turns into a minus while the row is open */}
              <span aria-hidden="true" className="absolute top-1/2 right-4 block size-3 -translate-y-1/2 opacity-70 md:right-14 [@media(hover:hover)_and_(pointer:fine)]:hidden">
                <span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-current" />
                <span className={`absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-current transition-transform duration-300 ${open ? "scale-y-0" : ""}`} />
              </span>
            </>
          );

          return (
            <li key={`${index}-${project.title}`} className="m-0 p-0">
              {project.href ? (
                <a href={project.href} role={touchLayout ? "button" : undefined} {...rowProps}>
                  {content}
                </a>
              ) : (
                <button type="button" {...rowProps}>
                  {content}
                </button>
              )}

              {/* Touch only: the picture opens under the row */}
              <div
                id={panelName}
                inert={!open}
                className={`grid transition-[grid-template-rows] duration-500 ease-out motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:hidden ${open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`}
              >
                <div className="min-h-0 overflow-hidden">
                  <div className="px-4 pb-5">
                    <img
                      src={project.image}
                      alt={project.alt ?? project.title}
                      loading="lazy"
                      decoding="async"
                      draggable={false}
                      className={`block w-full object-cover transition-opacity duration-500 ${open ? "opacity-100" : "opacity-0"}`}
                      style={{ aspectRatio: `${thumbnailWidth} / ${thumbnailHeight}`, borderRadius: thumbnailRadius }}
                    />
                    {project.href ? (
                      <a href={project.href} className="mt-3 inline-block text-sm underline underline-offset-4">
                        View project
                      </a>
                    ) : null}
                  </div>
                </div>
              </div>
            </li>
          );
        })}
      </ul>

      {/* The floating photo. Decorative: every row already says what the picture is. */}
      <div
        ref={thumbRef}
        aria-hidden="true"
        className="pointer-events-none absolute top-0 left-0 z-50 hidden overflow-hidden border border-current/20 shadow-[0_30px_60px_-24px_rgba(0,0,0,0.45)] [@media(hover:hover)_and_(pointer:fine)]:block"
        style={{ width: thumbnailWidth, height: thumbnailHeight, borderRadius: thumbnailRadius, opacity: 0, visibility: "hidden", willChange: "transform" }}
      >
        <div ref={stripRef} className="absolute inset-0">
          {projects.map((project, index) => (
            <div key={`${index}-${project.image}`} data-slide="" className="absolute inset-0">
              <img src={project.image} alt="" decoding="async" draggable={false} className="block h-full w-full object-cover" />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { ProjectHoverSection, type ProjectHoverItem, type ProjectHoverSectionProps } from "@/components/dimaac/project-hover-section";

// Five Lummi photographs, named by what they show (Lummi License: free for personal and commercial use)
const projects: ProjectHoverItem[] = [
  { title: "Concrete Corner", subtitle: "No. 01", image: "https://ui.dimaac.com/media/architecture/concrete-corner-3x2.jpg", alt: "A rough concrete corner with a black-framed window" },
  { title: "Red Wall", subtitle: "No. 02", image: "https://ui.dimaac.com/media/architecture/red-wall-interior-3x2.jpg", alt: "A modern interior with a bold red textured wall" },
  { title: "Palm Court", subtitle: "No. 03", image: "https://ui.dimaac.com/media/architecture/palm-pool-3x2.jpg", alt: "Palm trees on a platform of water" },
  { title: "White Corner", subtitle: "No. 04", image: "https://ui.dimaac.com/media/architecture/white-corner-16x9.jpg", alt: "A white building corner with teal-tinted glass" },
  { title: "Balconies", subtitle: "No. 05", image: "https://ui.dimaac.com/media/architecture/concrete-balconies-3x2.jpg", alt: "Concrete balconies against a pale sky" },
];

export default function Demo(props: Partial<ProjectHoverSectionProps>) {
  return (
    <div className="relative flex h-full w-full flex-col overflow-hidden bg-[#efece6] text-[#1c1917] dark:bg-[#0e0e0d] dark:text-[#ece8e1]">
      <div className="flex min-h-0 flex-1 flex-col justify-center pt-6 pb-2 [@media(hover:none)]:overflow-y-auto">
        <ProjectHoverSection projects={projects} {...props} className={props.className} />
      </div>

      <p className="pb-6 text-center font-mono text-[11px] tracking-wide uppercase opacity-60">
        <span className="[@media(hover:none)]:hidden">Hover a title</span>
        <span className="hidden [@media(hover:none)]:inline">Tap a title</span>
      </p>
    </div>
  );
}
```

## Agent prompt

```text
Add the Project hover component (<ProjectHoverSection />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/project-hover-section
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/project-hover-section.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/project-hover-section.json

Then use it like this:
import { ProjectHoverSection } from "@/components/dimaac/project-hover-section";
<ProjectHoverSection projects={projects} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/project-hover-section.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Proof Of Work
description: A big title over a grid of work that flies in, or tumbles into view, as you scroll.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/proof-of-work
registry: https://ui.dimaac.com/r/proof-of-work.json
---

# Proof Of Work

A showcase grid under a big heading. Each piece arrives as you scroll to it, in one of two ways: the picture flies up from below, tipped over and fading in, and settles upright (rise), or it sits under a solid tile that tumbles off down the page and away (fall). Pieces further along a row arrive a beat later, so a row lands left to right, and each caption fades in just behind its picture. The grid is plain CSS with any number of columns and any picture shape, one column on phones. It uses one ScrollTrigger timeline per piece, plays once or replays when you scroll back up, works on the page or inside any scrolling element through the scroller prop, measures again when fonts or pictures change the page, and leaves every picture in place for visitors who prefer reduced motion. Ported from the first DiMaac UI and merged with two YouTube builds, rebuilt without any site context.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/proof-of-work.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/proof-of-work.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/proof-of-work.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/proof-of-work`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/proof-of-work.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ProofOfWork } from "@/components/dimaac/proof-of-work" and give its parent a size: it fills it.

## Usage

```tsx
import { ProofOfWork } from "@/components/dimaac/proof-of-work";

<ProofOfWork works={works} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `works` (required) | `ProofOfWorkItem[]` | - | The pieces, in reading order. |
| `title` | `string` | `"Proof of Work"` | The big heading above the grid. |
| `tagline` | `string` | - | A small line under the heading. Leave it out to hide it. |
| `titleAs` | `"h1" \| "h2" \| "h3"` | - | Which tag the heading uses. Pick h1 when this is the main title of the page. |
| `effect` | `"rise" \| "fall"` | `"rise"` | How a piece arrives: "rise" flies the picture up from below, "fall" drops a cover tile away from it. |
| `columns` | `number` | `2` | How many columns the grid has from tablet width up. It is always one column on a phone. |
| `aspectRatio` | `string` | `"4 / 5"` | The shape of every picture as a CSS aspect-ratio, e.g. "4 / 5", "1 / 1" or "16 / 10". |
| `rotation` | `number` | `50` | For "rise": how far each picture is tipped over before it settles, in degrees. The direction alternates. |
| `distance` | `number` | `500` | For "rise": how far below its place each picture starts, in pixels (a little less on phones). |
| `duration` | `number` | `1` | How long one arrival takes, in seconds. |
| `columnDelay` | `number` | `0.25` | Extra wait for each column after the first, in seconds, so a row arrives left to right. |
| `ease` | `string` | `"power2.inOut"` | The GSAP ease of the arrival. |
| `coverColor` | `string` | `"currentColor"` | For "fall": the colour of the cover tile. "currentColor" follows the text colour, so it works in light and dark. |
| `showNumbers` | `boolean` | `true` | Show a small running number next to each title. |
| `replay` | `boolean` | `false` | Play the arrival again, backwards, when you scroll back up past a piece. Off means it plays once. |
| `start` | `string` | - | When a piece arrives, as a ScrollTrigger start such as "top 85%". Leave it out for a good default per effect. |
| `scroller` | `string \| HTMLElement` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes for the outer box. Set a background and a text colour here. |
| `titleClassName` | `string` | `""` | Extra classes for the heading, e.g. a font family. |
| `imageClassName` | `string` | `""` | Extra classes for each picture box, e.g. a radius. |

## Credits

- Abstract Eye Close-Up: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Avant-Garde Fashion Pose: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License
- Modern Architectural Design: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Elegant Perfume Bottle: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Surreal 3D Planet Scene: Ricardo Matos, Lummi (https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw), Lummi License
- Desert Wanderer: Beth Design Studio, Lummi (https://www.lummi.ai/photo/desert-wanderer-9puhu), Lummi License
- Vivid Portrait on Pink: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Contemplative Silhouette against Modern Minimalist Architecture: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License
- Portrait Through Orange Frame: Bernardo Lickfold, Lummi (https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i), Lummi License
- Elegant Perfume Bottles: Amino, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottles-vnjg1), Lummi License
- Intricate Natural Texture: Nika, Lummi (https://www.lummi.ai/photo/intricate-natural-texture-buk3_), Lummi License
- Vibrant Cylindrical Arrangement: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License

## Source

### proof-of-work.tsx → `components/dimaac/proof-of-work.tsx`

```tsx
"use client";

import { useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// A title with a grid of work under it. Each piece arrives as you scroll to it, and there are two
// ways it can arrive:
//   "rise"  the picture flies up from below, tipped over 50 degrees, and settles upright
//   "fall"  the picture sits under a solid tile, and the tile tumbles away down the page
// The grid is plain CSS, so it works with any number of columns. Only the arrival is animated, so
// with no JavaScript, or with reduced motion, every picture is simply there.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);

export type ProofOfWorkItem = {
  /** Address of the picture. */
  image: string;
  /** Alt text for the picture. Falls back to the title. */
  imageAlt?: string;
  /** The name of the piece, shown in capitals under the picture. */
  title: string;
  /** A short line under the title, such as the kind of work. */
  subtitle?: string;
};

export type ProofOfWorkProps = {
  /** The pieces, in reading order. */
  works: ProofOfWorkItem[];
  /** The big heading above the grid. */
  title?: string;
  /** A small line under the heading. Leave it out to hide it. */
  tagline?: string;
  /** Which tag the heading uses. Pick h1 when this is the main title of the page. */
  titleAs?: "h1" | "h2" | "h3";
  /** How a piece arrives: "rise" flies the picture up from below, "fall" drops a cover tile away from it. */
  effect?: "rise" | "fall";
  /** How many columns the grid has from tablet width up. It is always one column on a phone. */
  columns?: number;
  /** The shape of every picture as a CSS aspect-ratio, e.g. "4 / 5", "1 / 1" or "16 / 10". */
  aspectRatio?: string;
  /** For "rise": how far each picture is tipped over before it settles, in degrees. The direction alternates. */
  rotation?: number;
  /** For "rise": how far below its place each picture starts, in pixels (a little less on phones). */
  distance?: number;
  /** How long one arrival takes, in seconds. */
  duration?: number;
  /** Extra wait for each column after the first, in seconds, so a row arrives left to right. */
  columnDelay?: number;
  /** The GSAP ease of the arrival. */
  ease?: string;
  /** For "fall": the colour of the cover tile. "currentColor" follows the text colour, so it works in light and dark. */
  coverColor?: string;
  /** Show a small running number next to each title. */
  showNumbers?: boolean;
  /** Play the arrival again, backwards, when you scroll back up past a piece. Off means it plays once. */
  replay?: boolean;
  /** When a piece arrives, as a ScrollTrigger start such as "top 85%". Leave it out for a good default per effect. */
  start?: string;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes for the outer box. Set a background and a text colour here. */
  className?: string;
  /** Extra classes for the heading, e.g. a font family. */
  titleClassName?: string;
  /** Extra classes for each picture box, e.g. a radius. */
  imageClassName?: string;
};

// The grid switches from one column to many at the same width as Tailwind's md: breakpoint
const WIDE_SCREEN = "(min-width: 768px)";

export function ProofOfWork({
  works,
  title = "Proof of Work",
  tagline,
  titleAs: Heading = "h2",
  effect = "rise",
  columns = 2,
  aspectRatio = "4 / 5",
  rotation = 50,
  distance = 500,
  duration = 1,
  columnDelay = 0.25,
  ease = "power2.inOut",
  coverColor = "currentColor",
  showNumbers = true,
  replay = false,
  start,
  scroller,
  className = "",
  titleClassName = "",
  imageClassName = "",
}: ProofOfWorkProps) {
  const rootRef = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;

      const mm = gsap.matchMedia();

      // Reduced motion adds nothing here: every picture stays where the page put it
      mm.add({ motion: "(prefers-reduced-motion: no-preference)", wide: WIDE_SCREEN }, (context) => {
        const { motion, wide } = context.conditions ?? {};
        if (!motion) return;

        const items = gsap.utils.toArray<HTMLElement>("[data-pow-item]", root);
        const columnCount = wide ? Math.max(1, Math.round(columns)) : 1;
        const trigger = start ?? (effect === "fall" ? "top 80%" : "top 88%");
        const scrollerBox = typeof scroller === "string" ? document.querySelector<HTMLElement>(scroller) : scroller;
        const viewHeight = () => scrollerBox?.clientHeight ?? window.innerHeight;
        // Phones are short, so the pictures start a little closer
        const rise = wide ? distance : distance * 0.7;

        items.forEach((item, index) => {
          const media = item.querySelector<HTMLElement>("[data-pow-media]");
          const cover = item.querySelector<HTMLElement>("[data-pow-cover]");
          const caption = item.querySelector<HTMLElement>("[data-pow-caption]");
          if (!media) return;

          // Each piece has its own timeline, started by its own ScrollTrigger. It plays once by default.
          const arrival = gsap.timeline({
            scrollTrigger: {
              trigger: item,
              scroller,
              start: trigger,
              once: !replay,
              toggleActions: "play none none reverse",
            },
          });
          // Pieces further along the row arrive a beat later
          const wait = (index % columnCount) * columnDelay;

          if (effect === "rise") {
            arrival.fromTo(
              media,
              { rotation: index % 2 === 0 ? -rotation : rotation, y: rise, opacity: 0, transformOrigin: "50% 50%" },
              { rotation: 0, y: 0, opacity: 1, duration, ease },
              wait,
            );
          } else if (cover) {
            // The tile is hidden by default so the page works without JavaScript. Show it now.
            gsap.set(cover, { autoAlpha: 1 });
            arrival.to(
              cover,
              {
                x: () => gsap.utils.random(-100, 100),
                // Far enough to leave the screen, but never past the bottom of this component
                y: () => {
                  const room = root.getBoundingClientRect().bottom - item.getBoundingClientRect().top;
                  return Math.min(viewHeight() * 2 + gsap.utils.random(0, 400), room + 80);
                },
                rotation: () => gsap.utils.random(-180, 180),
                // Near the bottom of the component the tile fades out, so it never gets cut off in view
                opacity: () => (root.getBoundingClientRect().bottom - item.getBoundingClientRect().top < viewHeight() * 2 ? 0 : 1),
                duration: duration * 1.35,
                ease: "power3.in",
              },
              wait,
            );
            arrival.set(cover, { autoAlpha: 0 });
          }

          if (caption) {
            arrival.fromTo(caption, { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, wait + duration * 0.45);
          }
        });

        // Fonts and pictures change the page height after the first measure, so measure again.
        // A short delay turns a burst of changes into one measure.
        let measure: gsap.core.Tween | undefined;
        const observer = new ResizeObserver(() => {
          measure?.kill();
          measure = gsap.delayedCall(0.2, () => ScrollTrigger.refresh());
        });
        observer.observe(root);

        return () => {
          measure?.kill();
          observer.disconnect();
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [works.length, effect, columns, rotation, distance, duration, columnDelay, ease, replay, start, scroller], revertOnUpdate: true },
  );

  return (
    <div
      ref={rootRef}
      className={`relative w-full overflow-clip px-[clamp(1rem,4vw,4rem)] pt-[clamp(3rem,8vw,6rem)] pb-[clamp(4rem,12vh,10rem)] ${className}`}
      style={{ "--pow-columns": Math.max(1, Math.round(columns)) } as CSSProperties}
    >
      <div className="mx-auto max-w-[1400px]">
        <header className="mb-[clamp(2.5rem,7vw,6rem)] text-center">
          <Heading className={`m-0 text-[clamp(2.75rem,13vw,11rem)] leading-[0.92] font-normal tracking-[-0.03em] ${titleClassName}`}>{title}</Heading>
          {tagline ? <p className="mt-5 text-[11px] tracking-[0.18em] uppercase opacity-60">{tagline}</p> : null}
        </header>

        <ul className="m-0 grid list-none grid-cols-1 gap-x-[clamp(1rem,3vw,3rem)] gap-y-[clamp(2.5rem,6vw,5rem)] p-0 md:grid-cols-[repeat(var(--pow-columns),minmax(0,1fr))]">
          {works.map((work, index) => (
            <li key={`${index}-${work.image}`} data-pow-item className="group relative isolate min-w-0" style={{ zIndex: works.length - index }}>
              <figure className="m-0">
                <div className="relative">
                  <div
                    data-pow-media
                    className={`relative overflow-hidden ${imageClassName}`}
                    style={{ aspectRatio, backgroundColor: "color-mix(in srgb, currentColor 10%, transparent)", willChange: "transform, opacity" }}
                  >
                    <img
                      src={work.image}
                      alt={work.imageAlt ?? work.title}
                      loading="lazy"
                      decoding="async"
                      draggable={false}
                      className="block h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.04] motion-reduce:transition-none"
                    />
                  </div>
                  {/* The cover sits outside the clipped picture box, so it can tumble out of it */}
                  {effect === "fall" ? <div data-pow-cover aria-hidden="true" className={`invisible absolute inset-0 z-[2] ${imageClassName}`} style={{ backgroundColor: coverColor, willChange: "transform" }} /> : null}
                </div>
                <figcaption data-pow-caption className="mt-3 flex items-baseline justify-between gap-4">
                  <span className="min-w-0">
                    <span className="block text-[13px] font-semibold tracking-[0.04em] uppercase">{work.title}</span>
                    {work.subtitle ? <span className="mt-1 block text-[13px] opacity-65">{work.subtitle}</span> : null}
                  </span>
                  {showNumbers ? (
                    <span aria-hidden="true" className="text-[11px] tabular-nums opacity-45">
                      {String(index + 1).padStart(2, "0")}
                    </span>
                  ) : null}
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { ProofOfWork, type ProofOfWorkItem, type ProofOfWorkProps } from "@/components/dimaac/proof-of-work";

// Twelve photos in a grid. The page is paper in the light theme and ink in the dark one,
// and the heading, the captions and the "fall" tile all follow the text colour.

const works: ProofOfWorkItem[] = [
  { image: "https://ui.dimaac.com/media/portraits/light-streaks-4x5.jpg", imageAlt: "Close-up of a face with blue eyes and colorful light streaks", title: "Light Streaks", subtitle: "Portrait series" },
  { image: "https://ui.dimaac.com/media/fashion/chunky-knit-4x5.jpg", imageAlt: "A model in a chunky knit sweater, ruffled pants and coral lace-up boots against a warm backdrop", title: "Chunky Knit", subtitle: "Editorial" },
  { image: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg", imageAlt: "Minimalist architecture with yellow and beige walls under a clear blue sky", title: "Yellow Planes", subtitle: "Architecture" },
  { image: "https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg", imageAlt: "A glass perfume bottle with amber liquid against a green background with leaf shadows", title: "Amber No. 4", subtitle: "Product" },
  { image: "https://ui.dimaac.com/media/art/floating-spheres-4x5.jpg", imageAlt: "A textured grey sphere with small colourful spheres floating around it like planets", title: "Floating Spheres", subtitle: "3D study" },
  { image: "https://ui.dimaac.com/media/nature/desert-wanderer-4x5.jpg", imageAlt: "A person in a flowing garment walks across a desert under a golden sky", title: "Desert Wanderer", subtitle: "Travel" },
  { image: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg", imageAlt: "A person with curly red hair in a lime green blazer against a bright pink background", title: "Lime Blazer", subtitle: "Portrait" },
  { image: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg", imageAlt: "Pastel architecture with a purple wall, a pink building and a lone figure against a blue sky", title: "Pastel Rooftop", subtitle: "Architecture" },
  { image: "https://ui.dimaac.com/media/fashion/orange-ring-4x5.jpg", imageAlt: "A young man in a knit bucket hat framed through an orange circle", title: "Orange Ring", subtitle: "Editorial" },
  { image: "https://ui.dimaac.com/media/objects/blue-bottles-4x5.jpg", imageAlt: "Perfume bottles with blue liquid and metallic caps against a textured wall with white flowers", title: "Blue Hour", subtitle: "Product" },
  { image: "https://ui.dimaac.com/media/nature/coral-texture-4x5.jpg", imageAlt: "An abstract close-up of reddish-brown and white patterns that look like a network of veins", title: "Coral Texture", subtitle: "Abstract" },
  { image: "https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg", imageAlt: "Colourful cylinders on a bold geometric background in orange, red, blue and green", title: "Colour Cylinders", subtitle: "Set design" },
];

export default function Demo(props: Partial<ProofOfWorkProps>) {
  const { works: _works, className = "", ...settings } = props;
  return (
    <div className="min-h-dvh w-full font-[family-name:Inter,system-ui,sans-serif] bg-[#f4f2ee] text-[#1a1a1a] dark:bg-[#0e0d0c] dark:text-[#ece7df]">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Inter:wght@400;600&display=swap" precedence="default" />

      <ProofOfWork
        works={works}
        title="Proof of Work"
        tagline="Scroll to explore"
        titleClassName="font-[family-name:'Instrument_Serif',Georgia,serif]"
        {...settings}
        className={className}
      />

      <div aria-hidden="true" className="h-12" />
    </div>
  );
}
```

## Agent prompt

```text
Add the Proof Of Work component (<ProofOfWork />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/proof-of-work
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/proof-of-work.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/proof-of-work.json

Then use it like this:
import { ProofOfWork } from "@/components/dimaac/proof-of-work";
<ProofOfWork works={works} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/proof-of-work.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Scrolling Gallery
description: A zig-zag column of pictures that slide past each other as you scroll.
category: Gallery
tier: free
url: https://ui.dimaac.com/components/scrolling-gallery
registry: https://ui.dimaac.com/r/scrolling-gallery.json
---

# Scrolling Gallery

A zig-zag column of square pictures, each with its own speed. Scroll and the quick ones climb faster than the page while the slow ones lag behind, so neighbours overlap and part again; scroll fast and every picture leans over (skew). The page keeps its normal, native scroll: nothing takes it over, nothing is smoothed globally, and it plays well with Lenis or any other scroller (pass scroller to follow a scrolling element instead of the window). The first screen loads at rest, the layout is fluid down to phones, and with reduced motion the pictures simply sit in their zig-zag. Ported from the first DiMaac UI, where it ran on a page-wide ScrollSmoother; this version needs no plugin and rebuilds nothing on resize.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/scrolling-gallery.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/scrolling-gallery.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/scrolling-gallery.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/scrolling-gallery`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/scrolling-gallery.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ScrollingGallery } from "@/components/dimaac/scrolling-gallery" and give its parent a size: it fills it.

## Usage

```tsx
import { ScrollingGallery } from "@/components/dimaac/scrolling-gallery";

<ScrollingGallery images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `ScrollingGalleryImage[]` | - | The pictures, top to bottom. |
| `parallax` | `number` | `1` | How strongly the pictures drift from their spot. 0 turns the parallax off, 1 is each picture's own speed, 2 doubles it. |
| `skew` | `number` | `8` | The most a picture leans, in degrees, when the page scrolls fast. 0 never leans. |
| `smoothing` | `number` | `0.6` | How long a picture takes to catch up with the scroll, in seconds. A little lag feels soft. 0 follows the scroll exactly. |
| `size` | `number` | `26` | Width of each picture as a percent of the gallery. On a phone-wide gallery it grows to about half so the pictures stay big, and it never gets too wide for the zig-zag to fit. |
| `stagger` | `number` | `40` | How far apart the left and right pictures sit, as a percent of a picture's width. 0 stacks them in one column. |
| `gap` | `number` | `0` | Space between the pictures in the column, in pixels. |
| `aspectRatio` | `number` | `1` | Width divided by height of each picture. 1 is square, 0.8 is a 4:5 portrait. |
| `scroller` | `HTMLElement \| string \| null` | `null` | The element that scrolls, or a selector for it. Leave it out to follow the window. |
| `id` | `string` | - | An id for the outer box. |
| `className` | `string` | `""` | Extra classes for the outer box. |
| `imageClassName` | `string` | `""` | Extra classes for each picture, e.g. a radius or a shadow. |

## Credits

- Fashion Portrait with Orange Visor: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Modern Architectural Design: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Elegant Perfume Bottle: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Desert Dunes and Mountain Range: Pablo Stanley, Lummi (https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd), Lummi License
- Vivid Portrait on Pink: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Vibrant Cylindrical Arrangement: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Moody Solitary Figure: Krystyna, Lummi (https://www.lummi.ai/photo/moody-solitary-figure-x1avl), Lummi License
- Dramatic Coastline Aerial View: Cayetano Gros, Lummi (https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7), Lummi License
- Glossy Pink Sphere: marisol, Lummi (https://www.lummi.ai/photo/glossy-pink-sphere-45si5), Lummi License
- Avant-Garde Fashion Pose: Semihan Dosunmu, Lummi (https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo), Lummi License
- Minimalist Stucco Building: Siren Media, Lummi (https://www.lummi.ai/photo/minimalist-stucco-building-gurez), Lummi License
- Poised Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Elegant Vase with Dried Flowers: Pablo Stanley, Lummi (https://www.lummi.ai/photo/elegant-vase-with-dried-flowers-dhxnk), Lummi License
- Mossy Forest Scene: PZF, Lummi (https://www.lummi.ai/photo/mossy-forest-scene-o4b8t), Lummi License
- Reflective Spheres and Books in a Desert Mirage: Mariana Pedroza, Lummi (https://www.lummi.ai/photo/reflective-spheres-and-books-in-a-modern-setting-U_hc_), Lummi License
- Modern Fashion Pose: Axel Photography, Lummi (https://www.lummi.ai/photo/modern-fashion-pose-zsdav), Lummi License

## Source

### scrolling-gallery.tsx → `components/dimaac/scrolling-gallery.tsx`

```tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A zig-zag column of pictures that slide past each other as you scroll. Every picture has its own
// speed: a fast one climbs the screen quicker than the page scrolls, a slow one lags behind, so
// neighbours overlap and part again. A quick scroll also leans the pictures over (skew).
//
// It does NOT take over the page's scroll. The page scrolls natively; we only read where it is and
// nudge each picture with a transform. The nudge is computed from the picture's own position, so
// a picture sits exactly where the layout puts it when it is level with the middle of the screen
// and drifts away from that spot as it travels. Pictures that are already on screen when the page
// loads start at rest, so the first screen looks the way the layout was drawn.

export type ScrollingGalleryImage = {
  /** The image address. */
  src: string;
  /** Alt text for the picture. Use an empty string for a purely decorative one. */
  alt: string;
  /** How fast this picture travels compared with the scroll. 1 moves with the page, above 1 climbs quicker, below 1 lags behind. */
  speed?: number;
};

export type ScrollingGalleryProps = {
  /** The pictures, top to bottom. */
  images: ScrollingGalleryImage[];
  /** How strongly the pictures drift from their spot. 0 turns the parallax off, 1 is each picture's own speed, 2 doubles it. */
  parallax?: number;
  /** The most a picture leans, in degrees, when the page scrolls fast. 0 never leans. */
  skew?: number;
  /** How long a picture takes to catch up with the scroll, in seconds. A little lag feels soft. 0 follows the scroll exactly. */
  smoothing?: number;
  /** Width of each picture as a percent of the gallery. On a phone-wide gallery it grows to about half so the pictures stay big, and it never gets too wide for the zig-zag to fit. */
  size?: number;
  /** How far apart the left and right pictures sit, as a percent of a picture's width. 0 stacks them in one column. */
  stagger?: number;
  /** Space between the pictures in the column, in pixels. */
  gap?: number;
  /** Width divided by height of each picture. 1 is square, 0.8 is a 4:5 portrait. */
  aspectRatio?: number;
  /** The element that scrolls, or a selector for it. Leave it out to follow the window. */
  scroller?: HTMLElement | string | null;
  /** An id for the outer box. */
  id?: string;
  /** Extra classes for the outer box. */
  className?: string;
  /** Extra classes for each picture, e.g. a radius or a shadow. */
  imageClassName?: string;
};

function resolveScroller(scroller: ScrollingGalleryProps["scroller"]): HTMLElement | null {
  if (!scroller) return null;
  if (typeof scroller === "string") return document.querySelector<HTMLElement>(scroller);
  return scroller;
}

const clamp = (min: number, max: number, value: number) => Math.min(max, Math.max(min, value));

export function ScrollingGallery({
  images,
  parallax = 1,
  skew = 8,
  smoothing = 0.6,
  size = 26,
  stagger = 40,
  gap = 0,
  aspectRatio = 1,
  scroller = null,
  id,
  className = "",
  imageClassName = "",
}: ScrollingGalleryProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const rowRefs = useRef<(HTMLLIElement | null)[]>([]);
  const imageRefs = useRef<(HTMLImageElement | null)[]>([]);

  // Read on every frame, so moving these sliders doesn't rebuild anything
  const live = useRef({ parallax, skew });
  live.current = { parallax, skew };
  // The speeds are read when measuring and on every frame, so a changed speed lands without a rebuild
  const speeds = useRef<number[]>([]);
  speeds.current = images.map((image) => image.speed ?? 1);

  useGSAP(
    () => {
      const root = rootRef.current;
      const rows = rowRefs.current.slice(0, images.length).filter((row): row is HTMLLIElement => row !== null);
      const pictures = imageRefs.current.slice(0, images.length).filter((image): image is HTMLImageElement => image !== null);
      if (!root || rows.length === 0 || rows.length !== pictures.length) return;

      // Reduced motion: nothing below runs, the pictures stay where the layout puts them
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const scrollElement = resolveScroller(scroller);
        const readScroll = () => (scrollElement ? scrollElement.scrollTop : window.scrollY);
        // The visible part of whatever scrolls: its top edge and height, in window coordinates
        const readView = () => {
          if (!scrollElement) return { top: 0, height: window.innerHeight };
          return { top: scrollElement.getBoundingClientRect().top, height: scrollElement.clientHeight };
        };

        const moveY = pictures.map((picture) => gsap.quickTo(picture, "y", { duration: Math.max(smoothing, 0.0001), ease: "power3.out" }));
        const lean = gsap.quickTo(pictures, "skewY", { duration: 0.5, ease: "power3.out" });

        // Where each row's middle sits in the root, and the distance from the middle of the screen
        // at which that row is "at rest" (its drift is zero there). That is the middle of the screen,
        // unless the row is already on screen when the page is at the top: then it rests where it starts.
        let middles: number[] = [];
        let restOffsets: number[] = [];

        const measure = () => {
          const view = readView();
          const rootTop = root.getBoundingClientRect().top;
          const scrollTopInContent = rootTop - view.top + readScroll();
          middles = rows.map((row) => {
            const box = row.getBoundingClientRect();
            return box.top - rootTop + box.height / 2;
          });
          restOffsets = middles.map((middle) => {
            const middleAtScrollZero = scrollTopInContent + middle;
            const startsOnScreen = middleAtScrollZero >= 0 && middleAtScrollZero <= view.height;
            return startsOnScreen ? middleAtScrollZero - view.height / 2 : 0;
          });
        };
        measure();

        let lastScroll = readScroll();
        let velocity = 0; // pixels per second, smoothed. Positive means scrolling down.
        let visible = true;

        const tick = (_time: number, deltaMs: number) => {
          const dt = Math.min(deltaMs, 50) / 1000;
          if (dt <= 0) return;

          const scrolled = readScroll();
          const rawVelocity = (scrolled - lastScroll) / dt;
          lastScroll = scrolled;
          // Wheel ticks arrive in bursts, so smooth them
          velocity += (rawVelocity - velocity) * (1 - Math.exp(-dt * 10));
          if (!visible) return;

          const { parallax: strength, skew: maxSkew } = live.current;
          const view = readView();
          const rootTop = root.getBoundingClientRect().top - view.top;

          rows.forEach((_, index) => {
            const fromMiddle = rootTop + middles[index]! - view.height / 2 - restOffsets[index]!;
            // Bounded, so a picture far away never flies off
            const travelled = clamp(-view.height, view.height, fromMiddle);
            moveY[index]!(((speeds.current[index] ?? 1) - 1) * strength * travelled);
          });

          // Scrolling down leans the pictures one way, up the other. About 900px a second leans fully.
          lean(clamp(-maxSkew, maxSkew, (-velocity / 900) * maxSkew));
        };
        gsap.ticker.add(tick);

        // The gallery changing size (a resize, a new gap or size) moves every row, so measure again
        const resizeObserver = new ResizeObserver(measure);
        resizeObserver.observe(root);
        window.addEventListener("resize", measure);
        // Images and fonts above the gallery can push it down after the first measure
        window.addEventListener("load", measure);

        // No work while the gallery is off screen
        const visibility = new IntersectionObserver(([entry]) => {
          visible = entry?.isIntersecting ?? true;
        }, { rootMargin: "200px 0px" });
        visibility.observe(root);

        return () => {
          gsap.ticker.remove(tick);
          resizeObserver.disconnect();
          visibility.disconnect();
          window.removeEventListener("resize", measure);
          window.removeEventListener("load", measure);
          gsap.killTweensOf(pictures);
          gsap.set(pictures, { clearProps: "transform" });
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [images.length, scroller, smoothing], revertOnUpdate: true },
  );

  // The two columns sit `stagger` percent of a picture apart, so each row moves half of that.
  // The widest the zig-zag may make a picture is the width at which it still fits the box.
  const widest = 100 / (1 + stagger / 100);
  // In a box narrower than about 620px the picture grows to roughly half of it (the 4 is how fast
  // it ramps up), so phones don't get thumbnails. In a wider box this is 0 and `size` rules.
  const phoneFloor = "clamp(0px, calc((620px - 100%) * 4), 52%)";

  return (
    <div id={id} ref={rootRef} className={`relative w-full overflow-x-clip ${className}`}>
      <ul className="m-0 flex list-none flex-col p-0" style={{ rowGap: gap }}>
        {images.map((image, index) => (
          <li
            key={`${index}-${image.src}`}
            ref={(row) => {
              rowRefs.current[index] = row;
            }}
            className="flex justify-center"
          >
            <div className="max-w-full" style={{ width: `min(max(${size}%, 80px, ${phoneFloor}), ${widest}%)`, transform: `translateX(${index % 2 === 0 ? stagger / 2 : -stagger / 2}%)` }}>
              <img
                ref={(picture) => {
                  imageRefs.current[index] = picture;
                }}
                src={image.src}
                alt={image.alt}
                loading="lazy"
                decoding="async"
                draggable={false}
                className={`block w-full object-cover will-change-transform ${imageClassName}`}
                style={{ aspectRatio }}
              />
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { ScrollingGallery, type ScrollingGalleryImage, type ScrollingGalleryProps } from "@/components/dimaac/scrolling-gallery";

// A small editorial page: an opening screen, the gallery with a huge headline that stays put
// behind the pictures, and a closing line. The pictures slide over the headline as they pass.
// Sixteen Lummi photos (Lummi License), each with its own speed.

const photos: [file: string, speed: number, alt: string][] = [
  ["fashion/orange-visor-4x5.jpg", 0.8, "Stylish woman in an orange visor and navy dress, with bold red lips and yellow earrings, against a teal background"],
  ["architecture/yellow-planes-4x5.jpg", 0.9, "Minimalist architecture with yellow and beige walls under a clear blue sky"],
  ["objects/amber-perfume-4x5.jpg", 1.1, "Elegant glass perfume bottle with amber liquid, against a green background with leaf shadows"],
  ["nature/golden-dunes-3x2.jpg", 0.8, "Vast desert landscape with golden sand dunes and distant mountains under a clear blue sky"],
  ["portraits/lime-blazer-4x5.jpg", 1.2, "A person with curly red hair wearing a lime green blazer against a bright pink background"],
  ["art/colour-cylinders-4x5.jpg", 0.6, "Colourful cylindrical objects on a vibrant geometric background in orange, red, blue and green"],
  ["dark/teal-glass-4x5.jpg", 1.0, "Moody silhouette of a person sitting on a bench against teal glass, with soft yellow light"],
  ["nature/coastline-aerial-4x5.jpg", 0.85, "Aerial view of turquoise waves crashing against a sandy beach, bordered by rugged cliffs"],
  ["art/pink-sphere-1x1.jpg", 1.15, "A glossy pink sphere reflecting a dreamy sky with clouds"],
  ["fashion/chunky-knit-4x5.jpg", 0.9, "A model in a chunky knit sweater, ruffled pants and coral lace-up boots against a warm backdrop"],
  ["architecture/pink-stucco-2x3.jpg", 1.05, "White and pink stucco walls with wooden windows under a deep blue sky"],
  ["portraits/red-coat-4x5.jpg", 0.75, "A portrait of a person in a red coat against a deep blue background"],
  ["objects/purple-vase-3x2.jpg", 1.2, "An elegant white vase with dried flowers against a deep purple background"],
  ["nature/mossy-forest-4x5.jpg", 0.8, "Lush moss-covered forest floor with misty trees in the background"],
  ["art/chrome-spheres-1x1.jpg", 1.0, "Metallic sculptures as bookends on a concrete surface, reflecting a clear blue sky"],
  ["fashion/mid-air-suit-4x5.jpg", 0.95, "A person in a black suit and futuristic accessories, mid-motion against a teal and terracotta backdrop"],
];

const images: ScrollingGalleryImage[] = photos.map(([file, speed, alt]) => ({ src: media(file), alt, speed }));

export default function Demo(props: Partial<ScrollingGalleryProps>) {
  return (
    <div className="w-full overflow-x-clip bg-[#efece6] text-[#15130f] dark:bg-[#0d0c0b] dark:text-[#ece9e2]">
      <section className="relative flex min-h-[100dvh] flex-col justify-end gap-[clamp(32px,8vh,96px)] px-5 py-5 md:px-8 md:py-7">
        <h1 className="max-w-[12ch] text-[clamp(52px,11.5vw,184px)] leading-[0.88] font-medium tracking-[-0.055em]">
          Everything moves a little differently
        </h1>

        <footer className="flex items-end justify-between gap-6">
          <p className="max-w-[32ch] text-[15px] leading-snug opacity-65">Sixteen photographs, sixteen speeds. Scroll fast and they lean into it.</p>
          <span className="font-mono text-[11px] tracking-wide uppercase opacity-70">Scroll ↓</span>
        </footer>
      </section>

      <section className="relative pt-[26vh] pb-[18vh]">
        <ScrollingGallery images={images} {...props} className={`relative z-0 ${props.className ?? ""}`} imageClassName={`shadow-[0_24px_50px_-24px_rgba(0,0,0,0.45)] ${props.imageClassName ?? ""}`} />
        {/* Two words that stay in the middle of the screen while the pictures pass. They sit in the margins on a wide screen; on a phone the pictures slide over them, and the "difference" blend keeps them readable. */}
        <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 mix-blend-difference">
          <div className="sticky top-0 flex h-[100dvh] items-center justify-between overflow-hidden px-5 text-[clamp(52px,12.5vw,210px)] leading-none font-medium tracking-[-0.06em] text-white select-none md:px-8">
            <span>Slow</span>
            <span>Fast</span>
          </div>
        </div>
      </section>

      <section className="px-5 pt-[8vh] pb-8 md:px-8">
        <p className="max-w-[18ch] text-[clamp(32px,6vw,92px)] leading-[0.98] font-medium tracking-[-0.045em]">The page still scrolls the way you expect.</p>
      </section>
    </div>
  );
}
```

## Agent prompt

```text
Add the Scrolling Gallery component (<ScrollingGallery />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/scrolling-gallery
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/scrolling-gallery.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/scrolling-gallery.json

Then use it like this:
import { ScrollingGallery } from "@/components/dimaac/scrolling-gallery";
<ScrollingGallery images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/scrolling-gallery.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Section 3D
description: Tall cards that fall back into 3D as the next one arrives.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/section-3d
registry: https://ui.dimaac.com/r/section-3d.json
---

# Section 3D

A scroll section for a team or an our-work list. The page pins one stage; as you scroll, the next tall photo card rises from the bottom while the card under it fades, lifts and tips back into the screen. One pin and one scrubbed timeline run the whole stack, so a dozen cards stay light, and the animation plays backwards when you scroll up. Tilt, depth, lift, fade, perspective and how much scrolling each hand-over takes are all props. It works on the page or inside any scrolling element through the scroller prop, re-measures when the window or the scroller changes size, uses plain img tags (no Next.js needed) and turns into an ordinary tall page of cards when the visitor prefers reduced motion. Ported from the first DiMaac UI, where every card was pinned on its own.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/section-3d.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/section-3d.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/section-3d.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/section-3d`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/section-3d.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { Section3d } from "@/components/dimaac/section-3d" and give its parent a size: it fills it.

## Usage

```tsx
import { Section3d } from "@/components/dimaac/section-3d";

<Section3d />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

## Credits

- Portrait (Marcus Chen): studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait (Elena Voss): Edita, Lummi (https://www.lummi.ai/photo/striking-portrait-kfent), Lummi License
- Portrait (Priya Nair): Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait (Sofia Reyes): SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Portrait (Ava Mitchell): Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Portrait (Kai Tanaka): Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License
- Portrait (Liam Foster): Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Portrait (Nina Patel): Amino, Lummi (https://www.lummi.ai/photo/stylish-portrait-8inth), Lummi License

## Source

### section-3d.tsx → `components/dimaac/section-3d.tsx`

```tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// A stack of tall photo cards that arrive one after another. The page pins a single stage; as you
// scroll, the next card rises from the bottom while the card under it fades, lifts and tips back
// into the screen, as if it were falling away into a room behind the page.
// One pin and one scrubbed timeline run the whole thing (the first version pinned every card on its
// own, which got heavy with a dozen cards). The animation is tied to the scrollbar, so it also
// plays backwards when you scroll up.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);

export type Section3DCard = {
  /** The photo. */
  image: string;
  /** Alt text for the photo. Falls back to the name. */
  imageAlt?: string;
  /** A small label above the name, e.g. "01 / 08". */
  number: string;
  /** The big line on the card. */
  name: string;
  /** The line under the name. */
  role: string;
  /** A short quote at the bottom of the card. Leave it out for no quote. */
  quote?: string;
};

export type Section3DProps = {
  /** The cards, in the order they arrive. */
  cards: Section3DCard[];
  /** The big heading of the opening screen. */
  introTitle?: string;
  /** The paragraph under it. Pass an empty string to leave it out. */
  introDescription?: string;
  /** How far a card tips back while the next one arrives, in degrees. */
  tilt?: number;
  /** How far a card sinks away from you, in pixels. */
  depth?: number;
  /** How far a card rises while it recedes, as a percentage of its own height. */
  lift?: number;
  /** How much a receding card fades out, from 0 (stays solid) to 1 (fully gone). */
  fade?: number;
  /** Depth of the 3D effect, in pixels. Smaller is more dramatic. */
  perspective?: number;
  /** How much scrolling one hand-over takes, in screens. 1 moves the next card exactly with your scroll. */
  scrollPerCard?: number;
  /** The start colour of the heading underline and the small line on each card. */
  accent?: string;
  /** The end colour of those two gradients. */
  accentEnd?: string;
  /** The colour behind the cards. Use a dark one: the text on the page is light. */
  backdrop?: string;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes for the outer wrapper. */
  className?: string;
};

export function Section3D({
  cards,
  introTitle = "Portraits",
  introDescription = "Eight photos in one stack. Scroll to bring the next one up.",
  tilt = 80,
  depth = 800,
  lift = 25,
  fade = 1,
  perspective = 1000,
  scrollPerCard = 1,
  accent = "#ff6b35",
  accentEnd = "#f7c59f",
  backdrop = "#160f03",
  scroller,
  className = "",
}: Section3DProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const cardCount = cards.length;
  // The motion settings are read by the tweens when they (re)measure, so dragging a slider in a
  // playground doesn't rebuild the pin and throw the visitor back up the page
  const settings = useRef({ tilt, depth, lift, fade, scrollPerCard });
  settings.current = { tilt, depth, lift, fade, scrollPerCard };
  const trigger = useRef<ScrollTrigger | null>(null);

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root || cardCount === 0) return;

      const mm = gsap.matchMedia();

      // Reduced motion: nothing is pinned or tipped. The cards are just a tall page, one after another.
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const stage = root.querySelector<HTMLElement>("[data-s3d-stage]");
        const slots = gsap.utils.toArray<HTMLElement>("[data-s3d-slot]", root);
        const faces = gsap.utils.toArray<HTMLElement>("[data-s3d-card]", root);
        const line = root.querySelector<HTMLElement>("[data-s3d-line]");
        if (!stage) return;

        // Inside an inner scroller, "a screen" means that scroller's height, not the window's
        const scrollEl = typeof scroller === "string" ? document.querySelector<HTMLElement>(scroller) : scroller;
        const syncHeight = (): void => {
          root.style.setProperty("--s3d-h", scrollEl ? `${scrollEl.clientHeight}px` : "100svh");
        };
        syncHeight();
        ScrollTrigger.addEventListener("refreshInit", syncHeight);

        // The slow light that runs down the line at the bottom of the opening screen
        if (line) gsap.fromTo(line, { yPercent: -100 }, { yPercent: 100, duration: 2, ease: "sine.inOut", repeat: -1 });

        // One timeline, one step per hand-over: card i+1 rises into view while card i falls back.
        // Every step is one unit long and the steps run back to back, so scrub keeps them in order.
        if (cardCount > 1) {
          const handOvers = gsap.timeline({
            defaults: { ease: "none", duration: 1 },
            scrollTrigger: {
              trigger: stage,
              scroller,
              start: "top top",
              // Measured on every refresh, so rotating a phone or resizing the window stays correct
              end: () => `+=${(cardCount - 1) * stage.offsetHeight * settings.current.scrollPerCard}`,
              pin: true,
              scrub: true,
              anticipatePin: 1,
              invalidateOnRefresh: true,
            },
          });

          trigger.current = handOvers.scrollTrigger ?? null;

          for (let i = 1; i < cardCount; i++) {
            // The new card starts just below the stage (its slot sits at top: 100%) and climbs to the top
            handOvers.fromTo(slots[i]!, { yPercent: 0 }, { yPercent: -100 }, i - 1);
            handOvers.fromTo(
              faces[i - 1]!,
              { opacity: 1, yPercent: 0, z: 0, rotationX: 0 },
              {
                opacity: () => 1 - settings.current.fade,
                yPercent: () => -settings.current.lift,
                z: () => -settings.current.depth,
                rotationX: () => settings.current.tilt,
                transformOrigin: "50% 50%",
                force3D: true,
              },
              i - 1,
            );
          }
        }

        // A scroller that changes size (a resizable panel, for example) needs the pin measured again
        let measure: gsap.core.Tween | undefined;
        const observer = scrollEl ? new ResizeObserver(() => {
          measure?.kill();
          measure = gsap.delayedCall(0.2, () => ScrollTrigger.refresh());
        }) : null;
        if (scrollEl) observer?.observe(scrollEl);

        return () => {
          measure?.kill();
          observer?.disconnect();
          ScrollTrigger.removeEventListener("refreshInit", syncHeight);
          trigger.current = null;
          root.style.removeProperty("--s3d-h");
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [cardCount, scroller], revertOnUpdate: true },
  );

  // New motion settings: measure the pin again, keeping the scroll position
  useEffect(() => {
    trigger.current?.refresh();
  }, [tilt, depth, lift, fade, scrollPerCard]);

  const style = { backgroundColor: backdrop, "--s3d-h": "100svh" } as CSSProperties;
  const gradient = `linear-gradient(90deg, ${accent}, ${accentEnd})`;

  return (
    <div ref={rootRef} className={`relative w-full overflow-x-clip text-white ${className}`} style={style}>
      {/* The opening screen. It scrolls like a normal page. */}
      <section className="flex h-[var(--s3d-h)] flex-col items-center justify-center px-6 text-center md:px-8">
        <h2
          className="mt-auto mb-6 bg-clip-text text-[clamp(48px,8vw,80px)] leading-[1.05] font-extrabold tracking-[-0.02em] text-transparent"
          style={{ backgroundImage: `linear-gradient(135deg, ${accent} 0%, ${accentEnd} 50%, #fff 100%)` }}
        >
          {introTitle}
        </h2>
        {introDescription ? <p className="max-w-[500px] px-4 text-[clamp(16px,1.4vw,18px)] leading-normal tracking-[0.02em] text-white/50">{introDescription}</p> : null}
        <div aria-hidden="true" className="relative mt-auto h-24 w-px overflow-hidden bg-white/10 md:h-48">
          <span data-s3d-line className="absolute inset-0 block bg-gradient-to-b from-transparent via-white/60 to-transparent" />
        </div>
      </section>

      {/* The stage that gets pinned. The cards sit stacked inside it. */}
      <ul data-s3d-stage className="relative m-0 block h-[var(--s3d-h)] list-none overflow-hidden p-0 motion-reduce:h-auto motion-reduce:overflow-visible">
        {cards.map((card, index) => (
          <li
            key={`${index}-${card.image}`}
            data-s3d-slot
            // The first card fills the stage; the others wait just below it. Reduced motion stacks them in the flow.
            className={`absolute left-0 flex h-[var(--s3d-h)] w-full items-center justify-center p-4 md:p-8 motion-reduce:static motion-reduce:py-10 ${index === 0 ? "top-0" : "top-full"}`}
            style={{ perspective, transformStyle: "preserve-3d" }}
          >
            <article
              data-s3d-card
              className="relative h-[80%] w-[92%] max-w-[500px] overflow-hidden rounded-2xl bg-black/40 md:w-1/2 md:max-w-[360px] md:min-w-[280px] md:rounded-[2rem] lg:w-[35%] lg:max-w-[500px] lg:min-w-[380px]"
              style={{ willChange: "transform, opacity" }}
            >
              <img
                src={card.image}
                alt={card.imageAlt ?? card.name}
                className="absolute inset-0 h-full w-full object-cover"
                loading={index < 2 ? "eager" : "lazy"}
                decoding="async"
                draggable={false}
              />
              <div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/90 via-black/40 via-30% to-transparent to-60% p-6 md:p-10">
                <span className="mb-3 block text-[11px] font-semibold tracking-[0.3em] text-white/50 uppercase">{card.number}</span>
                <h3 className="mb-2 text-[clamp(22px,2.2vw,32px)] leading-[1.1] font-extrabold tracking-[-0.02em] text-white">{card.name}</h3>
                <p className="text-sm font-medium tracking-[0.05em] text-white/70 md:text-[15px]">{card.role}</p>
                <div className="mt-4 h-[3px] w-10 rounded-full" style={{ backgroundImage: gradient }} />
                {card.quote ? <p className="mt-4 text-xs leading-normal text-white/50 italic md:text-[13px]">&ldquo;{card.quote}&rdquo;</p> : null}
              </div>
            </article>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { Section3D, type Section3DCard, type Section3DProps } from "@/components/dimaac/section-3d";

// Eight portraits in a stack. They are Lummi photos (Lummi License: free for personal and commercial use).
// The page behind the cards is always dark, so the effect reads the same in the light and the dark site theme.

const portraits: Section3DCard[] = [
  {
    image: "https://ui.dimaac.com/media/portraits/platinum-blonde-4x5.jpg",
    imageAlt: "Close-up of a young man with platinum blonde hair, green eyes, and a yellow jacket against a bold orange background",
    number: "No. 01 / 08",
    name: "Platinum",
    role: "Yellow jacket, orange wall",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/glossy-lowkey-4x5.jpg",
    imageAlt: "Close-up portrait of a woman with glossy lips, sparkling earrings, and dramatic lighting highlighting her features and purple attire",
    number: "No. 02 / 08",
    name: "Glossy",
    role: "Low light, purple silk",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg",
    imageAlt: "A bold profile shot of a woman in neon pink sunglasses against a vibrant blue background, showcasing a modern and artistic vibe",
    number: "No. 03 / 08",
    name: "Neon pink",
    role: "Sunglasses on blue",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/tinted-glasses-4x5.jpg",
    imageAlt: "Stylish woman in purple-tinted sunglasses, braided hair, and a vibrant shirt against a glowing orange-yellow background",
    number: "No. 04 / 08",
    name: "Tinted glasses",
    role: "Braids against amber",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg",
    imageAlt: "A person with curly red hair wearing a lime green blazer with a blue lapel stands against a bright pink background",
    number: "No. 05 / 08",
    name: "Lime blazer",
    role: "Red curls, pink wall",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/light-streaks-4x5.jpg",
    imageAlt: "Close-up of a face with blue eyes and colourful streaks of light",
    number: "No. 06 / 08",
    name: "Light streaks",
    role: "Blue eyes, coloured light",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/red-coat-4x5.jpg",
    imageAlt: "A striking portrait of a person in a red coat against a deep blue background, showcasing bold contrasts and captivating lighting",
    number: "No. 07 / 08",
    name: "Red coat",
    role: "Deep blue backdrop",
  },
  {
    image: "https://ui.dimaac.com/media/portraits/burgundy-blazer-4x5.jpg",
    imageAlt: "A stylish individual in a burgundy blazer and white turtleneck, set against a neutral background, showcasing elegance and sophistication",
    number: "No. 08 / 08",
    name: "Burgundy blazer",
    role: "White turtleneck",
  },
];

export default function Demo(props: Partial<Section3DProps>) {
  return (
    <div className="w-full font-[family-name:Syne,sans-serif]">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&display=swap" precedence="default" />
      <Section3D cards={portraits} {...props} />
    </div>
  );
}
```

## Agent prompt

```text
Add the Section 3D component (<Section3d />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/section-3d
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/section-3d.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/section-3d.json

Then use it like this:
import { Section3d } from "@/components/dimaac/section-3d";
<Section3d />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/section-3d.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Settle Text
description: Headline rows that slide in while their letters drop in and settle.
category: Text
tier: free
url: https://ui.dimaac.com/components/settle-text
registry: https://ui.dimaac.com/r/settle-text.json
---

# Settle Text

Big headline rows driven by the scroll. As a row climbs into view it slides in from the side, and its letters fall in from above and below (every other letter from the opposite side) and settle one after another into the word. The next row comes in from the other side, so the page zig-zags as you scroll. Built with GSAP ScrollTrigger and SplitText, scrubbed so the motion follows the scrollbar with a little smoothing. It takes an optional scroller element, reverts the split text on unmount, keeps the headline readable for screen readers, and leaves the text still when the visitor prefers reduced motion. Recreated from the Scroll text YouTube tutorial.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/settle-text.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/settle-text.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/settle-text.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/settle-text`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/settle-text.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { SettleText } from "@/components/dimaac/settle-text" and give its parent a size: it fills it.

## Usage

```tsx
import { SettleText } from "@/components/dimaac/settle-text";

<SettleText />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `lines` (required) | `string[]` | - | The headlines, one per row. |
| `scrub` | `number` | `1` | Scroll smoothing in seconds: how long the text takes to catch up with the scrollbar. 0 follows it exactly. |
| `offset` | `number` | `100` | How far a row slides in from the side, as a percent of its width. 0 means no slide. |
| `letterStagger` | `number` | `0.04` | How much later each letter settles than the one before it, as a fraction of the row's scroll. |
| `drop` | `number` | `150` | How far, in pixels, the letters start above or below their place. |
| `alternate` | `boolean` | `true` | Slide every other row in from the opposite side, and settle its letters from the other end. |
| `scroller` | `HTMLElement \| string \| null` | `null` | The element that scrolls (an element or a CSS selector). The default is the window. |
| `className` | `string` | `""` | Extra classes for the outer box. |
| `rowClassName` | `string` | `"h-[85svh]"` | Extra classes for every row. Sets the row height (85svh by default) and can set a background. |
| `rowClassNames` | `string[]` | `[]` | Extra classes per row, repeating if there are more rows than entries. Handy for a background per row. |
| `headingClassName` | `string` | `"text-[clamp(2.5rem,13vw,9rem)]"` | Extra classes for the headline text, e.g. a font size or a font. |

## Source

### settle-text.tsx → `components/dimaac/settle-text.tsx`

```tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

// Big headline rows. As a row scrolls into view it slides in from the side while its letters
// drop in from above and below (every other letter from the opposite side) and settle into the
// word. The next row slides in from the other side, so the page zig-zags as you scroll.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);

export type SettleTextProps = {
  /** The headlines, one per row. */
  lines: string[];
  /** Scroll smoothing in seconds: how long the text takes to catch up with the scrollbar. 0 follows it exactly. */
  scrub?: number;
  /** How far a row slides in from the side, as a percent of its width. 0 means no slide. */
  offset?: number;
  /** How much later each letter settles than the one before it, as a fraction of the row's scroll. */
  letterStagger?: number;
  /** How far, in pixels, the letters start above or below their place. */
  drop?: number;
  /** Slide every other row in from the opposite side, and settle its letters from the other end. */
  alternate?: boolean;
  /** The element that scrolls (an element or a CSS selector). The default is the window. */
  scroller?: HTMLElement | string | null;
  /** Extra classes for the outer box. */
  className?: string;
  /** Extra classes for every row. Sets the row height (85svh by default) and can set a background. */
  rowClassName?: string;
  /** Extra classes per row, repeating if there are more rows than entries. Handy for a background per row. */
  rowClassNames?: string[];
  /** Extra classes for the headline text, e.g. a font size or a font. */
  headingClassName?: string;
};

export function SettleText({
  lines,
  scrub = 1,
  offset = 100,
  letterStagger = 0.04,
  drop = 150,
  alternate = true,
  scroller = null,
  className = "",
  rowClassName = "h-[85svh]",
  rowClassNames = [],
  headingClassName = "text-[clamp(2.5rem,13vw,9rem)]",
}: SettleTextProps) {
  const boxRef = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const box = boxRef.current;
      if (!box) return;

      // Reduced motion: nothing is split or moved, the headlines just sit there, readable.
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const splits: SplitText[] = [];

        gsap.utils.toArray<HTMLElement>("[data-settle-row]", box).forEach((row, rowIndex) => {
          const track = row.querySelector<HTMLElement>("[data-settle-track]");
          const heading = row.querySelector<HTMLElement>("[data-settle-heading]");
          if (!track || !heading) return;

          // Each letter becomes its own inline-block, so it can move on its own.
          // SplitText also keeps the whole word readable for screen readers.
          const split = SplitText.create(heading, { type: "chars", charsClass: "settle-char" });
          splits.push(split);
          const letters = split.chars as HTMLElement[];
          const count = letters.length;
          if (count === 0) return;

          // Odd rows go the other way: they slide in from the left and settle their last letter first.
          const flipped = alternate && rowIndex % 2 === 1;
          const side = flipped ? -1 : 1;

          // The row's scroll is one timeline of length 1. The letters start at 0.1 and all
          // finish by 1, so each letter's trip is whatever is left after the stagger.
          // The stagger is capped so every letter still has a short trip.
          const step = count > 1 ? Math.min(letterStagger, 0.7 / (count - 1)) : 0;
          const trip = 0.9 - step * (count - 1);

          const timeline = gsap.timeline({
            defaults: { ease: "none" },
            scrollTrigger: {
              trigger: row,
              ...(scroller ? { scroller } : {}),
              start: "top bottom",
              end: "top top",
              scrub: scrub > 0 ? scrub : true,
            },
          });

          timeline.fromTo(track, { xPercent: offset * side }, { xPercent: 0, duration: 1 }, 0);
          letters.forEach((letter, index) => {
            const order = flipped ? count - 1 - index : index;
            const from = index % 2 === 0 ? -drop : drop;
            timeline.fromTo(letter, { y: from }, { y: 0, duration: trip }, 0.1 + order * step);
          });
        });

        return () => splits.forEach((split) => split.revert());
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [lines.join("\n"), scrub, offset, letterStagger, drop, alternate, scroller], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} className={`w-full ${className}`}>
      {lines.map((line, index) => (
        <div
          key={`${index}-${line}`}
          data-settle-row=""
          className={`flex w-full items-center overflow-hidden ${rowClassName} ${rowClassNames.length ? (rowClassNames[index % rowClassNames.length] ?? "") : ""}`}
        >
          <div data-settle-track="" className="flex w-full justify-center will-change-transform">
            <h2 data-settle-heading="" className={`m-0 font-extrabold leading-[0.92] tracking-[-0.03em] whitespace-nowrap ${headingClassName}`}>
              {line}
            </h2>
          </div>
        </div>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { SettleText, type SettleTextProps } from "@/components/dimaac/settle-text";

// The scroll page from the video: a dark intro, three coloured headline rows, a quiet end.
// The demo has its own colours, so it looks the same in the light and the dark site theme.
// The playground sends "lines" as one string, with " / " between the rows.

const rowColours = [
  "bg-[linear-gradient(135deg,#3b6947_0%,#0f1a12_100%)] text-[#ede8df]",
  "bg-[#ede8df] text-[#0a0a0c]",
  "bg-[linear-gradient(135deg,#721e30_0%,#1a0a0e_100%)] text-[#ede8df]",
];

const defaultLines = ["Pull Into Focus", "Fade Into Static", "Burst Into Bloom"];

type DemoProps = Omit<Partial<SettleTextProps>, "lines"> & { lines?: string | string[] };

function toLines(value: DemoProps["lines"]): string[] {
  const list = Array.isArray(value) ? value : typeof value === "string" ? value.split("/") : defaultLines;
  const clean = list.map((line) => line.trim()).filter(Boolean);
  return clean.length ? clean : defaultLines;
}

export default function Demo({ lines, ...props }: DemoProps) {
  return (
    <div className="w-full bg-[#0a0a0c] [font-family:Bricolage_Grotesque,sans-serif] text-[#f4f0e8]">
      <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,800&display=swap"
        precedence="default"
      />

      <section className="grid h-svh place-content-center px-8 text-center">
        <p className="mb-6 text-xs font-medium tracking-[0.2em] text-[#c9a84c] uppercase">Scroll to explore</p>
        <h1 className="bg-gradient-to-b from-[#f4f0e8] to-[#8a8680] bg-clip-text text-[clamp(3rem,13vw,8rem)] leading-[0.92] font-extrabold tracking-[-0.03em] whitespace-nowrap text-transparent">
          Just Scroll
        </h1>
      </section>

      <SettleText lines={toLines(lines)} rowClassNames={rowColours} {...props} />

      <section className="grid h-svh place-content-center bg-gradient-to-b from-[#2a2a2f] to-[#0a0a0c] px-8 text-center">
        <p className="bg-gradient-to-b from-[#f4f0e8] to-[#8a8680] bg-clip-text text-[clamp(3rem,13vw,8rem)] leading-[0.92] font-extrabold tracking-[-0.03em] whitespace-nowrap text-transparent">
          The End
        </p>
      </section>
    </div>
  );
}
```

## Agent prompt

```text
Add the Settle Text component (<SettleText />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/settle-text
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/settle-text.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/settle-text.json

Then use it like this:
import { SettleText } from "@/components/dimaac/settle-text";
<SettleText />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/settle-text.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Social Card
description: A realistic post card in three looks: photo feed, short-text timeline and news feed.
category: Cards
tier: free
url: https://ui.dimaac.com/components/social-card
registry: https://ui.dimaac.com/r/social-card.json
---

# Social Card

One card that draws a believable social post in three looks (a photo feed, a short-text timeline and a news feed) from the same data: author with name, handle, picture and verified badge, text, an optional photo, and the like, comment and share counts. Like, repost and save really toggle and the numbers follow, with a small elastic pop and a ring of dots; double-click the photo on the photo-feed look to like it with a big heart. Hashtags and mentions are coloured. Everything is drawn here, so there are no logos, no icon package and no image component: the badge and every icon are plain shapes. It has its own light and dark colours (or follows the page), plays a soft entrance when it mounts or changes look, and with reduced motion it skips every animation but still works. Ported from the first DiMaac UI's Instagram, Twitter and Facebook cards and merged into one.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/social-card.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/social-card.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/social-card.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/social-card`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/social-card.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { SocialCard } from "@/components/dimaac/social-card" and give its parent a size: it fills it.

## Usage

```tsx
import { SocialCard } from "@/components/dimaac/social-card";

<SocialCard />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `SocialCardVariant` | `"instagram"` | Which look to draw: a photo feed, a short-text timeline or a news feed. |
| `author` | `SocialCardAuthor` | `defaultAuthor` | Who posted it: name, handle, profile picture and the verified badge. |
| `content` | `string` | `""` | The post text. #hashtags and @mentions are coloured. |
| `image` | `string` | - | Address of the photo under the text. Leave it out for a text-only post. |
| `imageAlt` | `string` | `""` | Alt text of the photo. |
| `imageRatio` | `number` | `0` | Shape of the photo as width divided by height, e.g. 1.6. 0 uses the look's own shape. |
| `likes` | `number` | `0` | Number of likes before the visitor touches the heart. |
| `comments` | `number` | `0` | Number of comments. |
| `shares` | `number` | `0` | Number of shares (reposts on the timeline look). |
| `timestamp` | `string` | `"now"` | The age of the post, as text: "2h", "Mar 4". |
| `liked` | `boolean` | - | Starts the post as already liked by the visitor. |
| `scheme` | `"auto" \| "light" \| "dark"` | `"auto"` | Colours of the card: follow the page's dark mode, or force light or dark. |
| `onLikedChange` | `(liked: boolean) => void` | - | Called whenever the visitor likes or unlikes the post. |
| `className` | `string` | `""` | Extra classes for the card. It is as wide as its parent, up to a limit per look. |

## Credits

- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Profile picture: Benginur Hajjaj, Lummi (https://www.lummi.ai/photo/calm-portrait-d1zqo), Lummi License

## Source

### social-card.tsx → `components/dimaac/social-card.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// One post card in three looks: a photo feed, a short-text timeline and a news feed. Same data in,
// different skin out. Like, repost and save really toggle (the counts follow), and they answer with
// a small pop and a ring of dots. Double-click the photo of the photo-feed card to like it.
// Every icon is drawn here as a plain stroke shape, so there are no logos and no icon package.

export type SocialCardVariant = "instagram" | "x" | "facebook";

export type SocialCardAuthor = {
  /** The display name, e.g. "Mira". */
  name: string;
  /** The handle without the @, e.g. "mira.photo". */
  handle: string;
  /** Address of the profile picture. When it is missing, the initials are shown. */
  avatar?: string;
  /** Shows the verified badge. */
  verified?: boolean;
};

export type SocialCardProps = {
  /** Which look to draw: a photo feed, a short-text timeline or a news feed. */
  variant?: SocialCardVariant;
  /** Who posted it: name, handle, profile picture and the verified badge. */
  author?: SocialCardAuthor;
  /** The post text. #hashtags and @mentions are coloured. */
  content?: string;
  /** Address of the photo under the text. Leave it out for a text-only post. */
  image?: string;
  /** Alt text of the photo. */
  imageAlt?: string;
  /** Shape of the photo as width divided by height, e.g. 1.6. 0 uses the look's own shape. */
  imageRatio?: number;
  /** Number of likes before the visitor touches the heart. */
  likes?: number;
  /** Number of comments. */
  comments?: number;
  /** Number of shares (reposts on the timeline look). */
  shares?: number;
  /** The age of the post, as text: "2h", "Mar 4". */
  timestamp?: string;
  /** Starts the post as already liked by the visitor. */
  liked?: boolean;
  /** Colours of the card: follow the page's dark mode, or force light or dark. */
  scheme?: "auto" | "light" | "dark";
  /** Called whenever the visitor likes or unlikes the post. */
  onLikedChange?: (liked: boolean) => void;
  /** Extra classes for the card. It is as wide as its parent, up to a limit per look. */
  className?: string;
};

// The colours of the card as CSS variables, so the three looks share one set of rules.
// "auto" uses the dark: variant of the project's Tailwind setup.
const lightScheme = "[--sc-bg:#ffffff] [--sc-fg:#0f1419] [--sc-muted:#5d6872] [--sc-line:rgba(15,20,25,0.11)] [--sc-hover:rgba(15,20,25,0.06)] [--sc-shadow:rgba(30,40,60,0.28)]";
const darkScheme = "[--sc-bg:#16181b] [--sc-fg:#eef0f2] [--sc-muted:#8d97a1] [--sc-line:rgba(255,255,255,0.11)] [--sc-hover:rgba(255,255,255,0.07)] [--sc-shadow:rgba(0,0,0,0.7)]";
const autoScheme = `${lightScheme} dark:[--sc-bg:#16181b] dark:[--sc-fg:#eef0f2] dark:[--sc-muted:#8d97a1] dark:[--sc-line:rgba(255,255,255,0.11)] dark:[--sc-hover:rgba(255,255,255,0.07)] dark:[--sc-shadow:rgba(0,0,0,0.7)]`;
const schemes = { auto: autoScheme, light: lightScheme, dark: darkScheme };

// Per look: the widest the card gets, the colour of the verified badge and of the like pop,
// and the shape of the photo
const looks = {
  instagram: { width: "max-w-[380px]", badge: "#0095f6", pop: "#ff3b5c", ratio: 4 / 5 },
  x: { width: "max-w-[560px]", badge: "#1d9bf0", pop: "#f91880", ratio: 16 / 10 },
  facebook: { width: "max-w-[560px]", badge: "#1877f2", pop: "#1877f2", ratio: 16 / 10 },
} as const;

const defaultAuthor: SocialCardAuthor = { name: "Alex", handle: "alex", verified: false };

// "12,480" for exact counts, "12.5K" for the compact ones on the timeline and news feed
const exact = new Intl.NumberFormat("en-US");
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });

export function SocialCard({
  variant = "instagram",
  author = defaultAuthor,
  content = "",
  image,
  imageAlt = "",
  imageRatio = 0,
  likes = 0,
  comments = 0,
  shares = 0,
  timestamp = "now",
  liked: startsLiked = false,
  scheme = "auto",
  onLikedChange,
  className = "",
}: SocialCardProps) {
  const rootRef = useRef<HTMLElement>(null);
  const [likedState, setLikedState] = useStateWithReset(startsLiked);
  const [repostedState, setRepostedState] = useStateWithReset(false);
  const [savedState, setSavedState] = useStateWithReset(false);
  const look = looks[variant];

  // The entrance: the parts of the card settle in one after another. It plays again when the look
  // changes, because the markup underneath is new.
  const { contextSafe } = useGSAP(
    () => {
      const root = rootRef.current;
      if (!root || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
      gsap.from(root.querySelectorAll("[data-in]"), { opacity: 0, y: 14, duration: 0.7, ease: "power3.out", stagger: 0.07, clearProps: "opacity,transform" });
    },
    { scope: rootRef, dependencies: [variant], revertOnUpdate: true },
  );

  // The small pop of an icon, and for a like also a ring of dots flying out
  const pop = contextSafe((name: "like" | "repost" | "save") => {
    const root = rootRef.current;
    if (!root || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const icon = root.querySelector(`[data-pop="${name}"]`);
    if (icon) gsap.fromTo(icon, { scale: 0.5, rotate: variant === "facebook" && name === "like" ? -24 : 0 }, { scale: 1, rotate: 0, duration: 0.75, ease: "elastic.out(1.1, 0.4)", overwrite: true });
    if (name !== "like") return;
    const dots = root.querySelectorAll("[data-dot]");
    gsap.fromTo(
      dots,
      { x: 0, y: 0, scale: 0.9, opacity: 1 },
      { x: (i) => Math.cos((i / dots.length) * Math.PI * 2) * 17, y: (i) => Math.sin((i / dots.length) * Math.PI * 2) * 17, scale: 0, opacity: 0, duration: 0.6, ease: "power2.out", overwrite: true },
    );
  });

  const setLiked = (next: boolean) => {
    setLikedState(next);
    onLikedChange?.(next);
    if (next) pop("like");
  };
  const toggleLike = () => setLiked(!likedState);
  const toggleRepost = () => {
    setRepostedState(!repostedState);
    if (!repostedState) pop("repost");
  };
  const toggleSave = () => {
    setSavedState(!savedState);
    if (!savedState) pop("save");
  };

  // Double-click on the photo: like it (never un-like) and show a big heart over it
  const likeFromPhoto = contextSafe(() => {
    const root = rootRef.current;
    const heart = root?.querySelector("[data-bigheart]");
    if (heart && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      gsap
        .timeline({ overwrite: true })
        .fromTo(heart, { scale: 0.3, opacity: 0, y: 0 }, { scale: 1.1, opacity: 1, duration: 0.35, ease: "back.out(2.2)" })
        .to(heart, { scale: 1, duration: 0.12 })
        .to(heart, { opacity: 0, y: -26, scale: 1.2, duration: 0.45, ease: "power2.in" }, "+=0.18");
    }
    if (!likedState) setLiked(true);
  });

  // The visitor's own like and repost add one to the numbers they were given
  const view: View = {
    author,
    content,
    image,
    imageAlt,
    ratio: imageRatio > 0 ? imageRatio : look.ratio,
    likes: likes + (likedState ? 1 : 0) - (startsLiked ? 1 : 0),
    comments,
    shares: shares + (repostedState ? 1 : 0),
    timestamp,
    liked: likedState,
    reposted: repostedState,
    saved: savedState,
    badge: look.badge,
    toggleLike,
    toggleRepost,
    toggleSave,
    likeFromPhoto,
  };

  const rootStyle = { "--sc-pop": look.pop, fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" } as CSSProperties;

  return (
    <article
      ref={rootRef}
      data-variant={variant}
      className={`${schemes[scheme]} ${look.width} w-full overflow-hidden rounded-[20px] border border-[var(--sc-line)] bg-[var(--sc-bg)] text-[var(--sc-fg)] shadow-[0_40px_70px_-40px_var(--sc-shadow)] ${className}`}
      style={rootStyle}
    >
      {variant === "instagram" && <PhotoFeed {...view} />}
      {variant === "x" && <Timeline {...view} />}
      {variant === "facebook" && <NewsFeed {...view} />}
    </article>
  );
}

// ---------------------------------------------------------------------------------------------
// State that starts from a prop and follows it when the prop changes (the playground flips it)
// ---------------------------------------------------------------------------------------------

function useStateWithReset(value: boolean) {
  const [state, setState] = useState(value);
  useEffect(() => setState(value), [value]);
  return [state, setState] as const;
}

// ---------------------------------------------------------------------------------------------
// The three looks
// ---------------------------------------------------------------------------------------------

type View = {
  author: SocialCardAuthor;
  content: string;
  image?: string;
  imageAlt: string;
  ratio: number;
  likes: number;
  comments: number;
  shares: number;
  timestamp: string;
  liked: boolean;
  reposted: boolean;
  saved: boolean;
  badge: string;
  toggleLike: () => void;
  toggleRepost: () => void;
  toggleSave: () => void;
  likeFromPhoto: () => void;
};

function PhotoFeed(v: View) {
  return (
    <>
      <header data-in className="flex items-center gap-3 px-3.5 py-3">
        <span className="rounded-full p-[2px]" style={{ background: "linear-gradient(135deg, #ffc53d, #ff5a5f 45%, #c13ad6)" }}>
          <span className="block rounded-full bg-[var(--sc-bg)] p-[2px]">
            <Avatar author={v.author} size={34} />
          </span>
        </span>
        <div className="flex min-w-0 flex-1 items-center gap-1 text-[14px] leading-none">
          <span className="truncate font-semibold">{v.author.handle}</span>
          {v.author.verified && <Seal color={v.badge} size={14} />}
          <span className="text-[var(--sc-muted)]">·</span>
          <span className="text-[var(--sc-muted)]">{v.timestamp}</span>
        </div>
        <IconButton label="More options" className="-mr-1.5">
          <Icon name="more" size={20} />
        </IconButton>
      </header>

      {v.image && (
        <div data-in className="relative overflow-hidden bg-[var(--sc-line)]" style={{ aspectRatio: v.ratio }} onDoubleClick={v.likeFromPhoto}>
          <img src={v.image} alt={v.imageAlt} className="block h-full w-full object-cover" draggable={false} decoding="async" />
          <svg data-bigheart viewBox="0 0 24 24" aria-hidden="true" className="pointer-events-none absolute top-1/2 left-1/2 -mt-14 -ml-14 h-28 w-28 opacity-0 drop-shadow-[0_8px_24px_rgba(0,0,0,0.35)]" fill="#fff">
            <path d={heartPath} />
          </svg>
        </div>
      )}

      <div data-in className="flex items-center gap-1 px-2.5 pt-2.5">
        <LikeButton v={v} size={26} className="p-1.5" />
        <IconButton label="Comment" className="p-1.5 hover:opacity-60">
          <Icon name="comment" size={25} />
        </IconButton>
        <IconButton label="Share" className="p-1.5 hover:opacity-60">
          <Icon name="send" size={25} />
        </IconButton>
        <button type="button" onClick={v.toggleSave} aria-pressed={v.saved} aria-label={v.saved ? "Remove from saved" : "Save"} className="ml-auto cursor-pointer rounded-md p-1.5 transition-opacity hover:opacity-60 focus-visible:outline-2 focus-visible:outline-offset-1">
          <span data-pop="save" className="block">
            <Icon name="bookmark" size={25} fill={v.saved} />
          </span>
        </button>
      </div>

      <div data-in className="px-3.5 pt-1 pb-4 text-[14px] leading-[1.45]">
        <p className="font-semibold">{exact.format(v.likes)} likes</p>
        {v.content && (
          <p className="mt-1 break-words whitespace-pre-wrap">
            <span className="mr-1.5 font-semibold">{v.author.handle}</span>
            <RichText text={v.content} color={v.badge} />
          </p>
        )}
        {v.comments > 0 && <p className="mt-1.5 text-[var(--sc-muted)]">View all {exact.format(v.comments)} comments</p>}
      </div>
    </>
  );
}

function Timeline(v: View) {
  const actionClass = "group flex cursor-pointer items-center gap-0.5 text-[var(--sc-muted)] transition-colors focus-visible:outline-2";
  // The round tint that blooms behind an icon on hover
  const bloom = "grid h-[34px] w-[34px] place-items-center rounded-full transition-colors";
  return (
    <div className="flex gap-3 px-4 pt-4 pb-2">
      <div data-in className="shrink-0">
        <Avatar author={v.author} size={44} />
      </div>
      <div className="min-w-0 flex-1">
        <div data-in className="flex items-center gap-1 text-[15px] leading-5">
          <span className="truncate font-bold">{v.author.name}</span>
          {v.author.verified && <Seal color={v.badge} size={18} />}
          <span className="truncate text-[var(--sc-muted)]">@{v.author.handle}</span>
          <span className="text-[var(--sc-muted)]">·</span>
          <span className="shrink-0 text-[var(--sc-muted)]">{v.timestamp}</span>
          <IconButton label="More options" className="-mr-2 ml-auto text-[var(--sc-muted)]">
            <Icon name="more" size={18} />
          </IconButton>
        </div>

        {v.content && (
          <p data-in className="mt-0.5 text-[15px] leading-5 break-words whitespace-pre-wrap">
            <RichText text={v.content} color={v.badge} />
          </p>
        )}

        {v.image && (
          <div data-in className="mt-3 overflow-hidden rounded-2xl border border-[var(--sc-line)] bg-[var(--sc-line)]" style={{ aspectRatio: v.ratio }}>
            <img src={v.image} alt={v.imageAlt} className="block h-full w-full object-cover" draggable={false} decoding="async" />
          </div>
        )}

        <div data-in className="-ml-2 mt-1.5 flex items-center justify-between pr-1 text-[13px]">
          <button type="button" aria-label="Reply" className={`${actionClass} hover:text-[#1d9bf0]`}>
            <span className={`${bloom} group-hover:bg-[#1d9bf0]/10`}>
              <Icon name="comment" size={18} />
            </span>
            <span className="min-w-6">{v.comments > 0 && compact.format(v.comments)}</span>
          </button>
          <button type="button" onClick={v.toggleRepost} aria-pressed={v.reposted} aria-label={v.reposted ? "Undo repost" : "Repost"} className={`${actionClass} hover:text-[#00ba7c] ${v.reposted ? "!text-[#00ba7c]" : ""}`}>
            <span className={`${bloom} group-hover:bg-[#00ba7c]/10`}>
              <span data-pop="repost" className="block">
                <Icon name="repost" size={18} />
              </span>
            </span>
            <span className="min-w-6">{v.shares > 0 && compact.format(v.shares)}</span>
          </button>
          <button type="button" onClick={v.toggleLike} aria-pressed={v.liked} aria-label={v.liked ? "Unlike" : "Like"} className={`${actionClass} hover:text-[#f91880] ${v.liked ? "!text-[#f91880]" : ""}`}>
            <span className={`${bloom} relative group-hover:bg-[#f91880]/10`}>
              <Dots />
              <span data-pop="like" className="block">
                <Icon name="heart" size={18} fill={v.liked} />
              </span>
            </span>
            <span className="min-w-6">{v.likes > 0 && compact.format(v.likes)}</span>
          </button>
          <button type="button" onClick={v.toggleSave} aria-pressed={v.saved} aria-label={v.saved ? "Remove bookmark" : "Bookmark"} className={`${actionClass} hover:text-[#1d9bf0] ${v.saved ? "!text-[#1d9bf0]" : ""}`}>
            <span className={`${bloom} group-hover:bg-[#1d9bf0]/10`}>
              <span data-pop="save" className="block">
                <Icon name="bookmark" size={18} fill={v.saved} />
              </span>
            </span>
          </button>
          <button type="button" aria-label="Share" className={`${actionClass} hover:text-[#1d9bf0]`}>
            <span className={`${bloom} group-hover:bg-[#1d9bf0]/10`}>
              <Icon name="share" size={18} />
            </span>
          </button>
        </div>
      </div>
    </div>
  );
}

function NewsFeed(v: View) {
  const action = "flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg py-2 text-[14px] font-semibold text-[var(--sc-muted)] transition-colors hover:bg-[var(--sc-hover)] focus-visible:outline-2";
  return (
    <>
      <header data-in className="flex items-center gap-2.5 px-4 pt-3.5 pb-2.5">
        <Avatar author={v.author} size={42} />
        <div className="min-w-0 flex-1 leading-tight">
          <p className="flex items-center gap-1 text-[15px] font-semibold">
            <span className="truncate">{v.author.name}</span>
            {v.author.verified && <Seal color={v.badge} size={15} />}
          </p>
          <p className="mt-0.5 flex items-center gap-1 text-[13px] text-[var(--sc-muted)]">
            {v.timestamp}
            <span>·</span>
            <Icon name="globe" size={12} />
          </p>
        </div>
        <IconButton label="More options" className="-mr-1.5 text-[var(--sc-muted)]">
          <Icon name="more" size={20} />
        </IconButton>
      </header>

      {v.content && (
        <p data-in className="px-4 pb-3 text-[15px] leading-[1.4] break-words whitespace-pre-wrap">
          <RichText text={v.content} color={v.badge} />
        </p>
      )}

      {v.image && (
        <div data-in className="overflow-hidden bg-[var(--sc-line)]" style={{ aspectRatio: v.ratio }}>
          <img src={v.image} alt={v.imageAlt} className="block h-full w-full object-cover" draggable={false} decoding="async" />
        </div>
      )}

      <div data-in className="flex items-center gap-1.5 px-4 pt-2.5 pb-2.5 text-[14px] text-[var(--sc-muted)]">
        <span className="grid h-[18px] w-[18px] place-items-center rounded-full text-white" style={{ background: v.badge }}>
          <Icon name="thumb" size={11} fill />
        </span>
        <span>{compact.format(v.likes)}</span>
        <span className="ml-auto">
          {compact.format(v.comments)} comments · {compact.format(v.shares)} shares
        </span>
      </div>

      <div data-in className="mx-4 mb-1 flex gap-1 border-t border-[var(--sc-line)] py-1">
        <button type="button" onClick={v.toggleLike} aria-pressed={v.liked} aria-label={v.liked ? "Unlike" : "Like"} className={action} style={v.liked ? { color: v.badge } : undefined}>
          <span data-pop="like" className="relative block">
            <Dots />
            <Icon name="thumb" size={20} fill={v.liked} />
          </span>
          Like
        </button>
        <button type="button" aria-label="Comment" className={action}>
          <Icon name="comment" size={20} />
          Comment
        </button>
        <button type="button" aria-label="Share" className={action}>
          <Icon name="send" size={20} />
          Share
        </button>
      </div>
      <div className="h-1.5" />
    </>
  );
}

// ---------------------------------------------------------------------------------------------
// Small parts
// ---------------------------------------------------------------------------------------------

function LikeButton({ v, size, className = "" }: { v: View; size: number; className?: string }) {
  return (
    <button type="button" onClick={v.toggleLike} aria-pressed={v.liked} aria-label={v.liked ? "Unlike" : "Like"} className={`relative cursor-pointer rounded-md transition-opacity hover:opacity-60 focus-visible:outline-2 focus-visible:outline-offset-1 ${className}`}>
      <Dots />
      <span data-pop="like" className="block" style={v.liked ? { color: "var(--sc-pop)" } : undefined}>
        <Icon name="heart" size={size} fill={v.liked} />
      </span>
    </button>
  );
}

/** Eight dots in the middle of a button. The like pop flies them outwards. */
function Dots() {
  return (
    <span aria-hidden="true" className="pointer-events-none absolute top-1/2 left-1/2">
      {Array.from({ length: 8 }, (_, i) => (
        <span key={i} data-dot className="absolute -mt-[2px] -ml-[2px] h-1 w-1 rounded-full bg-[var(--sc-pop)] opacity-0" />
      ))}
    </span>
  );
}

function IconButton({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
  return (
    <button type="button" aria-label={label} className={`grid cursor-pointer place-items-center rounded-full p-1.5 transition-colors hover:bg-[var(--sc-hover)] focus-visible:outline-2 ${className}`}>
      {children}
    </button>
  );
}

function Avatar({ author, size }: { author: SocialCardAuthor; size: number }) {
  const box = { width: size, height: size } as const;
  if (author.avatar) return <img src={author.avatar} alt={author.name} style={box} className="block shrink-0 rounded-full bg-[var(--sc-line)] object-cover" draggable={false} decoding="async" />;
  const initials = author.name
    .split(/\s+/)
    .map((word) => word[0] ?? "")
    .join("")
    .slice(0, 2)
    .toUpperCase();
  return (
    <span aria-label={author.name} role="img" style={{ ...box, fontSize: size * 0.38 }} className="grid shrink-0 place-items-center rounded-full bg-[var(--sc-hover)] font-semibold text-[var(--sc-muted)]">
      {initials}
    </span>
  );
}

/** The verified badge: a plain scalloped seal with a tick, drawn from two rounded squares */
function Seal({ color, size }: { color: string; size: number }) {
  return (
    <svg role="img" aria-label="Verified" viewBox="0 0 24 24" width={size} height={size} className="shrink-0">
      <rect x="4" y="4" width="16" height="16" rx="4.5" fill={color} />
      <rect x="4" y="4" width="16" height="16" rx="4.5" fill={color} transform="rotate(45 12 12)" />
      <path d="M8.2 12.4l2.5 2.5 5.1-5.4" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/** Text with #hashtags and @mentions picked out in the look's accent colour */
function RichText({ text, color }: { text: string; color: string }) {
  return (
    <>
      {text.split(/([#@][\p{L}\p{N}_.]*[\p{L}\p{N}_])/u).map((part, i) =>
        /^[#@]/.test(part) ? (
          <span key={i} style={{ color }}>
            {part}
          </span>
        ) : (
          part
        ),
      )}
    </>
  );
}

const heartPath = "M12 21s-7.6-4.7-9.4-9.4C1.4 8.4 3.2 5 6.7 5c2.1 0 3.6 1.1 5.3 3.3C13.7 6.1 15.2 5 17.3 5c3.5 0 5.3 3.4 4.1 6.6C19.6 16.3 12 21 12 21z";

const paths = {
  heart: heartPath,
  comment: "M21 11.5a8.5 8.5 0 0 1-12.4 7.6L3 20.6l1.5-5.1A8.5 8.5 0 1 1 21 11.5z",
  send: "M21.5 3 10.6 13.9M21.5 3l-6.8 18-4.1-7.1L3.5 9.8z",
  bookmark: "M6.5 3.5h11v17.2L12 16.5l-5.5 4.2z",
  repost: "M17 3l4 4-4 4M3 11V9.5A2.5 2.5 0 0 1 5.5 7H21M7 21l-4-4 4-4M21 13v1.5a2.5 2.5 0 0 1-2.5 2.5H3",
  share: "M12 15V3.5M7.5 8 12 3.5 16.5 8M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6",
  thumb: "M7.5 10.5V21H3.5V10.5zM7.5 10.5 11.4 3c1.6 0 2.6 1.3 2.3 2.8L13.1 9H19a2 2 0 0 1 2 2.4l-1.4 7.2a2 2 0 0 1-2 1.6H7.5",
} as const;

type IconName = keyof typeof paths | "more" | "globe";

function Icon({ name, size, fill = false }: { name: IconName; size: number; fill?: boolean }) {
  const common = { width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": true as const, className: "block" };
  if (name === "more") {
    return (
      <svg {...common} fill="currentColor">
        <circle cx="5" cy="12" r="1.8" />
        <circle cx="12" cy="12" r="1.8" />
        <circle cx="19" cy="12" r="1.8" />
      </svg>
    );
  }
  if (name === "globe") {
    return (
      <svg {...common} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
        <circle cx="12" cy="12" r="9" />
        <path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
      </svg>
    );
  }
  return (
    <svg {...common} fill={fill ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
      <path d={paths[name]} />
    </svg>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { SocialCard, type SocialCardProps, type SocialCardVariant } from "@/components/dimaac/social-card";

// A made-up author with one plain first name; photos from the media set (Lummi License)
const author = { name: "Mira", handle: "mira.photo", avatar: "https://ui.dimaac.com/media/portraits/calm-studio-4x5.jpg", verified: true };

const looks: { id: SocialCardVariant; label: string }[] = [
  { id: "instagram", label: "Photo feed" },
  { id: "x", label: "Timeline" },
  { id: "facebook", label: "News feed" },
];

// How wide each look may be so the whole card fits the stage's height: width follows the room left
// under the tabs, up to the look's own limit
const fit: Record<SocialCardVariant, string> = {
  instagram: "clamp(280px, calc((100dvh - 290px) * 0.8), 380px)",
  x: "560px",
  facebook: "clamp(300px, calc((100dvh - 300px) * 1.6), 560px)",
};

export default function Demo(props: Partial<SocialCardProps>) {
  // The tabs below the stage title switch the look; the playground's "Look" does the same
  const [variant, setVariant] = useState<SocialCardVariant>(props.variant ?? "instagram");
  useEffect(() => {
    if (props.variant) setVariant(props.variant);
  }, [props.variant]);

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#ebe8e1] text-[#1c1917] dark:bg-[#0b0c0e] dark:text-[#e7e5e4]">
      {/* Soft colour behind the card so the glass-white and near-black surfaces both read */}
      <div aria-hidden="true" className="pointer-events-none absolute -top-32 -left-24 h-[460px] w-[460px] rounded-full bg-[#ffc9a8] opacity-70 blur-[110px] dark:bg-[#3b2d8f] dark:opacity-45" />
      <div aria-hidden="true" className="pointer-events-none absolute -right-24 -bottom-40 h-[520px] w-[520px] rounded-full bg-[#bfd8ff] opacity-80 blur-[120px] dark:bg-[#0f6b63] dark:opacity-35" />

      <div className="relative z-10 flex h-full flex-col overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
        <nav aria-label="Look" className="flex shrink-0 justify-center gap-6 px-4 pt-6 font-mono text-[11px] tracking-wide uppercase">
          {looks.map((look) => (
            <button
              key={look.id}
              type="button"
              aria-pressed={variant === look.id}
              onClick={() => setVariant(look.id)}
              className={`cursor-pointer border-b pb-1 transition-opacity ${variant === look.id ? "border-current opacity-90" : "border-transparent opacity-45 hover:opacity-80"}`}
            >
              {look.label}
            </button>
          ))}
        </nav>

        <div className="flex flex-1 items-center justify-center px-4 py-6">
          <div className="w-full" style={{ maxWidth: fit[variant] }}>
            <SocialCard
              author={author}
              content="Found this corner at golden hour and could not leave. Some buildings are just light waiting for a reason. #architecture #slowmornings"
              image="https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg"
              imageAlt="Yellow and beige walls under a clear blue sky"
              likes={12480}
              comments={342}
              shares={1280}
              timestamp="2h"
              {...props}
              variant={variant}
            />
          </div>
        </div>

        <p className="shrink-0 pb-5 text-center font-mono text-[11px] tracking-wide uppercase opacity-50">Tap the heart or double-click the photo</p>
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Social Card component (<SocialCard />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/social-card
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/social-card.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/social-card.json

Then use it like this:
import { SocialCard } from "@/components/dimaac/social-card";
<SocialCard />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/social-card.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Spotlight Cards
description: A grid of cards lit by one soft light that follows the cursor.
category: Cards
tier: free
url: https://ui.dimaac.com/components/spotlight-cards
registry: https://ui.dimaac.com/r/spotlight-cards.json
---

# Spotlight Cards

A grid of cards where a single soft spotlight follows the pointer across the whole grid, lighting each card's border and surface, while the card under the cursor tilts gently toward it. One pointer listener on the grid writes the light's position onto the cards as CSS variables, so there are no per-card handlers and no animation library. It glides to the pointer, follows a finger on touch screens, works on light and dark backgrounds (the colour is yours), drops to fewer columns on phones, and keeps the tilt off when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/spotlight-cards`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/spotlight-cards.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { SpotlightCards } from "@/components/dimaac/spotlight-cards" and give its parent a size: it fills it.

## Usage

```tsx
import { SpotlightCards } from "@/components/dimaac/spotlight-cards";

<SpotlightCards cards={cards} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cards` (required) | `SpotlightCard[]` | - | The cards, in reading order. |
| `spotlightSize` | `number` | `380` | How far the light reaches from the cursor, in pixels. |
| `intensity` | `number` | `0.6` | How bright the light is, from 0 (off) to 1 (strong). |
| `tilt` | `number` | `6` | The most a card tilts toward the cursor, in degrees. 0 turns the tilt off. |
| `color` | `string` | `"#14b8a6"` | The colour of the light. Any CSS colour. |
| `columns` | `number` | `3` | How many columns on a wide screen. Narrow screens use fewer, see minCardWidth. |
| `gap` | `number` | `16` | Space between the cards, in pixels. |
| `minCardWidth` | `number` | `160` | A card never gets narrower than this, in pixels, so phones drop to fewer columns. |
| `className` | `string` | `""` | Extra classes for the grid. |
| `cardClassName` | `string` | `""` | Extra classes for each card, e.g. a different radius or padding. |

## Credits

- Photo: Mikiwa, Lummi (https://www.lummi.ai/photo/modern-building-corner-dr5dz), Lummi License
- Photo: Mariate, Lummi (https://www.lummi.ai/photo/minimalist-architectural-design-b8bbc), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License

## Source

### spotlight-cards.tsx → `components/dimaac/spotlight-cards.tsx`

```tsx
"use client";

import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";

// A grid of cards with one soft spotlight that follows the cursor across the whole grid.
// It lights each card's border and surface, and the card under the cursor tilts a little.
// Only the grid listens to the pointer. It writes the spotlight position onto every card as two
// CSS variables, and the card styles do the rest. No library needed.

export type SpotlightCard = {
  /** The card's heading. */
  title: string;
  /** One or two sentences under the heading. */
  body: string;
  /** A small icon shown in a box above the heading, e.g. an inline SVG. */
  icon?: ReactNode;
  /** A picture across the top of the card. */
  image?: string;
  /** Alt text for the picture. Leave empty if it is only decoration. */
  imageAlt?: string;
};

export type SpotlightCardsProps = {
  /** The cards, in reading order. */
  cards: SpotlightCard[];
  /** How far the light reaches from the cursor, in pixels. */
  spotlightSize?: number;
  /** How bright the light is, from 0 (off) to 1 (strong). */
  intensity?: number;
  /** The most a card tilts toward the cursor, in degrees. 0 turns the tilt off. */
  tilt?: number;
  /** The colour of the light. Any CSS colour. */
  color?: string;
  /** How many columns on a wide screen. Narrow screens use fewer, see minCardWidth. */
  columns?: number;
  /** Space between the cards, in pixels. */
  gap?: number;
  /** A card never gets narrower than this, in pixels, so phones drop to fewer columns. */
  minCardWidth?: number;
  /** Extra classes for the grid. */
  className?: string;
  /** Extra classes for each card, e.g. a different radius or padding. */
  cardClassName?: string;
};

// The ring is a 1px frame cut out of a filled box with a mask (the middle is subtracted)
const ringMask: CSSProperties = {
  padding: 1,
  mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
  maskComposite: "exclude",
  WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
  WebkitMaskComposite: "xor",
};

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

export function SpotlightCards({
  cards,
  spotlightSize = 380,
  intensity = 0.6,
  tilt = 6,
  color = "#14b8a6",
  columns = 3,
  gap = 16,
  minCardWidth = 160,
  className = "",
  cardClassName = "",
}: SpotlightCardsProps) {
  const gridRef = useRef<HTMLUListElement>(null);
  // Read when the pointer moves, so changing the tilt never rebuilds the listeners
  const tiltRef = useRef(tilt);
  tiltRef.current = tilt;

  useEffect(() => {
    const grid = gridRef.current;
    if (!grid) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const target = { x: 0, y: 0 };
    const light = { x: 0, y: 0 };
    let frame = 0;
    let lastTime = 0;
    let inside = false;

    const items = () => Array.from(grid.children) as HTMLElement[];

    // Give every card the light's position measured from its own top-left corner
    const paintLight = () => {
      for (const card of items()) {
        card.style.setProperty("--x", `${light.x - card.offsetLeft}px`);
        card.style.setProperty("--y", `${light.y - card.offsetTop}px`);
      }
    };

    // Only the card under the pointer tilts. The pointer is turned into -1..1 across that card.
    const paintTilt = () => {
      const max = reduced ? 0 : tiltRef.current;
      for (const card of items()) {
        const px = target.x - card.offsetLeft;
        const py = target.y - card.offsetTop;
        const over = inside && px >= 0 && py >= 0 && px <= card.offsetWidth && py <= card.offsetHeight;
        const across = over ? clamp((px / card.offsetWidth) * 2 - 1, -1, 1) : 0;
        const down = over ? clamp((py / card.offsetHeight) * 2 - 1, -1, 1) : 0;
        card.style.setProperty("--ry", `${across * max}deg`);
        card.style.setProperty("--rx", `${-down * max}deg`);
        card.style.setProperty("--lift", over && max > 0 ? "1.012" : "1");
      }
    };

    // The light eases toward the pointer, so it glides instead of snapping
    const step = (now: number) => {
      const seconds = Math.min(0.05, (now - lastTime) / 1000);
      lastTime = now;
      const ease = 1 - Math.exp(-seconds * 18);
      light.x += (target.x - light.x) * ease;
      light.y += (target.y - light.y) * ease;
      paintLight();
      const close = Math.hypot(target.x - light.x, target.y - light.y) < 0.3;
      frame = close ? 0 : requestAnimationFrame(step);
    };

    const follow = (event: PointerEvent, snap: boolean) => {
      const rect = grid.getBoundingClientRect();
      target.x = event.clientX - rect.left;
      target.y = event.clientY - rect.top;
      if (snap || reduced) {
        light.x = target.x;
        light.y = target.y;
        paintLight();
      } else if (!frame) {
        lastTime = performance.now();
        frame = requestAnimationFrame(step);
      }
      paintTilt();
    };

    const turnOn = (event: PointerEvent) => {
      if (!event.isPrimary) return;
      // A fresh entry starts the light under the pointer instead of sliding in from the last spot
      const fresh = !inside;
      inside = true;
      grid.style.setProperty("--spot", "1");
      follow(event, fresh);
    };

    const turnOff = () => {
      inside = false;
      grid.style.setProperty("--spot", "0");
      paintTilt();
    };

    grid.addEventListener("pointermove", turnOn);
    grid.addEventListener("pointerdown", turnOn);
    grid.addEventListener("pointerleave", turnOff);
    grid.addEventListener("pointercancel", turnOff);

    return () => {
      grid.removeEventListener("pointermove", turnOn);
      grid.removeEventListener("pointerdown", turnOn);
      grid.removeEventListener("pointerleave", turnOff);
      grid.removeEventListener("pointercancel", turnOff);
      if (frame) cancelAnimationFrame(frame);
    };
  }, []);

  const glowAlpha = clamp(intensity, 0, 1);
  const mix = (percent: number) => `color-mix(in srgb, ${color} ${Math.round(percent * 100)}%, transparent)`;
  const light = (percent: number) => `radial-gradient(${spotlightSize}px circle at var(--x, -999px) var(--y, -999px), ${mix(percent)}, transparent)`;
  const columnCount = Math.max(1, Math.round(columns));
  // As many columns as fit, up to `columns`, and never narrower than minCardWidth
  const cardWidth = `max(${minCardWidth}px, calc((100% - ${(columnCount - 1) * gap}px) / ${columnCount}))`;

  return (
    <ul
      ref={gridRef}
      className={`relative m-0 grid w-full list-none p-0 ${className}`}
      style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${cardWidth}, 1fr))`, gap, touchAction: "pan-y", ["--spot" as string]: 0 }}
    >
      {cards.map((card, index) => (
        <li
          key={`${index}-${card.title}`}
          className={`relative isolate flex flex-col overflow-hidden rounded-2xl ${cardClassName}`}
          style={{
            background: "color-mix(in srgb, currentColor 3.5%, transparent)",
            transform: "perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--lift, 1))",
            transition: "transform 380ms cubic-bezier(0.2, 0.8, 0.2, 1)",
            willChange: "transform",
          }}
        >
          {/* The always-on hairline border */}
          <span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ ...ringMask, background: "color-mix(in srgb, currentColor 11%, transparent)" }} />

          {card.image ? (
            <div className="aspect-[5/2] w-full overflow-hidden">
              <img src={card.image} alt={card.imageAlt ?? ""} className="block h-full w-full object-cover" draggable={false} loading="lazy" decoding="async" />
            </div>
          ) : null}

          <div className="flex flex-1 flex-col gap-3 p-5 sm:p-6">
            {card.icon ? (
              <span
                aria-hidden
                className="grid h-10 w-10 shrink-0 place-items-center rounded-xl [&>svg]:h-5 [&>svg]:w-5"
                style={{ background: "color-mix(in srgb, currentColor 6%, transparent)", boxShadow: "inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent)" }}
              >
                {card.icon}
              </span>
            ) : null}
            <h3 className="m-0 text-[17px] leading-tight font-medium tracking-[-0.01em]">{card.title}</h3>
            <p className="m-0 text-[14px] leading-[1.55] opacity-65">{card.body}</p>
          </div>

          {/* The light on the surface, then the light on the border. Both fade with --spot. */}
          <span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ background: light(glowAlpha * 0.34), opacity: "var(--spot)", transition: "opacity 450ms ease" }} />
          <span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ ...ringMask, background: light(Math.min(1, glowAlpha * 1.35)), opacity: "var(--spot)", transition: "opacity 450ms ease" }} />
        </li>
      ))}
    </ul>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import type { ReactNode } from "react";
import { SpotlightCards, type SpotlightCard, type SpotlightCardsProps } from "@/components/dimaac/spotlight-cards";

// A product page for a made-up design tool. Photos: Lummi (Lummi License).

// Small line icons, drawn inline so the demo needs no icon package
const Icon = ({ children }: { children: ReactNode }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
    {children}
  </svg>
);

const cards: SpotlightCard[] = [
  {
    title: "Briefs that write themselves",
    body: "Drop in a call transcript and get a one-page brief, with scope, dates and open questions already pulled out.",
    icon: (
      <Icon>
        <path d="M6 3h9l4 4v14H6z" />
        <path d="M14 3v5h5M9 13h7M9 17h5" />
      </Icon>
    ),
  },
  {
    title: "Moodboards, kept honest",
    body: "Every image remembers where it came from and who owns it, so nothing unlicensed reaches the client.",
    image: "https://ui.dimaac.com/media/architecture/white-corner-16x9.jpg",
    imageAlt: "A white building corner with a teal window against a teal sky",
  },
  {
    title: "Review rounds in one thread",
    body: "Comments sit on the exact frame, and every change is checked against the last round.",
    icon: (
      <Icon>
        <path d="M4 5h16v11H9l-5 4z" />
        <path d="M8 9.5h8M8 12.5h5" />
      </Icon>
    ),
  },
  {
    title: "Handoff without the folder dance",
    body: "Files, fonts and notes leave as one tidy package, named the way the developer expects.",
    image: "https://ui.dimaac.com/media/architecture/concrete-balconies-3x2.jpg",
    imageAlt: "Rows of concrete balconies against a pale sky",
  },
  {
    title: "Invoices that follow the work",
    body: "Hours and milestones become an invoice the moment a stage is signed off. Nothing to remember.",
    icon: (
      <Icon>
        <rect x="3" y="6" width="18" height="12" rx="2" />
        <circle cx="12" cy="12" r="2.5" />
        <path d="M6.5 9.5v.01M17.5 14.5v.01" />
      </Icon>
    ),
  },
  {
    title: "An archive you can search",
    body: "Find the sketch from two summers ago by what was in it, not what someone called the file.",
    image: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg",
    imageAlt: "Yellow and beige walls under a clear blue sky",
  },
];

export default function Demo(props: Partial<SpotlightCardsProps>) {
  return (
    <div className="relative h-full w-full overflow-x-hidden overflow-y-auto bg-[#f4f3ef] text-[#17201f] dark:bg-[#080b0b] dark:text-[#e6eeec]">
      {/* A faint grid behind everything gives the light something to land on */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-[0.07]"
        style={{
          backgroundImage: "linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
          backgroundSize: "56px 56px",
          maskImage: "radial-gradient(ellipse at 50% 30%, #000 20%, transparent 75%)",
          WebkitMaskImage: "radial-gradient(ellipse at 50% 30%, #000 20%, transparent 75%)",
        }}
      />

      <div className="relative mx-auto flex min-h-full w-full max-w-[1120px] flex-col justify-center px-5 py-8 sm:px-8 sm:py-10">
        <header className="mb-6 flex max-w-[640px] flex-col gap-3 sm:mb-9 sm:gap-3">
          <h1 className="m-0 text-[clamp(30px,4.6vw,54px)] leading-[0.98] font-medium tracking-[-0.04em]">Everything a studio needs, lit as you look.</h1>
          <p className="m-0 max-w-[46ch] text-[15px] leading-[1.55] opacity-60">Six small tools that replace six open tabs. Move across them.</p>
        </header>

        <SpotlightCards cards={cards} {...props} />
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Spotlight Cards component (<SpotlightCards />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/spotlight-cards
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/spotlight-cards.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/spotlight-cards.json

Then use it like this:
import { SpotlightCards } from "@/components/dimaac/spotlight-cards";
<SpotlightCards cards={cards} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/spotlight-cards.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Stack Fly-Out Cards
description: Cards rise into a peeking stack, then fly off one by one as you scroll.
category: Scroll
tier: free
url: https://ui.dimaac.com/components/stack-fly-cards
registry: https://ui.dimaac.com/r/stack-fly-cards.json
---

# Stack Fly-Out Cards

A pinned section for services, steps or case studies. As you scroll in, the cards rise from below the fold into a stack where each one peeks out under the one in front. Keep scrolling and the front card flies off the top with a turn while the rest move up a place, until only the last card is left. It's one scrubbed GSAP ScrollTrigger timeline, so scrolling back plays it in reverse. It works with the page or any scrolling element, rebuilds itself on resize, scrolls a card to the front when you tab to its button, and becomes a plain column of cards when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/stack-fly-cards`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/stack-fly-cards.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { StackFlyCards } from "@/components/dimaac/stack-fly-cards" and give its parent a size: it fills it.

## Usage

```tsx
import { StackFlyCards } from "@/components/dimaac/stack-fly-cards";

<StackFlyCards cards={cards} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cards` (required) | `StackFlyCard[]` | - | The cards, front card first. Three to six read best. |
| `peek` | `number` | `42` | How far each card in the stack peeks out under the one in front, in pixels. |
| `scaleStep` | `number` | `0.045` | How much smaller each card in the stack is than the one in front (0.05 is 5%). |
| `flyRotate` | `number` | `25` | How far a card turns as it flies off, in degrees. |
| `scrollLength` | `number` | `1` | How long the pinned section lasts: screens of scrolling per card. |
| `smoothing` | `number` | `0.6` | How long the cards take to catch up with the scrollbar, in seconds. 0 follows it exactly. |
| `scroller` | `HTMLElement \| string \| null` | `null` | The scrolling element (an element or a selector). Leave it out to use the page. |
| `background` | `string` | `"#121212"` | The section's background. The cards' shadows look best on a dark one. |
| `accent` | `string` | `"#d6ef4a"` | The colour of the square behind each button's arrow. |
| `titleFont` | `string` | `'"Instrument Serif", "Times New Roman", Times, serif'` | The font of the card titles and numbers. Load it yourself (the demo loads Instrument Serif). |
| `children` | `ReactNode` | - | Content above the deck, like a heading. It sits over the cards as they fly past. |
| `headerClassName` | `string` | `""` | Extra classes for the heading's box, e.g. "mix-blend-difference" so it inverts over the cards. |
| `className` | `string` | `""` | Extra classes for the section. It's one screen tall and pins while the cards move. |

## Credits

- Portrait (lime blazer): Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Fashion photo (grey suit): Axel Photography, Lummi (https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn), Lummi License
- Interior: SHIHO, Lummi (https://www.lummi.ai/photo/minimalist-rustic-interior-qppyl), Lummi License
- Fashion portrait (orange visor): West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Architecture (pastel rooftop): Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License

## Source

### stack-fly-cards.tsx → `components/dimaac/stack-fly-cards.tsx`

```tsx
"use client";

import { useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// A deck of cards in a pinned section. As you scroll, the cards rise into a stack where each one
// peeks out under the one in front, then the front card flies off the top, the rest move up a
// place, and so on until only the last card is left. One scrubbed timeline does all of it, so
// scrolling back plays it in reverse.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger);

export type StackFlyCard = {
  /** The card's photo. */
  src: string;
  /** Describes the photo for screen readers. Empty means it's decoration. */
  alt?: string;
  /** The big serif title. */
  title: string;
  /** A short line under the title. */
  lede?: string;
  /** The paragraph near the bottom of the card. */
  body: string;
  /** Where the card's button goes. No href, no button. */
  href?: string;
  /** The button's label. */
  cta?: string;
  /** The card's colour. Defaults to a palette of five soft tones. */
  background?: string;
  /** The card's text colour. */
  foreground?: string;
};

export type StackFlyCardsProps = {
  /** The cards, front card first. Three to six read best. */
  cards: StackFlyCard[];
  /** How far each card in the stack peeks out under the one in front, in pixels. */
  peek?: number;
  /** How much smaller each card in the stack is than the one in front (0.05 is 5%). */
  scaleStep?: number;
  /** How far a card turns as it flies off, in degrees. */
  flyRotate?: number;
  /** How long the pinned section lasts: screens of scrolling per card. */
  scrollLength?: number;
  /** How long the cards take to catch up with the scrollbar, in seconds. 0 follows it exactly. */
  smoothing?: number;
  /** The scrolling element (an element or a selector). Leave it out to use the page. */
  scroller?: HTMLElement | string | null;
  /** The section's background. The cards' shadows look best on a dark one. */
  background?: string;
  /** The colour of the square behind each button's arrow. */
  accent?: string;
  /** The font of the card titles and numbers. Load it yourself (the demo loads Instrument Serif). */
  titleFont?: string;
  /** Content above the deck, like a heading. It sits over the cards as they fly past. */
  children?: ReactNode;
  /** Extra classes for the heading's box, e.g. "mix-blend-difference" so it inverts over the cards. */
  headerClassName?: string;
  /** Extra classes for the section. It's one screen tall and pins while the cards move. */
  className?: string;
};

// The five tones from the original video, used in turn when a card has no background of its own
const tones = ["#d6ef4a", "#f4f4f2", "#6db5a8", "#e8d5c4", "#c8b8e8"];

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

function resolveScroller(scroller: StackFlyCardsProps["scroller"]): HTMLElement | null {
  if (!scroller) return null;
  if (typeof scroller === "string") {
    const element = document.querySelector<HTMLElement>(scroller);
    // Pinning against the page when a scroller was asked for looks broken in a confusing way, so say so
    if (!element && typeof process !== "undefined" && process.env.NODE_ENV !== "production") console.warn("StackFlyCards: scroller not found", scroller);
    return element;
  }
  return scroller;
}

export function StackFlyCards({
  cards,
  peek = 42,
  scaleStep = 0.045,
  flyRotate = 25,
  scrollLength = 1,
  smoothing = 0.6,
  scroller = null,
  background = "#121212",
  accent = "#d6ef4a",
  titleFont = '"Instrument Serif", "Times New Roman", Times, serif',
  children,
  headerClassName = "",
  className = "",
}: StackFlyCardsProps) {
  const sectionRef = useRef<HTMLElement>(null);
  const cardRefs = useRef<(HTMLElement | null)[]>([]);
  // Where the scroller was, so rebuilding the timeline (a control changed) doesn't jump the page
  const lastScroll = useRef(0);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);

  useGSAP(
    () => {
      const section = sectionRef.current;
      const deck = cardRefs.current.slice(0, cards.length).filter((card): card is HTMLElement => card !== null);
      if (!section || deck.length === 0 || reducedMotion) return;

      const scrollerElement = resolveScroller(scroller);
      const viewportHeight = () => (scrollerElement ? scrollerElement.clientHeight : window.innerHeight);
      // Where card i sits in the stack: lower down and a little smaller for every card in front of it
      const pose = (i: number) => ({ y: i * peek, scale: 1 - i * scaleStep });

      // In a scrolling element the section is as tall as that element, not the window
      const fitHeight = () => {
        if (scrollerElement) section.style.minHeight = `${scrollerElement.clientHeight}px`;
      };
      fitHeight();
      ScrollTrigger.addEventListener("refreshInit", fitHeight);

      // Scaling from the top edge keeps each card's top where the peek puts it
      gsap.set(deck, { zIndex: (i: number) => deck.length - i, transformOrigin: "50% 0%", force3D: true });

      const timeline = gsap.timeline({
        scrollTrigger: {
          trigger: section,
          scroller: scrollerElement ?? undefined,
          start: "top top",
          end: () => `+=${deck.length * viewportHeight() * scrollLength}`,
          pin: true,
          scrub: smoothing > 0 ? smoothing : true,
          invalidateOnRefresh: true,
        },
      });

      // 1. The cards rise from below the fold into the stack, one a moment after another
      deck.forEach((card, i) => {
        timeline.fromTo(
          card,
          { y: () => viewportHeight() * 0.72 + i * peek, scale: pose(i).scale * 0.9, rotation: 0 },
          { ...pose(i), ease: "power3.out", duration: 1.35 },
          i * 0.06,
        );
      });

      // A short rest with the whole stack in view
      timeline.to({}, { duration: 0.35 });
      const flyAt = timeline.duration();

      // 2. The front card flies off the top and everything behind it moves up a place.
      //    The last card stays: it's where the section ends.
      deck.slice(0, -1).forEach((card, i) => {
        const behind = deck.slice(i + 1);
        timeline.to(card, { y: () => -viewportHeight() * 1.15, rotation: -flyRotate, scale: 0.94, ease: "none", duration: 1 }, flyAt + i);
        timeline.to(behind, { y: (index: number) => pose(index).y, scale: (index: number) => pose(index).scale, ease: "none", duration: 1 }, flyAt + i);
      });

      // A short rest on the last card before the section scrolls away
      timeline.to({}, { duration: 0.4 });

      const trigger = timeline.scrollTrigger;
      if (!trigger) return;

      // On a rebuild (a prop changed), taking the old pin out made the page shorter for a moment
      // and the browser pulled the scroll back. Measure the new pin now and put the scroll back.
      if (lastScroll.current > 0) {
        trigger.refresh();
        if (Math.abs(trigger.scroll() - lastScroll.current) > 1) trigger.scroll(lastScroll.current);
      }

      const scrollTarget: HTMLElement | Window = scrollerElement ?? window;
      const onScroll = () => {
        lastScroll.current = trigger.scroll();
      };
      scrollTarget.addEventListener("scroll", onScroll, { passive: true });

      // Tabbing to a button on a card that's behind (or already gone) scrolls to where it's in front
      const onFocusIn = (event: FocusEvent) => {
        const index = deck.findIndex((card) => card.contains(event.target as Node));
        if (index < 0) return;
        const progress = Math.min(1, (flyAt + index) / timeline.duration());
        trigger.scroll(trigger.start + (trigger.end - trigger.start) * progress);
      };
      section.addEventListener("focusin", onFocusIn);

      return () => {
        ScrollTrigger.removeEventListener("refreshInit", fitHeight);
        scrollTarget.removeEventListener("scroll", onScroll);
        section.removeEventListener("focusin", onFocusIn);
        section.style.minHeight = "";
      };
    },
    { scope: sectionRef, dependencies: [cards.length, peek, scaleStep, flyRotate, scrollLength, smoothing, scroller, reducedMotion], revertOnUpdate: true },
  );

  return (
    <section
      ref={sectionRef}
      className={`relative flex w-full flex-col items-center overflow-hidden px-5 pt-[clamp(1.75rem,4vh,2.75rem)] pb-[clamp(2rem,5vh,3.5rem)] ${reducedMotion ? "" : "min-h-[100dvh]"} ${className}`}
      style={{ background }}
    >
      {children ? <div className={`pointer-events-none relative z-20 mt-8 mb-[clamp(1.5rem,5vh,3.5rem)] shrink-0 text-center ${headerClassName}`}>{children}</div> : null}

      <div className="relative my-auto w-full max-w-[1100px]">
        {/* Reduced motion: no pin, no flight. The cards are a plain column you scroll past. */}
        <div className={reducedMotion ? "flex flex-col gap-5" : "relative h-[min(68vh,580px)] md:h-[min(62vh,540px)]"}>
          {cards.map((card, index) => (
            <article
              key={`${index}-${card.title}`}
              ref={(element) => {
                cardRefs.current[index] = element;
              }}
              className={`${reducedMotion ? "relative h-[min(80vh,580px)] md:h-[min(62vh,540px)]" : "absolute inset-0 will-change-transform"} flex flex-col gap-4 rounded-[3px] p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)] md:grid md:grid-cols-[1.15fr_0.85fr] md:gap-[clamp(1rem,3vw,2.5rem)] md:p-[clamp(1.25rem,3vw,2.25rem)]`}
              style={
                {
                  background: card.background ?? tones[index % tones.length],
                  color: card.foreground ?? "#141414",
                  // The button's focus ring uses the card's text colour, so it shows on light and dark cards alike
                  "--card-fg": card.foreground ?? "#141414",
                  zIndex: reducedMotion ? undefined : cards.length - index,
                } as CSSProperties & Record<"--card-fg", string>
              }
            >
              <div className="order-2 flex min-h-0 min-w-0 flex-1 flex-col md:order-none">
                <div className="mb-[0.65rem] flex items-start justify-between gap-4">
                  <h3 className="text-[clamp(2rem,4.5vw,3.4rem)] leading-none font-normal tracking-[-0.02em]" style={{ fontFamily: titleFont }}>
                    {card.title}
                  </h3>
                  <span aria-hidden="true" className="shrink-0 text-[clamp(1.5rem,3vw,2.25rem)] leading-none opacity-35" style={{ fontFamily: titleFont }}>
                    {String(index + 1).padStart(2, "0")}
                  </span>
                </div>
                {card.lede ? <p className="max-w-[28ch] text-[clamp(0.85rem,1.4vw,1rem)] leading-[1.45] opacity-75">{card.lede}</p> : null}

                <div className="mt-auto pt-4">
                  <p className="max-w-[36ch] text-[clamp(0.8rem,1.2vw,0.92rem)] leading-[1.55] opacity-85">{card.body}</p>
                  {card.href ? (
                    <a
                      href={card.href}
                      className="mt-4 inline-flex items-center gap-3 bg-[#111] py-[0.55rem] pr-[0.55rem] pl-4 text-[0.85rem] text-white no-underline transition-transform duration-200 hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--card-fg)] md:mt-5"
                    >
                      {card.cta ?? "Discover our approach"}
                      <span aria-hidden="true" className="grid size-7 place-items-center text-[#111]" style={{ background: accent }}>
                        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6">
                          <path d="M2 7h10M8 3l4 4-4 4" />
                        </svg>
                      </span>
                    </a>
                  ) : null}
                </div>
              </div>

              <div className="order-1 flex h-[36%] min-h-[130px] shrink-0 justify-end md:order-none md:h-auto md:min-h-0">
                <div className="h-full w-full overflow-hidden bg-[#1a1a1a] shadow-[0_10px_28px_rgba(0,0,0,0.14)] md:w-[min(100%,260px)]">
                  <img src={card.src} alt={card.alt ?? ""} className="block h-full w-full object-cover" draggable={false} decoding="async" />
                </div>
              </div>
            </article>
          ))}
        </div>
      </div>
    </section>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { StackFlyCards, type StackFlyCard, type StackFlyCardsProps } from "@/components/dimaac/stack-fly-cards";

// A quiet intro, the pinned deck of five cards, and an outro to land on.
// Photos from Lummi (Lummi License), each picked to sit well on its card's colour.

const serif = '"Instrument Serif", "Times New Roman", Times, serif';

const photos: StackFlyCard[] = [
  {
    src: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg",
    alt: "A person with curly red hair in a lime green blazer against a bright pink background",
    title: "Lime blazer",
    body: "Curly red hair, a lime green blazer and a flat pink wall. The colours are loud and the pose is quiet.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/fashion/grey-suit-stride-4x5.jpg",
    alt: "A person in a grey suit and platform shoes mid-stride against a clean blue background",
    title: "Grey suit",
    body: "A grey suit, tall platform shoes and one long stride across a clean blue studio floor.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/architecture/rustic-interior-4x5.jpg",
    alt: "A calm rustic interior with textured beige walls and soft daylight",
    title: "Rustic interior",
    body: "Textured beige walls and soft daylight from one side. Nothing in the room asks for attention.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg",
    alt: "A woman in an orange visor and navy dress with yellow earrings against a teal background",
    title: "Orange visor",
    body: "An orange visor, a navy dress and yellow earrings against deep teal. Three colours and nothing else.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
  {
    src: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg",
    alt: "A purple wall and a pink building under a blue sky, with one small figure on the roof",
    title: "Pastel rooftop",
    body: "A purple wall, a pink building and one small figure on the roof under a pale blue sky.",
    href: "#stack-fly-cards-outro",
    cta: "Skip to the end",
  },
];

function Glow({ className }: { className: string }) {
  return <div aria-hidden="true" className={`pointer-events-none absolute rounded-full blur-[90px] ${className}`} />;
}

export default function Demo(props: Partial<StackFlyCardsProps>) {
  return (
    <div className="w-full bg-[#eceae4] text-[#141414] [--stack-bg:#121212] dark:bg-[#0c0c0b] dark:text-[#ecebe6] dark:[--stack-bg:#0c0c0b]" style={{ fontFamily: '"DM Sans", system-ui, sans-serif' }}>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500&family=Instrument+Serif:ital@0;1&display=swap" precedence="default" />

      <section className="relative grid min-h-[100dvh] place-items-center overflow-hidden px-6 text-center">
        <Glow className="top-[12%] left-[8%] h-[46vh] w-[52vw] bg-[#d6ef4a]/30 dark:bg-[#d6ef4a]/10" />
        <Glow className="right-[6%] bottom-[10%] h-[40vh] w-[42vw] bg-[#6db5a8]/30 dark:bg-[#6db5a8]/12" />
        <div className="relative max-w-[28rem]">
          <h1 className="mb-4 text-[clamp(2.75rem,7vw,4.5rem)] leading-[1.05] tracking-[-0.02em]" style={{ fontFamily: serif }}>
            Five cards, one stack
          </h1>
          <p className="text-[1.05rem] leading-[1.55] opacity-65">Scroll in and they fly out one by one.</p>
        </div>
        <p className="absolute bottom-8 left-1/2 -translate-x-1/2 text-[0.7rem] tracking-[0.18em] uppercase opacity-50">Scroll</p>
      </section>

      <StackFlyCards cards={photos} background="var(--stack-bg)" headerClassName="mix-blend-difference text-white" {...props} className={props.className ?? ""}>
        <h2 className="text-[clamp(1.75rem,3.5vw,2.5rem)] leading-none" style={{ fontFamily: serif }}>
          DiMaac
        </h2>
      </StackFlyCards>

      <section id="stack-fly-cards-outro" className="relative grid min-h-[100dvh] place-items-center overflow-hidden px-6 text-center">
        <Glow className="top-[10%] right-[10%] h-[44vh] w-[46vw] bg-[#c8b8e8]/40 dark:bg-[#c8b8e8]/12" />
        <Glow className="bottom-[8%] left-[6%] h-[40vh] w-[40vw] bg-[#e8d5c4]/50 dark:bg-[#e8d5c4]/10" />
        <div className="relative max-w-[28rem]">
          <h2 className="mb-4 text-[clamp(2.75rem,7vw,4.5rem)] leading-[1.05] tracking-[-0.02em]" style={{ fontFamily: serif }}>
            The end
          </h2>
          <p className="text-[1.05rem] leading-[1.55] opacity-65">That's the stack. Scroll back up to fly it again.</p>
        </div>
      </section>
    </div>
  );
}
```

## Agent prompt

```text
Add the Stack Fly-Out Cards component (<StackFlyCards />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/stack-fly-cards
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/stack-fly-cards.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/stack-fly-cards.json

Then use it like this:
import { StackFlyCards } from "@/components/dimaac/stack-fly-cards";
<StackFlyCards cards={cards} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/stack-fly-cards.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Stacked card slider
description: Click to cycle cards with GSAP Flip.
category: Cards
tier: free
url: https://ui.dimaac.com/components/stacked-card-slider
registry: https://ui.dimaac.com/r/stacked-card-slider.json
---

# Stacked card slider

A stack of photo cards laid out on a diagonal. Click it, press an arrow key or swipe, and the front card slides away to the bottom left while the cards behind step forward and a new card settles in at the back. The stepping is done with GSAP Flip, so it always starts exactly where the cards were. It is a real button (Enter, Space and the arrow keys work, Shift-click goes back), announces the current card to screen readers, shrinks to fit narrow screens and swaps instantly when the visitor prefers reduced motion. Ported from the first DiMaac UI and rebuilt with useGSAP, full cleanup and a previous direction.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/stacked-card-slider.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/stacked-card-slider.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/stacked-card-slider.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/stacked-card-slider`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/stacked-card-slider.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { StackedCardSlider } from "@/components/dimaac/stacked-card-slider" and give its parent a size: it fills it.

## Usage

```tsx
import { StackedCardSlider } from "@/components/dimaac/stacked-card-slider";

<StackedCardSlider cards={cards} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cards` (required) | `StackedCard[]` | - | The cards, front card first. Each click brings the next one to the front. |
| `cardWidth` | `number` | `300` | Width of a card, in pixels. It shrinks on its own if the box is narrower. |
| `offsetStep` | `number` | `20` | How far each card behind sticks out up and to the right, in pixels. |
| `aspectRatio` | `number` | `0.67` | Card width divided by card height. 0.67 is a 2:3 portrait. |
| `visibleCards` | `number` | `5` | How many cards show in the stack at once (the rest wait invisibly behind the last one). |
| `duration` | `number` | `0.5` | How long one step takes, in seconds. |
| `ease` | `string` | `"sine.inOut"` | The GSAP ease of the step, e.g. "sine.inOut" or "expo.inOut". |
| `radius` | `number` | `6` | Corner radius of the cards, in pixels. |
| `onChange` | `(index: number) => void` | - | Called with the index (in `cards`) of the new front card as every step starts. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills its parent); the stack sits in the middle of it. |
| `cardClassName` | `string` | `""` | Extra classes for each card image, e.g. a different shadow. |

## Credits

- Portrait (lime blazer): Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Portrait (neon profile): Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait (platinum blonde): studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait (red coat): Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Portrait (tinted glasses): SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Fashion portrait (orange visor): West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Portrait (face paint): Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License

## Source

### stacked-card-slider.tsx → `components/dimaac/stacked-card-slider.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { useGSAP } from "@gsap/react";

// A deck of photos stacked on a diagonal. Click it (or press an arrow key, or swipe) and the front
// card slides away while the rest step forward and a fresh card settles in at the back.
// GSAP Flip does the stepping: React moves the cards to their new spots, Flip then plays the
// move backwards-to-forwards, so the animation always starts exactly where the cards were.

if (typeof window !== "undefined") gsap.registerPlugin(Flip);

export type StackedCard = {
  /** Image address. */
  image: string;
  /** What the photo shows, for screen readers. */
  alt?: string;
};

export type StackedCardSliderProps = {
  /** The cards, front card first. Each click brings the next one to the front. */
  cards: StackedCard[];
  /** Width of a card, in pixels. It shrinks on its own if the box is narrower. */
  cardWidth?: number;
  /** How far each card behind sticks out up and to the right, in pixels. */
  offsetStep?: number;
  /** Card width divided by card height. 0.67 is a 2:3 portrait. */
  aspectRatio?: number;
  /** How many cards show in the stack at once (the rest wait invisibly behind the last one). */
  visibleCards?: number;
  /** How long one step takes, in seconds. */
  duration?: number;
  /** The GSAP ease of the step, e.g. "sine.inOut" or "expo.inOut". */
  ease?: string;
  /** Corner radius of the cards, in pixels. */
  radius?: number;
  /** Called with the index (in `cards`) of the new front card as every step starts. */
  onChange?: (index: number) => void;
  /** Extra classes for the outer box. Give it a size (it fills its parent); the stack sits in the middle of it. */
  className?: string;
  /** Extra classes for each card image, e.g. a different shadow. */
  cardClassName?: string;
};

type View = {
  /** Which card is at the front */
  front: number;
  /** The card that is flying away right now, drawn as a copy on top of everything */
  leaving: number | null;
};

type Pending = {
  /** Where the cards were before the step */
  state: Flip.FlipState;
  /** +1 = forward, -1 = back */
  step: 1 | -1;
};

export function StackedCardSlider({
  cards,
  cardWidth = 300,
  offsetStep = 20,
  aspectRatio = 0.67,
  visibleCards = 5,
  duration = 0.5,
  ease = "sine.inOut",
  radius = 6,
  onChange,
  className = "",
  cardClassName = "",
}: StackedCardSliderProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLImageElement | null)[]>([]);
  const leavingRef = useRef<HTMLImageElement>(null);
  const [view, setView] = useState<View>({ front: 0, leaving: null });
  // The step that was asked for and is waiting for React to move the cards
  const pending = useRef<Pending | null>(null);
  const busy = useRef(false);
  // Read when a step starts, so a new duration or ease needs no rebuild
  const settings = useRef({ duration, ease, onChange });
  settings.current = { duration, ease, onChange };
  const touchStart = useRef<{ x: number; y: number } | null>(null);
  const swiped = useRef(false);

  const count = cards.length;
  // If the list changes length, start again from the first card
  const front = count === 0 ? 0 : view.front % count;
  const shown = Math.max(1, Math.min(count, Math.round(visibleCards)));
  const total = offsetStep * (shown - 1);
  const cardHeight = `calc(var(--stack-card-width) / ${aspectRatio})`;

  const { contextSafe } = useGSAP(
    () => {
      busy.current = false;
      return () => {
        busy.current = false;
        pending.current = null;
      };
    },
    { scope: boxRef },
  );

  // Runs right after React has moved the cards to their new spots
  useGSAP(
    () => {
      const job = pending.current;
      if (!job) return;
      pending.current = null;

      const els = cardRefs.current.slice(0, count).filter((el): el is HTMLImageElement => el !== null);
      const { duration: time, ease: curve } = settings.current;
      const finish = () => {
        busy.current = false;
      };

      // The card flying away leaves toward the bottom left and fades out
      if (leavingRef.current) {
        gsap.to(leavingRef.current, {
          xPercent: -20,
          yPercent: 20,
          opacity: 0,
          transformOrigin: "bottom left",
          duration: time,
          ease: "expo.out",
          onComplete: () => setView((current) => ({ ...current, leaving: null })),
        });
      }

      Flip.from(job.state, {
        targets: els,
        // The cards never change size, so move them with transforms only
        scale: true,
        duration: time,
        ease: curve,
        // The one card that was not measured before the step is the one that arrives
        onEnter: (entering) =>
          gsap.from(entering, job.step === 1 ? { yPercent: 20, opacity: 0, duration: time, ease: "expo.out" } : { xPercent: -20, yPercent: 20, opacity: 0, transformOrigin: "bottom left", duration: time, ease: "expo.out" }),
        onComplete: finish,
        onInterrupt: finish,
      });
    },
    { scope: boxRef, dependencies: [view.front] },
  );

  const go = contextSafe((step: 1 | -1) => {
    if (busy.current || count < 2) return;
    const next = (front + step + count) % count;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    settings.current.onChange?.(next);
    if (reduced) {
      setView({ front: next, leaving: null });
      return;
    }
    busy.current = true;
    const els = cardRefs.current.slice(0, count);
    // The card that arrives has no "before", so it is left out of the measurement
    const arriving = step === 1 ? front : next;
    const measured = els.filter((el, index): el is HTMLImageElement => el !== null && index !== arriving);
    pending.current = { state: Flip.getState(measured, { props: "opacity,filter" }), step };
    setView({ front: next, leaving: step === 1 ? front : null });
  });

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault();
      go(1);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault();
      go(-1);
    }
  };

  // A swipe goes forward (to the left) or back (to the right); a plain tap is a click
  const onPointerDown = (event: React.PointerEvent) => {
    touchStart.current = { x: event.clientX, y: event.clientY };
    swiped.current = false;
  };
  const onPointerUp = (event: React.PointerEvent) => {
    const start = touchStart.current;
    touchStart.current = null;
    if (!start) return;
    const dx = event.clientX - start.x;
    const dy = event.clientY - start.y;
    if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) {
      swiped.current = true;
      go(dx < 0 ? 1 : -1);
    }
  };
  const onClick = (event: React.MouseEvent) => {
    if (swiped.current) {
      swiped.current = false;
      return;
    }
    go(event.shiftKey ? -1 : 1);
  };

  const spot = (rank: number) => {
    const slot = Math.min(rank, shown - 1);
    return { left: slot * offsetStep, top: total - slot * offsetStep };
  };

  const imageStyle = {
    width: "var(--stack-card-width)",
    height: cardHeight,
    borderRadius: radius,
  } as const;

  return (
    <div
      ref={boxRef}
      className={`flex h-full w-full items-center justify-center ${className}`}
      style={{
        // The card shrinks to fit the box both ways, so the whole stack always stays on screen
        containerType: "size",
        ["--stack-card-width" as string]: `min(${cardWidth}px, calc(100cqw - ${total + 32}px), calc((100cqh - ${total + 32}px) * ${aspectRatio}))`,
      }}
    >
      <button
        type="button"
        aria-label="Next card"
        onClick={onClick}
        onKeyDown={onKeyDown}
        onPointerDown={onPointerDown}
        onPointerUp={onPointerUp}
        onPointerCancel={() => (touchStart.current = null)}
        className="relative block shrink-0 cursor-pointer select-none rounded-[10px] border-0 bg-transparent p-0 outline-offset-[14px] focus-visible:outline-2 focus-visible:outline-current"
        style={{ width: `calc(var(--stack-card-width) + ${total}px)`, height: `calc(${cardHeight} + ${total}px)`, touchAction: "pan-y", WebkitTapHighlightColor: "transparent" }}
      >
        {cards.map((card, index) => {
          const rank = (index - front + count) % count;
          const visible = rank < shown;
          return (
            <img
              key={index}
              ref={(el) => {
                cardRefs.current[index] = el;
              }}
              src={card.image}
              alt={card.alt ?? `Card ${index + 1}`}
              aria-hidden={rank === 0 ? undefined : true}
              draggable={false}
              decoding="async"
              className={`pointer-events-none absolute block object-cover object-top shadow-[0_0_0_1px_rgba(0,0,0,0.18),0_22px_44px_-18px_rgba(0,0,0,0.55)] ${cardClassName}`}
              style={{
                ...imageStyle,
                ...spot(rank),
                zIndex: count - rank,
                opacity: visible ? 1 : 0,
                filter: `brightness(${1 - Math.min(rank, shown - 1) * 0.07})`,
                willChange: "transform, opacity",
              }}
            />
          );
        })}
        {view.leaving !== null && cards[view.leaving] ? (
          <img
            ref={leavingRef}
            src={cards[view.leaving]!.image}
            alt=""
            aria-hidden="true"
            draggable={false}
            className={`pointer-events-none absolute block object-cover object-top shadow-[0_0_0_1px_rgba(0,0,0,0.18),0_22px_44px_-18px_rgba(0,0,0,0.55)] ${cardClassName}`}
            style={{ ...imageStyle, ...spot(0), zIndex: count + 1, willChange: "transform, opacity" }}
          />
        ) : null}
      </button>
      <span className="sr-only" aria-live="polite">
        {cards[front] ? `${cards[front]!.alt ?? `Card ${front + 1}`}, card ${front + 1} of ${count}` : ""}
      </span>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useRef, useState } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
import { StackedCardSlider, type StackedCard, type StackedCardSliderProps } from "@/components/dimaac/stacked-card-slider";

// Seven portraits from Lummi (Lummi License: free for personal and commercial use).
// The big name on the left follows whichever card is at the front.

const portraits: (StackedCard & { name: string })[] = [
  { name: "Lime", image: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg", alt: "A person with curly red hair in a lime green blazer against a bright pink background" },
  { name: "Neon", image: "https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg", alt: "A profile portrait of a woman in neon pink sunglasses against a vibrant blue background" },
  { name: "Platinum", image: "https://ui.dimaac.com/media/portraits/platinum-blonde-4x5.jpg", alt: "A young man with platinum blonde hair and a yellow jacket against a bold orange background" },
  { name: "Scarlet", image: "https://ui.dimaac.com/media/portraits/red-coat-4x5.jpg", alt: "A person in a red coat against a deep blue background" },
  { name: "Dusk", image: "https://ui.dimaac.com/media/portraits/tinted-glasses-4x5.jpg", alt: "A woman in purple-tinted sunglasses and braided hair against a glowing orange background" },
  { name: "Visor", image: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg", alt: "A woman in an orange visor and navy dress with red lips against a teal background" },
  { name: "Noir", image: "https://ui.dimaac.com/media/portraits/glossy-lowkey-4x5.jpg", alt: "A close-up portrait of a woman with glossy lips and sparkling earrings in dramatic black and white light, with a flash of purple at the collar" },
];

const two = (n: number) => String(n).padStart(2, "0");

export default function Demo(props: Partial<StackedCardSliderProps>) {
  const [index, setIndex] = useState(0);
  const labelRef = useRef<HTMLDivElement>(null);

  // The name and number rise in again every time the front card changes
  useGSAP(
    () => {
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
      gsap.from("[data-swap]", { yPercent: 40, opacity: 0, duration: 0.7, ease: "expo.out", stagger: 0.06 });
    },
    { scope: labelRef, dependencies: [index] },
  );

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#ebe8e1] text-[#17171a] dark:bg-[#121214] dark:text-[#ecebe6]" style={{ fontFamily: '"DM Sans", system-ui, sans-serif' }}>
      <div aria-hidden="true" className="pointer-events-none absolute top-1/2 left-1/2 size-[70vmin] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#d4cfc4] blur-[90px] dark:bg-[#26262c]" />

      <StackedCardSlider cards={portraits} {...props} onChange={setIndex} className={`absolute inset-0 z-10 pt-8 pb-28 md:py-10 ${props.className ?? ""}`} />

      <div ref={labelRef} className="pointer-events-none absolute right-6 bottom-20 left-6 z-20 text-center md:right-auto md:bottom-14 md:left-12 md:text-left" aria-hidden="true">
        <div className="overflow-hidden">
          <p data-swap className="font-mono text-[12px] tracking-wide uppercase opacity-60">
            {two(index + 1)} / {two(portraits.length)}
          </p>
        </div>
        <div className="overflow-hidden pb-2">
          <h2 data-swap className="text-[clamp(44px,8vw,112px)] leading-[0.95] font-medium tracking-[-0.045em]">
            {portraits[index]?.name}
          </h2>
        </div>
      </div>

      <footer className="pointer-events-none absolute inset-x-6 bottom-6 z-20 flex justify-between font-mono text-[11px] tracking-wide uppercase opacity-70">
        <span className="mx-auto">Click · swipe · arrow keys</span>
      </footer>
    </div>
  );
}
```

## Agent prompt

```text
Add the Stacked card slider component (<StackedCardSlider />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/stacked-card-slider
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/stacked-card-slider.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/stacked-card-slider.json

Then use it like this:
import { StackedCardSlider } from "@/components/dimaac/stacked-card-slider";
<StackedCardSlider cards={cards} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/stacked-card-slider.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Swipeable Cards
description: A deck of cards you swipe through.
category: Cards
tier: free
url: https://ui.dimaac.com/components/swipeable-cards
registry: https://ui.dimaac.com/r/swipeable-cards.json
---

# Swipeable Cards

A deck of photo cards built on Swiper's cards effect. Drag the top card away and the next one settles into place with the rest fanned out behind; click a card behind to bring it forward. It has optional previous and next buttons, dot navigation, autoplay that pauses under the pointer, and arrow keys when the deck is focused. The buttons talk to their own deck, so two decks on one page never steer each other. Card size, corner radius, tilt and fan-out are props, and visitors who prefer reduced motion get instant changes and no autoplay. Ported from the first DiMaac UI, with its global button selectors, fixed card size and hard-coded autoplay speed fixed.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/swipeable-cards.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/swipeable-cards.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/swipeable-cards.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/swipeable-cards`

Dependencies:

```bash
pnpm add swiper lucide-react
npm install swiper lucide-react
```

By hand:

1. Install the packages it uses: swiper, lucide-react.
2. Copy the component file below into components/dimaac/swipeable-cards.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { SwipeableCards } from "@/components/dimaac/swipeable-cards" and give its parent a size: it fills it.

## Usage

```tsx
import { SwipeableCards } from "@/components/dimaac/swipeable-cards";

<SwipeableCards images={images} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` (required) | `SwipeableCard[]` | - | The cards, top one first. Eight or more looks best. |
| `cardWidth` | `number` | `300` | Width of a card, in pixels. On a narrower screen it shrinks to fit. |
| `cardHeight` | `number` | `440` | Height of a card, in pixels. |
| `radius` | `number` | `24` | Corner radius of a card, in pixels. |
| `loop` | `boolean` | `true` | Start again at the first card after the last one. |
| `autoplay` | `boolean` | `false` | Flick to the next card on a timer. It pauses under the pointer and never runs for visitors who prefer reduced motion. |
| `autoplayDelay` | `number` | `2500` | Milliseconds between cards while autoplay is on. |
| `speed` | `number` | `300` | Milliseconds a card takes to move. Visitors who prefer reduced motion get an instant change. |
| `perSlideRotate` | `number` | `2` | Tilt of each card behind the top one, in degrees. |
| `perSlideOffset` | `number` | `8` | How far each card behind the top one peeks out to the side, in pixels (roughly). |
| `rotate` | `boolean` | `true` | Tilt the cards as they fan out. Off keeps the deck flat and straight. |
| `slideShadows` | `boolean` | `true` | Darken the cards behind the top one, so the deck has depth. |
| `showNavigation` | `boolean` | `false` | Show previous and next buttons under the deck. |
| `showPagination` | `boolean` | `false` | Show one dot per card under the deck. They are buttons too. |
| `grabCursor` | `boolean` | `true` | Show a grab hand over the deck. |
| `label` | `string` | `"Swipeable cards"` | The name screen readers announce for the deck. |
| `onIndexChange` | `(index: number) => void` | - | Called with the number of the card on top (starting at 0) each time it changes. |
| `className` | `string` | `""` | Extra classes for the outer box. |
| `cardClassName` | `string` | `"shadow-[0_26px_40px_-22px_rgba(0,0,0,0.5)]"` | Extra classes for each card, e.g. a different shadow. |

## Credits

- Portrait with Neon Sunglasses: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Vibrant Woman Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Vivid Portrait on Pink: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Expressive Minimal Portrait: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License
- Poised Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License
- Stylish Portrait: Amino, Lummi (https://www.lummi.ai/photo/stylish-portrait-8inth), Lummi License
- Monochrome Portrait of a Young Woman: West Kast, Lummi (https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s), Lummi License
- Mystery Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/mystery-portrait-cffyo), Lummi License
- Abstract Eye Close-Up: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Striking Portrait: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Fashion Portrait with Orange Visor: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Stylish Studio Portrait: Amino, Lummi (https://www.lummi.ai/photo/stylish-studio-portrait-d9xnl), Lummi License

## Source

### swipeable-cards.tsx → `components/dimaac/swipeable-cards.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { A11y, Autoplay, EffectCards } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperInstance } from "swiper/types";
import "swiper/css";
import "swiper/css/effect-cards";
import "swiper/css/a11y";

// A deck of photos you flick through. The top card flies off to the side when you drag it and the
// next one settles into its place, with the rest fanned out behind. Swiper's "cards" effect does
// the 3D, we add the buttons and dots around it. Those talk to THIS deck through its instance,
// not through global class names, so two decks on one page never steer each other.

export type SwipeableCard = {
  /** The image address. */
  src: string;
  /** What the image shows, for screen readers. */
  alt: string;
};

export type SwipeableCardsProps = {
  /** The cards, top one first. Eight or more looks best. */
  images: SwipeableCard[];
  /** Width of a card, in pixels. On a narrower screen it shrinks to fit. */
  cardWidth?: number;
  /** Height of a card, in pixels. */
  cardHeight?: number;
  /** Corner radius of a card, in pixels. */
  radius?: number;
  /** Start again at the first card after the last one. */
  loop?: boolean;
  /** Flick to the next card on a timer. It pauses under the pointer and never runs for visitors who prefer reduced motion. */
  autoplay?: boolean;
  /** Milliseconds between cards while autoplay is on. */
  autoplayDelay?: number;
  /** Milliseconds a card takes to move. Visitors who prefer reduced motion get an instant change. */
  speed?: number;
  /** Tilt of each card behind the top one, in degrees. */
  perSlideRotate?: number;
  /** How far each card behind the top one peeks out to the side, in pixels (roughly). */
  perSlideOffset?: number;
  /** Tilt the cards as they fan out. Off keeps the deck flat and straight. */
  rotate?: boolean;
  /** Darken the cards behind the top one, so the deck has depth. */
  slideShadows?: boolean;
  /** Show previous and next buttons under the deck. */
  showNavigation?: boolean;
  /** Show one dot per card under the deck. They are buttons too. */
  showPagination?: boolean;
  /** Show a grab hand over the deck. */
  grabCursor?: boolean;
  /** The name screen readers announce for the deck. */
  label?: string;
  /** Called with the number of the card on top (starting at 0) each time it changes. */
  onIndexChange?: (index: number) => void;
  /** Extra classes for the outer box. */
  className?: string;
  /** Extra classes for each card, e.g. a different shadow. */
  cardClassName?: string;
};

export function SwipeableCards({
  images,
  cardWidth = 300,
  cardHeight = 440,
  radius = 24,
  loop = true,
  autoplay = false,
  autoplayDelay = 2500,
  speed = 300,
  perSlideRotate = 2,
  perSlideOffset = 8,
  rotate = true,
  slideShadows = true,
  showNavigation = false,
  showPagination = false,
  grabCursor = true,
  label = "Swipeable cards",
  onIndexChange,
  className = "",
  cardClassName = "shadow-[0_26px_40px_-22px_rgba(0,0,0,0.5)]",
}: SwipeableCardsProps) {
  // The Swiper instance of this deck. The buttons and the dots use it.
  const swiperRef = useRef<SwiperInstance | null>(null);
  const [index, setIndex] = useState(0);
  // Read after mount so the server and the first client render match
  const [reducedMotion, setReducedMotion] = useState(false);

  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReducedMotion(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);

  // Swiper doesn't pick up a change of loop, autoplay or the card effect settings on its own, so
  // those rebuild the deck (it stays on the same card). Everything else, like speed, updates in place.
  const running = autoplay && !reducedMotion;
  const deckKey = `${loop}-${rotate}-${slideShadows}-${perSlideRotate}-${perSlideOffset}-${running}-${autoplayDelay}-${images.length}`;

  // The listener lives on the box that holds the deck, so only the deck the visitor focused reacts
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowRight") {
      event.preventDefault();
      swiperRef.current?.slideNext();
    } else if (event.key === "ArrowLeft") {
      event.preventDefault();
      swiperRef.current?.slidePrev();
    }
  };

  const buttonClass =
    "grid h-11 w-11 place-items-center rounded-full border border-current/25 bg-transparent text-inherit transition-colors duration-200 hover:bg-current/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current";

  return (
    <div
      className={`group/deck relative flex w-full flex-col items-center gap-7 outline-none ${className}`}
      role="group"
      aria-roledescription="carousel"
      aria-label={label}
      tabIndex={0}
      onKeyDown={onKeyDown}
    >
      <div
        className="relative group-focus-visible/deck:rounded-[32px] group-focus-visible/deck:outline-2 group-focus-visible/deck:outline-offset-[10px] group-focus-visible/deck:outline-current/50"
        style={{ width: cardWidth, maxWidth: "calc(100% - 3rem)", height: cardHeight }}
      >
        <Swiper
          key={deckKey}
          className="!h-full !w-full"
          modules={[EffectCards, Autoplay, A11y]}
          effect="cards"
          cardsEffect={{ rotate, slideShadows, perSlideRotate, perSlideOffset }}
          grabCursor={grabCursor}
          loop={loop}
          speed={reducedMotion ? 0 : speed}
          slideToClickedSlide
          autoplay={running ? { delay: autoplayDelay, disableOnInteraction: false, pauseOnMouseEnter: true } : false}
          a11y={{ slideLabelMessage: "Card {{index}} of {{slidesLength}}" }}
          initialSlide={index}
          onSwiper={(swiper) => {
            swiperRef.current = swiper;
          }}
          onRealIndexChange={(swiper) => {
            setIndex(swiper.realIndex);
            onIndexChange?.(swiper.realIndex);
          }}
        >
          {images.map((image, i) => (
            <SwiperSlide key={`${i}-${image.src}`} className={cardClassName} style={{ borderRadius: radius }}>
              <img src={image.src} alt={image.alt} draggable={false} decoding="async" className="block h-full w-full object-cover select-none" />
            </SwiperSlide>
          ))}
        </Swiper>
      </div>

      {(showNavigation || showPagination) && (
        <div className="flex items-center gap-4">
          {showNavigation && (
            <button type="button" className={buttonClass} aria-label="Previous card" onClick={() => swiperRef.current?.slidePrev()}>
              <ChevronLeftIcon className="h-5 w-5" aria-hidden="true" />
            </button>
          )}
          {showPagination && (
            <div className="flex items-center" role="group" aria-label="Choose a card">
              {images.map((image, i) => (
                <button
                  key={`${i}-${image.src}`}
                  type="button"
                  className="group grid h-6 place-items-center px-[3px] text-inherit focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
                  aria-label={`Show card ${i + 1} of ${images.length}`}
                  aria-current={i === index ? "true" : undefined}
                  onClick={() => (loop ? swiperRef.current?.slideToLoop(i) : swiperRef.current?.slideTo(i))}
                >
                  <span
                    className={`block h-[6px] rounded-full bg-current transition-[width,opacity] duration-300 ${i === index ? "w-5 opacity-100" : "w-[6px] opacity-30 group-hover:opacity-60"}`}
                  />
                </button>
              ))}
            </div>
          )}
          {showNavigation && (
            <button type="button" className={buttonClass} aria-label="Next card" onClick={() => swiperRef.current?.slideNext()}>
              <ChevronRightIcon className="h-5 w-5" aria-hidden="true" />
            </button>
          )}
        </div>
      )}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { SwipeableCards, type SwipeableCardsProps } from "@/components/dimaac/swipeable-cards";

// A deck of twelve photos from the Lummi set (Lummi License). The title under the deck follows the
// card on top.

const portraits = [
  { file: "portraits/neon-profile-4x5.jpg", title: "Neon shades", alt: "A woman in neon pink sunglasses in profile against a bright blue background" },
  { file: "portraits/tinted-glasses-4x5.jpg", title: "Purple shades", alt: "A woman in purple-tinted sunglasses and braids against a glowing orange background" },
  { file: "portraits/lime-blazer-4x5.jpg", title: "Lime blazer", alt: "A person with curly red hair in a lime green blazer against bright pink" },
  { file: "portraits/glossy-lowkey-4x5.jpg", title: "Low key", alt: "A woman with glossy lips and sparkling earrings in low, dramatic light" },
  { file: "portraits/red-coat-4x5.jpg", title: "Red coat", alt: "A person in a red coat against a deep blue background" },
  { file: "portraits/burgundy-blazer-4x5.jpg", title: "Burgundy blazer", alt: "A person in a burgundy blazer and white turtleneck against a neutral background" },
  { file: "portraits/mono-profile-4x5.jpg", title: "Mono profile", alt: "A black and white profile portrait of a woman in dramatic light" },
  { file: "portraits/red-brushstroke-4x5.jpg", title: "Red stroke", alt: "A portrait with a red brushstroke across the eyes against muted green" },
  { file: "portraits/light-streaks-4x5.jpg", title: "Light streaks", alt: "A close-up of a face with blue eyes and coloured light streaks" },
  { file: "portraits/platinum-blonde-4x5.jpg", title: "Yellow jacket", alt: "A young man with platinum blonde hair in a yellow jacket against orange" },
  { file: "fashion/orange-visor-4x5.jpg", title: "Orange visor", alt: "A woman in an orange visor and navy dress against a teal background" },
  { file: "fashion/varsity-jacket-4x5.jpg", title: "Varsity jacket", alt: "A person in a varsity jacket and orange sunglasses with a yellow ribbon in their hair" },
];

const images = portraits.map((portrait) => ({ src: media(portrait.file), alt: portrait.alt }));

export default function Demo(props: Partial<SwipeableCardsProps>) {
  const [index, setIndex] = useState(0);
  const number = String(index + 1).padStart(2, "0");
  const total = String(portraits.length).padStart(2, "0");

  return (
    <div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      {/* A huge word behind the deck, so the cards sit on something */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center text-[clamp(90px,24vw,340px)] leading-none font-medium tracking-[-0.06em] opacity-[0.06] select-none dark:opacity-[0.09]"
      >
        Faces
      </div>

      <SwipeableCards
        images={images}
        label="Photo deck"
        {...props}
        onIndexChange={(next) => {
          setIndex(next);
          props.onIndexChange?.(next);
        }}
        className={`relative z-10 ${props.className ?? ""}`}
      />

      <div className="pointer-events-none relative z-10 mt-8 flex flex-col items-center gap-1 text-center">
        <p className="font-mono text-[13px] tracking-wide tabular-nums">
          {number} <span className="opacity-40">/ {total}</span>
        </p>
        <p className="text-[15px] opacity-70">{portraits[index]?.title}</p>
      </div>

      <p className="pointer-events-none absolute inset-x-6 bottom-6 z-10 text-center font-mono text-[11px] tracking-wide uppercase opacity-60">
        Drag a card<span className="hidden sm:inline"> or press ← →</span>
      </p>
    </div>
  );
}
```

## Agent prompt

```text
Add the Swipeable Cards component (<SwipeableCards />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/swipeable-cards
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/swipeable-cards.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/swipeable-cards.json

Then use it like this:
import { SwipeableCards } from "@/components/dimaac/swipeable-cards";
<SwipeableCards images={images} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/swipeable-cards.tsx and install swiper, lucide-react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Text block effect
description: A coloured block sweeps each line in.
category: Text
tier: free
url: https://ui.dimaac.com/components/text-block-effect
registry: https://ui.dimaac.com/r/text-block-effect.json
---

# Text block effect

A paragraph or heading cut into lines, where a block of colour slides across each line, the words appear underneath it and the block slides away. The blocks fit the words exactly, whether the text is left, centred or right aligned, and can travel in any of four directions, one line at a time or covering everything first. It uses GSAP SplitText with autoSplit, so the lines are cut again when the width changes or the web font loads, and everything is put back the way it was on unmount. It needs no section around it: the text starts by itself when it scrolls into view. It takes an optional scroller for inner scroll areas, can play again every time it comes back into view, and shows the text as it is when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/text-block-effect.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/text-block-effect.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/text-block-effect.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/text-block-effect`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/text-block-effect.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { TextBlockEffect } from "@/components/dimaac/text-block-effect" and give its parent a size: it fills it.

## Usage

```tsx
import { TextBlockEffect } from "@/components/dimaac/text-block-effect";

<TextBlockEffect children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `string` | - | The text to show. Plain text only (the lines are cut from it). |
| `as` | `"h1" \| "h2" \| "h3" \| "p"` | `"p"` | The tag to render: a page title, a section title or a paragraph. |
| `blockColor` | `string` | `"#DDFC3E"` | The colour of the sliding blocks. |
| `textColor` | `string` | `""` | The colour of the text. Empty keeps whatever colour the surrounding page gives it. |
| `direction` | `"right" \| "left" \| "down" \| "up"` | `"right"` | The way the blocks travel across a line before they leave again. |
| `mode` | `"line" \| "cover"` | `"line"` | "line" reveals one line after another. "cover" covers every line first, then uncovers them all. |
| `duration` | `number` | `0.45` | How long one block takes to slide across (or off) a line, in seconds. |
| `stagger` | `number` | `0.3` | The wait between one line starting and the next, in seconds. |
| `replay` | `boolean` | `false` | Play the reveal again each time the text scrolls back into view from above. |
| `start` | `string` | `"top 80%"` | When the reveal starts: "<element edge> <viewport edge>", e.g. "top 80%". |
| `scroller` | `string \| HTMLElement` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes: put the font size, weight, alignment and line height here. |

## Credits

- Background photo: stacy, Lummi (https://www.lummi.ai/photo/silhouette-with-sunglasses-wd3ut), Lummi License

## Source

### text-block-effect.tsx → `components/dimaac/text-block-effect.tsx`

```tsx
"use client";

import { createElement, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

// Text that is revealed behind a coloured block. Each line gets a block that slides across it,
// the words appear while the block covers them, and the block slides away to show the line.
// The text stays real text: selectable, and read as one sentence by screen readers.
// It starts by itself when it scrolls into view, so it does not need a section around it.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);

export type TextBlockEffectProps = {
  /** The text to show. Plain text only (the lines are cut from it). */
  children: string;
  /** The tag to render: a page title, a section title or a paragraph. */
  as?: "h1" | "h2" | "h3" | "p";
  /** The colour of the sliding blocks. */
  blockColor?: string;
  /** The colour of the text. Empty keeps whatever colour the surrounding page gives it. */
  textColor?: string;
  /** The way the blocks travel across a line before they leave again. */
  direction?: "right" | "left" | "down" | "up";
  /** "line" reveals one line after another. "cover" covers every line first, then uncovers them all. */
  mode?: "line" | "cover";
  /** How long one block takes to slide across (or off) a line, in seconds. */
  duration?: number;
  /** The wait between one line starting and the next, in seconds. */
  stagger?: number;
  /** Play the reveal again each time the text scrolls back into view from above. */
  replay?: boolean;
  /** When the reveal starts: "<element edge> <viewport edge>", e.g. "top 80%". */
  start?: string;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes: put the font size, weight, alignment and line height here. */
  className?: string;
};

export function TextBlockEffect({
  children,
  as = "p",
  blockColor = "#DDFC3E",
  textColor = "",
  direction = "right",
  mode = "line",
  duration = 0.45,
  stagger = 0.3,
  replay = false,
  start = "top 80%",
  scroller,
  className = "",
}: TextBlockEffectProps) {
  const textRef = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const element = textRef.current;
      if (!element) return;

      // The text is hidden until it is split, so you never see it jump. Show it again either way.
      element.style.visibility = "visible";

      // Reduced motion: leave the text exactly as written, fully readable
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

      // Sideways directions use scaleX, up and down use scaleY.
      // A block grows from the side it comes from, then shrinks towards the opposite side.
      const sideways = direction === "right" || direction === "left";
      const axis = sideways ? "scaleX" : "scaleY";
      const origins = { right: ["left center", "right center"], left: ["right center", "left center"], down: ["center top", "center bottom"], up: ["center bottom", "center top"] } as const;
      const [growFrom, shrinkTo] = origins[direction];

      const split = SplitText.create(element, {
        type: "lines",
        linesClass: "text-block-line",
        // Cut the lines again when the width changes or the web font arrives
        autoSplit: true,
        onSplit(self) {
          const align = getComputedStyle(element).textAlign;
          const pieces: { text: HTMLElement; block: HTMLElement }[] = [];

          self.lines.forEach((line) => {
            // The line shrinks to the width of its words, so the block is exactly as wide as the text
            const lineBox = line as HTMLElement;
            lineBox.style.display = "block";
            lineBox.style.position = "relative";
            lineBox.style.width = "fit-content";
            lineBox.style.maxWidth = "100%";
            lineBox.style.textAlign = "start";
            if (align === "center") lineBox.style.marginInline = "auto";
            else if (align === "right" || align === "end") lineBox.style.marginLeft = "auto";

            // The words go in their own wrapper, so they can be hidden without hiding the block
            const text = document.createElement("span");
            while (lineBox.firstChild) text.appendChild(lineBox.firstChild);
            lineBox.appendChild(text);

            // A touch bigger than the line, so no sliver of text shows around the edges
            const block = document.createElement("span");
            block.setAttribute("aria-hidden", "true");
            block.style.cssText = `position:absolute;left:-1%;top:0;width:102%;height:102%;pointer-events:none;background:${blockColor};`;
            lineBox.appendChild(block);

            pieces.push({ text, block });
          });

          gsap.set(pieces.map((p) => p.text), { opacity: 0 });
          gsap.set(pieces.map((p) => p.block), { [axis]: 0, transformOrigin: growFrom });

          const timeline = gsap.timeline({
            scrollTrigger: { trigger: element, scroller, start, toggleActions: replay ? "play none none reset" : "play none none none" },
          });

          const grow = (block: HTMLElement, at: number) => {
            timeline.set(block, { transformOrigin: growFrom }, at);
            timeline.to(block, { [axis]: 1, duration, ease: "power2.inOut" }, at);
          };
          const shrink = (block: HTMLElement, text: HTMLElement, at: number) => {
            // The words are shown at the moment the block is fully over them
            timeline.set(text, { opacity: 1 }, at);
            timeline.set(block, { transformOrigin: shrinkTo }, at);
            timeline.to(block, { [axis]: 0, duration, ease: "power2.inOut" }, at);
          };

          if (mode === "cover") {
            // Every line is covered first, one after another, then they are all uncovered
            pieces.forEach((piece, i) => grow(piece.block, i * stagger));
            const covered = (pieces.length - 1) * stagger + duration;
            pieces.forEach((piece, i) => shrink(piece.block, piece.text, covered + i * stagger));
          } else {
            // Each line is covered and uncovered in turn, the next one starting a moment later
            pieces.forEach((piece, i) => {
              grow(piece.block, i * stagger);
              shrink(piece.block, piece.text, i * stagger + duration);
            });
          }

          // Returning the timeline lets SplitText keep it in step when it re-splits
          return timeline;
        },
      });

      return () => split.revert();
    },
    { scope: textRef, dependencies: [children, as, blockColor, direction, mode, duration, stagger, replay, start, scroller], revertOnUpdate: true },
  );

  // The key makes React build a fresh element when the text or tag changes, because SplitText
  // rewrites the inside of the old one.
  return createElement(as, { ref: textRef, key: `${as}:${children}`, className, style: { visibility: "hidden", ...(textColor ? { color: textColor } : null) } }, children);
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { TextBlockEffect, type TextBlockEffectProps } from "@/components/dimaac/text-block-effect";

// Four short screens of type, each one a different kind of text. Every block uses the playground settings.
// Photo: stacy on Lummi (Lummi License).

const body = "font-[family-name:'Bricolage_Grotesque',sans-serif] text-[#ededed]";

export default function Demo(props: Partial<TextBlockEffectProps>) {
  const { className: _className, children: _children, as: _as, ...shared } = props;
  return (
    <div className="min-h-dvh bg-[#0b0b0f]">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300..700&display=swap" precedence="default" />

      <section className="relative flex min-h-dvh w-full items-center justify-center overflow-hidden px-8">
        <p className="absolute top-6 left-6 font-mono text-[11px] tracking-wide text-white/50 uppercase md:left-12">Scroll</p>
        <TextBlockEffect {...shared} start="top 100%" className={`max-w-[600px] text-center text-[clamp(1.75rem,3vw,2.75rem)] leading-[1.6] ${body}`}>
          We believe in the quiet power of ideas. The ones that sit with you long after you have looked away.
        </TextBlockEffect>
      </section>

      <section className="relative flex min-h-dvh w-full items-center justify-center overflow-hidden px-8">
        <img src="https://ui.dimaac.com/media/dark/blue-silhouette-3x2.jpg" alt="" className="absolute inset-0 h-full w-full object-cover object-center" style={{ filter: "brightness(0.6) saturate(0.85)" }} />
        <div className="pointer-events-none absolute inset-0 bg-[#0b0b0f]/50" />
        <TextBlockEffect {...shared} className={`relative z-[2] max-w-[600px] text-center text-[clamp(1.75rem,3vw,2.75rem)] leading-[1.6] ${body}`}>
          Some things are felt before they are understood. We craft those in-between moments, still, sharp and unforgettable.
        </TextBlockEffect>
      </section>

      <section className="relative flex min-h-dvh w-full flex-col justify-center gap-10 px-8 md:px-[8vw]">
        <TextBlockEffect {...shared} as="h2" className={`max-w-[11ch] text-left text-[clamp(3rem,9vw,8rem)] leading-[1.15] font-medium tracking-[-0.03em] ${body}`}>
          Closer to what matters
        </TextBlockEffect>
        <TextBlockEffect {...shared} className={`max-w-[34ch] self-end text-right text-[clamp(1rem,1.6vw,1.375rem)] leading-[1.7] ${body} opacity-80`}>
          Not louder. Not faster. Just closer to something that actually matters, set in type and let in one line at a time.
        </TextBlockEffect>
      </section>

      <section className="relative flex min-h-[60dvh] w-full items-center justify-center overflow-hidden px-8">
        <TextBlockEffect {...shared} className={`max-w-[600px] text-center text-[clamp(1.75rem,3vw,2.75rem)] leading-[1.6] ${body}`}>
          Scroll back up and it plays again, if replay is on.
        </TextBlockEffect>
      </section>
    </div>
  );
}
```

## Agent prompt

```text
Add the Text block effect component (<TextBlockEffect />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/text-block-effect
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/text-block-effect.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/text-block-effect.json

Then use it like this:
import { TextBlockEffect } from "@/components/dimaac/text-block-effect";
<TextBlockEffect children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/text-block-effect.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Text Loader
description: Letters rise out of a mask, fill with a gradient, leave, and the panel lifts away.
category: Loaders
tier: free
url: https://ui.dimaac.com/components/text-loader
registry: https://ui.dimaac.com/r/text-loader.json
---

# Text Loader

A word preloader. Each letter rises into view out of a mask, a gradient wipes up through all of them, they leave through the top, and then the whole panel slides off the screen to show the page underneath. It uses the font of the page it sits in (no font import), fills the box it is placed in or the whole window with the fullscreen prop, scales its type to the panel so long words still fit, and plays again whenever you change its replayKey. A parent that re-renders never restarts it. With reduced motion the word simply shows filled and the panel fades out. Ported from the first DiMaac UI, rebuilt without styled-jsx and without the Google Fonts import.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/text-loader.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/text-loader.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/text-loader.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/text-loader`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/text-loader.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { TextLoader } from "@/components/dimaac/text-loader" and give its parent a size: it fills it.

## Usage

```tsx
import { TextLoader } from "@/components/dimaac/text-loader";

<TextLoader />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | `"DIMAAC"` | The word the loader spells out. One letter is one animated piece. |
| `gradientColors` | `string[]` | `DEFAULT_COLORS` | Colours of the gradient that fills the letters (45 degrees, one gradient per letter). Two or more. |
| `backgroundColor` | `string` | `"#17171a"` | Colour of the panel. |
| `ghostColor` | `string` | - | Colour of the letters before the gradient fills them. Leave it out and it follows the panel: a light tint on a dark panel, a dark tint on a light one. |
| `duration` | `{` | - | How long each step takes, in seconds. Any you leave out keep their default. |
| `slideUp` | `number` | - | Letters rising into view. |
| `reveal` | `number` | - | The gradient filling the letters. |
| `slideDown` | `number` | - | Letters leaving through the top. |
| `delays` | `{` | - | The pauses in between, in seconds. Any you leave out keep their default. |
| `stagger` | `number` | - | Gap between one letter and the next. |
| `betweenAnimations` | `number` | - | Pause between the letters landing and the gradient starting. |
| `beforeSlideDown` | `number` | - | Pause between the gradient finishing and the letters leaving. |
| `fullscreen` | `boolean` | `false` | Cover the whole window (position: fixed) instead of filling the box it sits in. |
| `replayKey` | `number` | `0` | Change this number to play the loader again. |
| `onComplete` | `() => void` | - | Called once the panel has left the screen. A new function on every render is fine. |
| `className` | `string` | `""` | Extra classes for the panel. |

## Credits

- Page photo: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/an-empty-snowy-mountain-with-pink-sunrise-on-the-hori-270fdea6-887e-4d89-8205-3eab907f3fb0-HgoCN), Lummi License (free for personal and commercial use)

## Source

### text-loader.tsx → `components/dimaac/text-loader.tsx`

```tsx
"use client";

import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A preloader. The letters rise one by one out of a mask, a gradient wipes up through each of them,
// they leave through the top, and then the whole panel lifts off the page. It uses the font of the
// page it sits in, so there is no font to install.

export type TextLoaderProps = {
  /** The word the loader spells out. One letter is one animated piece. */
  text?: string;
  /** Colours of the gradient that fills the letters (45 degrees, one gradient per letter). Two or more. */
  gradientColors?: string[];
  /** Colour of the panel. */
  backgroundColor?: string;
  /** Colour of the letters before the gradient fills them. Leave it out and it follows the panel: a light tint on a dark panel, a dark tint on a light one. */
  ghostColor?: string;
  /** How long each step takes, in seconds. Any you leave out keep their default. */
  duration?: {
    /** Letters rising into view. */
    slideUp?: number;
    /** The gradient filling the letters. */
    reveal?: number;
    /** Letters leaving through the top. */
    slideDown?: number;
  };
  /** The pauses in between, in seconds. Any you leave out keep their default. */
  delays?: {
    /** Gap between one letter and the next. */
    stagger?: number;
    /** Pause between the letters landing and the gradient starting. */
    betweenAnimations?: number;
    /** Pause between the gradient finishing and the letters leaving. */
    beforeSlideDown?: number;
  };
  /** Cover the whole window (position: fixed) instead of filling the box it sits in. */
  fullscreen?: boolean;
  /** Change this number to play the loader again. */
  replayKey?: number;
  /** Called once the panel has left the screen. A new function on every render is fine. */
  onComplete?: () => void;
  /** Extra classes for the panel. */
  className?: string;
};

// Module constants, so the defaults are the same object on every render
const DEFAULT_COLORS = ["#ff0000", "#ff3333", "#ff6600", "#cc0000"];
const DEFAULT_DURATION = { slideUp: 0.6, reveal: 0.8, slideDown: 0.6 };
const DEFAULT_DELAYS = { stagger: 0.05, betweenAnimations: 0.3, beforeSlideDown: 0.5 };

// Like { ...defaults, ...given }, except a value that is undefined falls back to its default
function withDefaults<T extends Record<string, number>>(defaults: T, given: Partial<T> = {}): T {
  const merged = { ...defaults };
  for (const key of Object.keys(defaults) as (keyof T)[]) {
    const value = given[key];
    if (value !== undefined) merged[key] = value;
  }
  return merged;
}

// Picks the ghost letters from the panel colour, so a light panel never hides them.
// Only #rgb and #rrggbb are read; any other colour format falls back to the light tint.
function ghostFor(background: string): string {
  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(background.trim())?.[1];
  if (!hex) return "rgba(255, 255, 255, 0.2)";
  const full = hex.length === 3 ? Array.from(hex, (c) => c + c).join("") : hex;
  const channel = (i: number) => parseInt(full.slice(i, i + 2), 16);
  const [r, g, b] = [channel(0), channel(2), channel(4)];
  const light = (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
  return light ? "rgba(0, 0, 0, 0.14)" : "rgba(255, 255, 255, 0.2)";
}

export function TextLoader({
  text = "DIMAAC",
  gradientColors = DEFAULT_COLORS,
  backgroundColor = "#17171a",
  ghostColor,
  duration,
  delays,
  fullscreen = false,
  replayKey = 0,
  onComplete,
  className = "",
}: TextLoaderProps) {
  const panelRef = useRef<HTMLDivElement>(null);

  // A partly filled object still works: whatever is missing comes from the defaults
  const { slideUp, reveal, slideDown } = withDefaults(DEFAULT_DURATION, duration);
  const { stagger, betweenAnimations, beforeSlideDown } = withDefaults(DEFAULT_DELAYS, delays);

  // The newest onComplete lives in a ref. The animation reads it when it ends, so a parent that
  // re-renders (and passes a new function) never restarts the loader.
  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  });

  const letters = Array.from(text);
  const gradient = `linear-gradient(45deg, ${(gradientColors.length > 1 ? gradientColors : DEFAULT_COLORS).join(", ")})`;

  useGSAP(
    () => {
      const panel = panelRef.current;
      if (!panel) return;
      const glyphs = gsap.utils.toArray<HTMLElement>("[data-glyph]", panel);
      const fills = gsap.utils.toArray<HTMLElement>("[data-fill]", panel);
      const finish = () => {
        // Off screen already, but also out of reach of the keyboard and screen readers
        gsap.set(panel, { visibility: "hidden" });
        onCompleteRef.current?.();
      };

      const mm = gsap.matchMedia();

      mm.add("(prefers-reduced-motion: no-preference)", () => {
        gsap.set(panel, { y: 0, yPercent: 0, visibility: "visible" });
        gsap.set(glyphs, { y: 0, yPercent: 110 });
        gsap.set(fills, { clipPath: "inset(100% 0% 0% 0%)" });

        const tl = gsap.timeline({ onComplete: finish });
        tl.to(glyphs, { yPercent: 0, duration: slideUp, stagger, ease: "power2.out" })
          .to(fills, { clipPath: "inset(0% 0% 0% 0%)", duration: reveal, delay: betweenAnimations, ease: "power1.inOut" })
          .to(glyphs, { yPercent: -110, duration: slideDown, stagger, delay: beforeSlideDown, ease: "power2.in" })
          .to(panel, { yPercent: -100, duration: 0.8, delay: 0.3, ease: "power2.inOut" }, "-=0.2");
      });

      // Reduced motion: nothing moves. The word shows filled for a moment, then the panel fades away.
      mm.add("(prefers-reduced-motion: reduce)", () => {
        gsap.set(panel, { y: 0, yPercent: 0, visibility: "visible", opacity: 1 });
        gsap.set(glyphs, { y: 0, yPercent: 0 });
        gsap.set(fills, { clipPath: "inset(0% 0% 0% 0%)" });
        gsap.to(panel, { opacity: 0, duration: 0.4, delay: 0.9, ease: "none", onComplete: finish });
      });

      return () => mm.revert();
    },
    // Only the numbers and the text restart it. Colours are plain styles, so they change live.
    { scope: panelRef, dependencies: [text, slideUp, reveal, slideDown, stagger, betweenAnimations, beforeSlideDown, replayKey], revertOnUpdate: true },
  );

  // One size rule: it grows with the panel's width, and shrinks for long words so they always fit
  const fontSize = `clamp(2rem, min(20cqi, ${(88 / (Math.max(letters.length, 3) * 0.66)).toFixed(2)}cqi), 8rem)`;

  return (
    <div
      ref={panelRef}
      role="status"
      className={`${fullscreen ? "fixed" : "absolute"} inset-0 z-50 flex items-center justify-center overflow-hidden ${className}`}
      style={{ backgroundColor, containerType: "inline-size" }}
    >
      <span className="sr-only">{text}</span>
      <div aria-hidden="true" className="flex overflow-hidden px-[0.04em]" style={{ fontSize, fontWeight: 700, lineHeight: 1.05, letterSpacing: "-0.01em" }}>
        {letters.map((letter, index) => (
          <span
            key={`${letter}-${index}`}
            data-glyph
            className="relative inline-block whitespace-pre"
            style={{ color: ghostColor ?? ghostFor(backgroundColor), transform: "translateY(110%)" }}
          >
            {letter}
            {/* The same letter on top, filled with the gradient and wiped in from the bottom */}
            <span
              data-fill
              className="absolute inset-0"
              style={{ backgroundImage: gradient, backgroundClip: "text", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", clipPath: "inset(100% 0% 0% 0%)" }}
            >
              {letter}
            </span>
          </span>
        ))}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { TextLoader, type TextLoaderProps } from "@/components/dimaac/text-loader";

// The page the loader opens. The photo is from Lummi (Lummi License: free for personal and commercial use).
const photo = "https://ui.dimaac.com/media/nature/alpine-dawn-16x9.jpg";

const palettes: Record<string, string[]> = {
  ember: ["#ff0000", "#ff3333", "#ff6600", "#cc0000"],
  ocean: ["#0066ff", "#3399ff", "#00ccff", "#0099cc"],
  dusk: ["#8b5cf6", "#a78bfa", "#c084fc", "#ddd6fe"],
  forest: ["#22c55e", "#16a34a", "#15803d", "#166534"],
  lime: ["#d9f99d", "#bef264", "#a3e635", "#84cc16"],
};

// The playground passes flat values, so the demo folds them back into the component's objects
type DemoProps = {
  text: string;
  palette: string;
  backgroundColor: string;
  slideUp: number;
  reveal: number;
  slideDown: number;
  stagger: number;
  betweenAnimations: number;
  beforeSlideDown: number;
} & Pick<TextLoaderProps, "className">;

export default function Demo({ text, palette = "ember", backgroundColor, slideUp, reveal, slideDown, stagger, betweenAnimations, beforeSlideDown, className }: Partial<DemoProps>) {
  const [replayKey, setReplayKey] = useState(0);

  // Colours are plain styles and the panel is gone once the run ends, so replay when they change
  const firstRender = useRef(true);
  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }
    setReplayKey((key) => key + 1);
  }, [palette, backgroundColor]);

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#14161c] text-[#f4f1ea]">
      {/* The page underneath */}
      <img src={photo} alt="Snow-covered mountains at sunrise under a pink and orange sky" className="absolute inset-0 h-full w-full object-cover" decoding="async" />
      <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-black/30" />

      <div className="absolute inset-x-6 bottom-8 flex flex-col items-start gap-6 md:inset-x-10 md:bottom-10 md:flex-row md:items-end md:justify-between">
        <h1 className="max-w-[12ch] text-[clamp(44px,8vw,120px)] leading-[0.92] font-medium tracking-[-0.045em]">First light, high up</h1>
        <button
          type="button"
          onClick={() => setReplayKey((key) => key + 1)}
          className="cursor-pointer rounded-full border border-white/40 bg-white/10 px-5 py-2.5 font-mono text-[12px] tracking-wide uppercase backdrop-blur-md transition-colors hover:bg-white/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
        >
          Replay loader
        </button>
      </div>

      <TextLoader
        text={text}
        gradientColors={palettes[palette] ?? palettes.ember}
        backgroundColor={backgroundColor}
        duration={{ slideUp, reveal, slideDown }}
        delays={{ stagger, betweenAnimations, beforeSlideDown }}
        replayKey={replayKey}
        className={className}
      />
    </div>
  );
}
```

## Agent prompt

```text
Add the Text Loader component (<TextLoader />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/text-loader
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/text-loader.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/text-loader.json

Then use it like this:
import { TextLoader } from "@/components/dimaac/text-loader";
<TextLoader />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/text-loader.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Text scramble reveal
description: Letters scramble near the mouse.
category: Text
tier: free
url: https://ui.dimaac.com/components/text-scramble-reveal
registry: https://ui.dimaac.com/r/text-scramble-reveal.json
---

# Text scramble reveal

A paragraph whose letters shuffle through random characters and settle back into place. It plays once when the text scrolls into view, resolving left to right, and it scrambles the letters near the pointer as you move across it (a fast flick leaves no gaps, and a finger works the same). The letters are real spans that React owns: the scramble paints a CSS overlay instead of rewriting text, so the layout never jumps, changing the text never crashes React, and screen readers get the real sentence once. Reduced motion shows the plain text. Ported from the first DiMaac UI, where changing the children could crash, and rebuilt with one shared GSAP ticker and cached letter positions.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/text-scramble-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/text-scramble-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/text-scramble-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/text-scramble-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/text-scramble-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { TextScrambleReveal } from "@/components/dimaac/text-scramble-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { TextScrambleReveal } from "@/components/dimaac/text-scramble-reveal";

<TextScrambleReveal />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | - | The words to show. Plain text. |
| `mode` | `"hover" \| "reveal" \| "both"` | `"both"` | When it scrambles: "reveal" plays once on entering the view, "hover" reacts to the pointer, "both" does the two. |
| `scrambleChars` | `string` | `".:·+*-"` | The random characters a letter shuffles through. Every character in the string is one choice. |
| `scrambleColor` | `string` | `"#00a8e8"` | The colour of the random characters, and of the flash a letter leaves as it settles. Any CSS colour. |
| `scrambleDuration` | `number` | `0.45` | How long one letter keeps shuffling, in seconds. Each letter varies a little around it. |
| `radius` | `number` | `80` | How close the pointer must come to a letter to scramble it, in pixels. |
| `stagger` | `number` | `0.01` | In "reveal" mode, the gap between one letter starting to settle and the next, in seconds. Long texts are sped up to finish in about two seconds. |
| `once` | `boolean` | `true` | Play the reveal only the first time it is seen (true), or every time it scrolls back into view (false). |
| `fontSize` | `number \| string` | - | Text size. A number is pixels, a string is any CSS size such as "clamp(18px, 4vw, 40px)". Leave it out to inherit. |
| `lineHeight` | `number` | `1.3` | Line height, as a multiple of the text size. |
| `className` | `string` | `""` | Extra classes for the paragraph: font, colour, width. The text takes its colour from here. |

## Source

### text-scramble-reveal.tsx → `components/dimaac/text-scramble-reveal.tsx`

```tsx
"use client";

import { Fragment, useRef, type CSSProperties } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// Text that shuffles through other characters and settles into place. It can play once when the
// text scrolls into view (letters resolve left to right), and it can scramble the letters near
// the pointer as you move across it.
//
// The letters are real spans that React renders and owns. The scramble never edits their text.
// It sets a data attribute, and a CSS overlay (::after) paints the random glyph on top of the
// real letter, which is hidden for that moment. So the layout never jumps, React never loses a
// node it expects, and changing the `text` prop is safe.

export type TextScrambleRevealProps = {
  /** The words to show. Plain text. */
  text: string;
  /** When it scrambles: "reveal" plays once on entering the view, "hover" reacts to the pointer, "both" does the two. */
  mode?: "hover" | "reveal" | "both";
  /** The random characters a letter shuffles through. Every character in the string is one choice. */
  scrambleChars?: string;
  /** The colour of the random characters, and of the flash a letter leaves as it settles. Any CSS colour. */
  scrambleColor?: string;
  /** How long one letter keeps shuffling, in seconds. Each letter varies a little around it. */
  scrambleDuration?: number;
  /** How close the pointer must come to a letter to scramble it, in pixels. */
  radius?: number;
  /** In "reveal" mode, the gap between one letter starting to settle and the next, in seconds. Long texts are sped up to finish in about two seconds. */
  stagger?: number;
  /** Play the reveal only the first time it is seen (true), or every time it scrolls back into view (false). */
  once?: boolean;
  /** Text size. A number is pixels, a string is any CSS size such as "clamp(18px, 4vw, 40px)". Leave it out to inherit. */
  fontSize?: number | string;
  /** Line height, as a multiple of the text size. */
  lineHeight?: number;
  /** Extra classes for the paragraph: font, colour, width. The text takes its colour from here. */
  className?: string;
};

type Job = { end: number; swapAt: number };

export function TextScrambleReveal({
  text,
  mode = "both",
  scrambleChars = ".:·+*-",
  scrambleColor = "#00a8e8",
  scrambleDuration = 0.45,
  radius = 80,
  stagger = 0.01,
  once = true,
  fontSize,
  lineHeight = 1.3,
  className = "",
}: TextScrambleRevealProps) {
  const boxRef = useRef<HTMLParagraphElement>(null);

  // Read at the moment they are needed, so changing them never restarts the effect
  const settings = useRef({ glyphs: [".", ":"], duration: scrambleDuration, radius, color: scrambleColor });
  const glyphs = Array.from(scrambleChars).filter((c) => c.trim() !== "");
  settings.current = { glyphs: glyphs.length > 0 ? glyphs : ["."], duration: scrambleDuration, radius, color: scrambleColor };

  useGSAP(
    () => {
      const box = boxRef.current;
      if (!box) return;
      const letters = Array.from(box.querySelectorAll<HTMLElement>("[data-letter]"));
      if (letters.length === 0) return;

      // Reduced motion: no scrambling at all. The text is simply there.
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const wantsReveal = mode === "reveal" || mode === "both";
        const wantsHover = mode === "hover" || mode === "both";

        const jobs = new Map<number, Job>(); // letters that are shuffling right now
        const flashes = new Set<Animation>(); // the colour fade after a letter settles
        let centres: { x: number; y: number }[] = [];
        let ticking = false;
        let ready = !wantsReveal; // hover waits until the reveal has started
        let last: { x: number; y: number } | null = null;

        const randomGlyph = () => settings.current.glyphs[Math.floor(Math.random() * settings.current.glyphs.length)] ?? ".";
        const swapGap = () => 0.04 + Math.random() * 0.03;

        // Settling: show the real letter again and fade it from the scramble colour
        const settle = (letter: HTMLElement) => {
          delete letter.dataset.s;
          const flash = letter.animate([{ color: settings.current.color }], { duration: 650, easing: "ease-out" });
          flashes.add(flash);
          flash.onfinish = flash.oncancel = () => flashes.delete(flash);
        };

        const tick = () => {
          const now = gsap.ticker.time;
          jobs.forEach((job, index) => {
            const letter = letters[index]!;
            if (now >= job.end) {
              jobs.delete(index);
              settle(letter);
            } else if (now >= job.swapAt) {
              letter.dataset.s = randomGlyph();
              job.swapAt = now + swapGap();
            }
          });
          if (jobs.size === 0) {
            gsap.ticker.remove(tick);
            ticking = false;
          }
        };

        // One shared ticker loop for every shuffling letter. It sleeps when nothing is shuffling.
        const scramble = (index: number, delay = 0) => {
          const now = gsap.ticker.time;
          const length = settings.current.duration * (0.75 + Math.random() * 0.5);
          letters[index]!.dataset.s = randomGlyph();
          jobs.set(index, { end: now + delay + length, swapAt: now + swapGap() });
          if (!ticking) {
            ticking = true;
            gsap.ticker.add(tick);
          }
        };

        // Where each letter is, relative to the paragraph. Measured once, not on every pointer move.
        const measure = () => {
          const frame = box.getBoundingClientRect();
          centres = letters.map((letter) => {
            const r = letter.getBoundingClientRect();
            return { x: r.left - frame.left + r.width / 2, y: r.top - frame.top + r.height / 2 };
          });
        };

        const resize = new ResizeObserver(measure);
        resize.observe(box);
        let fontsDone = false;
        document.fonts?.ready.then(() => {
          if (!fontsDone) measure();
        });

        // The reveal: every letter starts as a random glyph and settles in reading order
        const hideAll = () => {
          letters.forEach((letter) => {
            letter.dataset.s = randomGlyph();
          });
        };
        const playReveal = () => {
          jobs.forEach((_job, index) => delete letters[index]!.dataset.s);
          jobs.clear();
          const step = Math.min(stagger, 2 / letters.length);
          letters.forEach((_letter, index) => scramble(index, index * step));
          ready = true;
        };

        let view: IntersectionObserver | null = null;
        if (wantsReveal) {
          hideAll();
          view = new IntersectionObserver(
            ([entry]) => {
              if (!entry) return;
              if (entry.isIntersecting) {
                playReveal();
                if (once) view?.disconnect();
              } else if (!once) {
                // Left the screen: get ready to play again
                jobs.clear();
                hideAll();
                ready = false;
              }
            },
            { threshold: 0.35 },
          );
          view.observe(box);
        }

        // Hover: scramble every letter within `radius` of the path the pointer just travelled,
        // so a fast flick leaves no gaps.
        const touch = (x: number, y: number) => {
          const from = last ?? { x, y };
          const dx = x - from.x;
          const dy = y - from.y;
          const lengthSquared = dx * dx + dy * dy;
          const reach = settings.current.radius;
          centres.forEach((c, index) => {
            if (jobs.has(index)) return;
            const along = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((c.x - from.x) * dx + (c.y - from.y) * dy) / lengthSquared));
            const ox = c.x - (from.x + dx * along);
            const oy = c.y - (from.y + dy * along);
            if (ox * ox + oy * oy < reach * reach) scramble(index);
          });
          last = { x, y };
        };

        const onMove = (event: PointerEvent) => {
          if (!ready || !event.isPrimary) return;
          const frame = box.getBoundingClientRect();
          touch(event.clientX - frame.left, event.clientY - frame.top);
        };
        const onLeave = () => {
          last = null;
        };

        if (wantsHover) {
          box.addEventListener("pointermove", onMove);
          box.addEventListener("pointerdown", onMove);
          box.addEventListener("pointerleave", onLeave);
          box.addEventListener("pointercancel", onLeave);
        }

        return () => {
          fontsDone = true;
          gsap.ticker.remove(tick);
          resize.disconnect();
          view?.disconnect();
          box.removeEventListener("pointermove", onMove);
          box.removeEventListener("pointerdown", onMove);
          box.removeEventListener("pointerleave", onLeave);
          box.removeEventListener("pointercancel", onLeave);
          flashes.forEach((flash) => flash.cancel());
          flashes.clear();
          jobs.clear();
          // Put the real letters back, so a re-run (or a change of props) starts clean
          letters.forEach((letter) => delete letter.dataset.s);
        };
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [text, mode, once, stagger], revertOnUpdate: true },
  );

  const words = text.trim().split(/\s+/).filter(Boolean);

  return (
    <p
      ref={boxRef}
      className={`relative ${className}`}
      style={{ fontSize, lineHeight, touchAction: "pan-y", "--scramble-color": scrambleColor } as CSSProperties}
    >
      {/* Screen readers get the real sentence once. The letters below are only the picture of it. */}
      <span className="sr-only">{text}</span>
      <span aria-hidden="true">
        {words.map((word, w) => (
          <Fragment key={`${w}-${word}`}>
            {w > 0 ? " " : null}
            {/* A word never breaks across lines */}
            <span className="whitespace-nowrap">
              {Array.from(word).map((letter, i) => (
                <span
                  key={i}
                  data-letter=""
                  className="relative data-[s]:text-transparent after:pointer-events-none after:absolute after:inset-x-0 after:top-0 after:text-center after:content-[attr(data-s)] after:text-[color:var(--scramble-color)]"
                >
                  {letter}
                </span>
              ))}
            </span>
          </Fragment>
        ))}
      </span>
    </p>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { TextScrambleReveal, type TextScrambleRevealProps } from "@/components/dimaac/text-scramble-reveal";

const copy =
  "Every letter is a small decision. Move through this paragraph and the type loosens, shuffles through a handful of other marks, and settles back into place. Nothing is lost, only briefly reconsidered.";

export default function Demo(props: Partial<TextScrambleRevealProps>) {
  // Replay remounts the paragraph, so the reveal plays again
  const [run, setRun] = useState(0);
  const { fontSize = 44, className = "", text = copy, ...rest } = props;
  // The size is the desktop size; on a narrow stage it shrinks with the width
  const size = typeof fontSize === "number" ? `min(${fontSize}px, ${(fontSize / 7).toFixed(2)}vw)` : fontSize;

  return (
    <>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap" precedence="default" />
      <div className="relative flex h-full w-full flex-col justify-center overflow-hidden bg-[#f3f0eb] px-6 text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4] md:px-[8vw]">
        <header className="absolute inset-x-6 top-6 flex items-center justify-between font-mono text-[11px] tracking-wide uppercase md:inset-x-[8vw]">
          <span aria-hidden="true" />
          <button
            type="button"
            onClick={() => setRun((n) => n + 1)}
            className="cursor-pointer rounded-full border border-current/25 px-3 py-1 tracking-wide uppercase opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2"
          >
            Replay
          </button>
        </header>

        <TextScrambleReveal
          key={run}
          text={text}
          fontSize={size}
          className={`max-w-[20em] cursor-crosshair font-['Instrument_Serif',Georgia,serif] opacity-90 ${className}`}
          {...rest}
        />

        <footer className="absolute inset-x-6 bottom-6 flex justify-between font-mono text-[11px] tracking-wide uppercase opacity-60 md:inset-x-[8vw]">
          <span>
            <span className="[@media(hover:none)]:hidden">Move your cursor across the text</span>
            <span className="hidden [@media(hover:none)]:inline">Drag a finger across the text</span>
          </span>
        </footer>
      </div>
    </>
  );
}
```

## Agent prompt

```text
Add the Text scramble reveal component (<TextScrambleReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/text-scramble-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/text-scramble-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/text-scramble-reveal.json

Then use it like this:
import { TextScrambleReveal } from "@/components/dimaac/text-scramble-reveal";
<TextScrambleReveal />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/text-scramble-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Tilt Menu
description: The page swings aside and tilts to show a full menu behind it.
category: Navigation
tier: free
url: https://ui.dimaac.com/components/tilt-menu
registry: https://ui.dimaac.com/r/tilt-menu.json
---

# Tilt Menu

A full-screen menu that lives behind the page. Opening it swings the page away to one side and tilts it like a door on a slow-fast-slow curve, its corners rounding off as it lifts, while the links slide in after it. Every label rolls up letter by letter on hover or keyboard focus, and a link can open a small photo out of its label. The menu button is a real button with aria-expanded; focus moves into the menu, Tab stays inside it, Escape or a click on the tilted page closes it, and focus goes back to the button. With reduced motion the page simply fades over the menu and the letters stay still. Built with GSAP, SplitText and CustomEase.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/tilt-menu.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/tilt-menu.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/tilt-menu.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/tilt-menu`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/tilt-menu.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { TiltMenu } from "@/components/dimaac/tilt-menu" and give its parent a size: it fills it.

## Usage

```tsx
import { TiltMenu } from "@/components/dimaac/tilt-menu";

<TiltMenu links={links} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `links` (required) | `TiltMenuLink[]` | - | The menu's links, top first. Three to six short words look best. |
| `tilt` | `number` | `12` | How far the page tilts when the menu is open, in degrees. |
| `shift` | `number` | `50` | How far the page slides aside when the menu is open, as a share of its width (50 = half). |
| `duration` | `number` | `0.77` | How long the page takes to swing open or shut, in seconds. |
| `side` | `"left" \| "right"` | `"right"` | The side the menu opens on. The page swings away to the other side. |
| `open` | `boolean` | - | Opens the menu from outside. Leave it out and the component keeps its own state. |
| `defaultOpen` | `boolean` | `false` | Whether the menu starts open (only when `open` is left out). |
| `onOpenChange` | `(open: boolean) => void` | - | Called with the new state when the menu opens or closes. |
| `brand` | `ReactNode` | - | The logo or name at the top of the page, next to the menu button. |
| `menuLabel` | `string` | `"Menu"` | Text on the button that opens the menu. |
| `closeLabel` | `string` | `"Close"` | Text on the button that closes it. |
| `menuFooter` | `ReactNode` | - | Small print at the bottom of the menu, e.g. an email address. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `pageClassName` | `string` | `"bg-white text-black dark:bg-neutral-950 dark:text-white"` | Classes for the page: its background and text colour. |
| `menuClassName` | `string` | `"bg-black text-white dark:bg-[#efede8] dark:text-black"` | Classes for the menu: its background and text colour. |
| `linkClassName` | `string` | `"text-[clamp(2.5rem,8vw,4.5rem)] leading-[0.95] font-bold tracking-[-0.02em] uppercase"` | Classes for each link: its font and size. |
| `buttonClassName` | `string` | `"text-[1.5rem] leading-none uppercase"` | Classes for the menu and close buttons. |
| `children` | `ReactNode` | - | The page itself. It scrolls inside the box. |

## Credits

- Portrait: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Portrait: Benginur Hajjaj, Lummi (https://www.lummi.ai/photo/calm-portrait-d1zqo), Lummi License
- Portrait: Steph Meade, Lummi (https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc), Lummi License
- Portrait: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-woman-portrait-htofy), Lummi License
- Portrait: studiomiklus, Lummi (https://www.lummi.ai/photo/striking-portrait-jodiu), Lummi License
- Portrait: Shaun Duval, Lummi (https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg), Lummi License
- Portrait: Lloyd Creates, Lummi (https://www.lummi.ai/photo/poised-portrait-ooi9b), Lummi License

## Source

### tilt-menu.tsx → `components/dimaac/tilt-menu.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
import { CustomEase } from "gsap/CustomEase";
import { SplitText } from "gsap/SplitText";

// A full-screen menu that sits behind the page. Opening it swings the page aside and tilts it,
// like a door, so the menu shows in the gap. Every label rolls up letter by letter on hover:
// each label is written twice, one copy on top of the other, and the letters of the top copy
// slide up out of their masks while the letters of the copy below slide in.

if (typeof window !== "undefined") {
  gsap.registerPlugin(useGSAP, CustomEase, SplitText);
  // The swing: slow start, quick middle, soft landing
  if (!CustomEase.get("tiltMenu")) CustomEase.create("tiltMenu", "M0,0 C0.55,0 0.45,1 1,1");
}

export type TiltMenuLink = {
  /** The text of the link */
  label: string;
  /** Where it goes */
  href: string;
  /** A photo that opens next to the label on hover. Decorative, so it has no alt text. */
  image?: string;
};

export type TiltMenuProps = {
  /** The menu's links, top first. Three to six short words look best. */
  links: TiltMenuLink[];
  /** How far the page tilts when the menu is open, in degrees. */
  tilt?: number;
  /** How far the page slides aside when the menu is open, as a share of its width (50 = half). */
  shift?: number;
  /** How long the page takes to swing open or shut, in seconds. */
  duration?: number;
  /** The side the menu opens on. The page swings away to the other side. */
  side?: "left" | "right";
  /** Opens the menu from outside. Leave it out and the component keeps its own state. */
  open?: boolean;
  /** Whether the menu starts open (only when `open` is left out). */
  defaultOpen?: boolean;
  /** Called with the new state when the menu opens or closes. */
  onOpenChange?: (open: boolean) => void;
  /** The logo or name at the top of the page, next to the menu button. */
  brand?: ReactNode;
  /** Text on the button that opens the menu. */
  menuLabel?: string;
  /** Text on the button that closes it. */
  closeLabel?: string;
  /** Small print at the bottom of the menu, e.g. an email address. */
  menuFooter?: ReactNode;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Classes for the page: its background and text colour. */
  pageClassName?: string;
  /** Classes for the menu: its background and text colour. */
  menuClassName?: string;
  /** Classes for each link: its font and size. */
  linkClassName?: string;
  /** Classes for the menu and close buttons. */
  buttonClassName?: string;
  /** The page itself. It scrolls inside the box. */
  children?: ReactNode;
};

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// A label written twice for the roll. Screen readers read the hidden copy once; the two visible
// copies are aria-hidden. The bottom copy stays invisible until its letters are split and moved down.
function RollText({ label }: { label: string }) {
  return (
    <span data-roll className="relative inline-block whitespace-nowrap">
      <span className="sr-only">{label}</span>
      <span data-roll-front aria-hidden="true" className="inline-block">
        {label}
      </span>
      <span data-roll-back aria-hidden="true" className="absolute top-0 left-0 inline-block" style={{ visibility: "hidden" }}>
        {label}
      </span>
    </span>
  );
}

export function TiltMenu({
  links,
  tilt = 12,
  shift = 50,
  duration = 0.77,
  side = "right",
  open,
  defaultOpen = false,
  onOpenChange,
  brand,
  menuLabel = "Menu",
  closeLabel = "Close",
  menuFooter,
  className = "",
  pageClassName = "bg-white text-black dark:bg-neutral-950 dark:text-white",
  menuClassName = "bg-black text-white dark:bg-[#efede8] dark:text-black",
  linkClassName = "text-[clamp(2.5rem,8vw,4.5rem)] leading-[0.95] font-bold tracking-[-0.02em] uppercase",
  buttonClassName = "text-[1.5rem] leading-none uppercase",
  children,
}: TiltMenuProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const pageRef = useRef<HTMLDivElement>(null);
  const navRef = useRef<HTMLElement>(null);
  const toggleRef = useRef<HTMLButtonElement>(null);
  const closeRef = useRef<HTMLButtonElement>(null);
  const menuId = useId();
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);

  // Controlled when `open` is passed, otherwise the component keeps its own state
  const [ownOpen, setOwnOpen] = useState(defaultOpen);
  const isOpen = open ?? ownOpen;
  const onOpenChangeRef = useRef(onOpenChange);
  onOpenChangeRef.current = onOpenChange;
  const setOpen = (next: boolean) => {
    if (open === undefined) setOwnOpen(next);
    onOpenChangeRef.current?.(next);
  };
  const setOpenRef = useRef(setOpen);
  setOpenRef.current = setOpen;

  // +1 when the menu is on the right (the page swings left), -1 when it's on the left
  const direction = side === "right" ? 1 : -1;

  // The rolls. Built again only when the labels change, not on every render.
  const labelsKey = [menuLabel, closeLabel, ...links.map((link) => `${link.label} ${link.image ?? ""}`)].join("|");
  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;
      const cleanups: (() => void)[] = [];

      root.querySelectorAll<HTMLElement>("[data-roll]").forEach((roll) => {
        const front = roll.querySelector<HTMLElement>("[data-roll-front]");
        const back = roll.querySelector<HTMLElement>("[data-roll-back]");
        const trigger = roll.closest<HTMLElement>("a, button") ?? roll;
        const thumb = trigger.querySelector<HTMLElement>("[data-thumb]");
        if (!front || !back) return;

        // The photo opens out of the label: from its right edge when it sits left of the label
        const hiddenClip = trigger.dataset.side === "right" ? "inset(0% 0% 0% 100%)" : "inset(0% 100% 0% 0%)";
        let play: () => void;
        let reverse: () => void;

        if (reducedMotion) {
          // No roll: the photo just shows while the link is hovered or focused
          if (thumb) gsap.set(thumb, { clipPath: hiddenClip });
          play = () => thumb && gsap.set(thumb, { clipPath: "inset(0% 0% 0% 0%)" });
          reverse = () => thumb && gsap.set(thumb, { clipPath: hiddenClip });
        } else {
          const outgoing = SplitText.create(front, { type: "chars", mask: "chars", aria: "none" });
          const incoming = SplitText.create(back, { type: "chars", mask: "chars", aria: "none" });
          gsap.set(incoming.chars, { yPercent: 100 });
          gsap.set(back, { visibility: "visible" });

          const label = front.textContent ?? "";
          const roll = gsap.timeline({
            paused: true,
            defaults: { duration: 0.45, ease: "power3.inOut", stagger: Math.min(0.022, 0.25 / Math.max(label.length, 1)) },
          });
          roll.to(outgoing.chars, { yPercent: -100 }).to(incoming.chars, { yPercent: 0 }, 0);
          if (thumb) {
            roll.fromTo(thumb, { clipPath: hiddenClip }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.6, ease: "power3.out", stagger: 0 }, 0);
            roll.fromTo(thumb.firstElementChild, { scale: 1.35 }, { scale: 1, duration: 0.7, ease: "power3.out", stagger: 0 }, 0);
          }
          play = () => roll.play();
          reverse = () => roll.reverse();
          cleanups.push(() => {
            roll.kill();
            outgoing.revert();
            incoming.revert();
          });
        }

        // Keyboard focus rolls it too, but not the focus a click leaves behind
        const onFocus = () => trigger.matches(":focus-visible") && play();
        trigger.addEventListener("mouseenter", play);
        trigger.addEventListener("mouseleave", reverse);
        trigger.addEventListener("focus", onFocus);
        trigger.addEventListener("blur", reverse);
        cleanups.push(() => {
          trigger.removeEventListener("mouseenter", play);
          trigger.removeEventListener("mouseleave", reverse);
          trigger.removeEventListener("focus", onFocus);
          trigger.removeEventListener("blur", reverse);
        });
      });

      return () => cleanups.forEach((cleanup) => cleanup());
    },
    { scope: rootRef, dependencies: [labelsKey, side, reducedMotion], revertOnUpdate: true },
  );

  // The swing. A new tilt, shift or side while the menu is open moves the page straight there.
  const firstSwing = useRef(true);
  const wasOpen = useRef(isOpen);
  useGSAP(
    () => {
      const page = pageRef.current;
      const nav = navRef.current;
      if (!page || !nav) return;
      const items = nav.querySelectorAll<HTMLElement>("[data-menu-item]");
      const instant = firstSwing.current;
      const toggled = wasOpen.current !== isOpen;
      firstSwing.current = false;
      wasOpen.current = isOpen;

      if (reducedMotion) {
        // No swing: the page fades out over the menu, and back in
        gsap.set(page, { xPercent: 0, rotation: 0, borderRadius: 0 });
        gsap.set(items, { x: 0, autoAlpha: 1 });
        const fade = instant ? 0 : 0.25;
        if (isOpen) {
          // Hidden only once the fade ends, so the faded page can't be clicked or focused
          gsap.to(page, { opacity: 0, duration: fade, ease: "none", overwrite: true, onComplete: () => gsap.set(page, { visibility: "hidden" }) });
        } else {
          // Visible straight away (this runs before the focus effect), so focus can go back to the menu button
          gsap.set(page, { visibility: "visible" });
          gsap.to(page, { opacity: 1, duration: fade, ease: "none", overwrite: true });
        }
      } else {
        gsap.set(page, { opacity: 1, visibility: "visible" });
        gsap.to(page, {
          xPercent: isOpen ? -direction * shift : 0,
          rotation: isOpen ? direction * tilt : 0,
          // The corners round off as it leaves, so it reads as a card lifted off the menu
          borderRadius: isOpen ? 18 : 0,
          duration: instant ? 0 : duration,
          ease: "tiltMenu",
          overwrite: true,
        });

        if (instant) gsap.set(items, { x: 0, autoAlpha: isOpen ? 1 : 0 });
        else if (toggled && isOpen) {
          // The links follow the page in from the menu's side, one after another
          gsap.fromTo(
            items,
            { x: direction * 70, autoAlpha: 0 },
            { x: 0, autoAlpha: 1, duration: duration * 1.1, delay: duration * 0.22, ease: "power3.out", stagger: 0.055, overwrite: true },
          );
        } else if (toggled) {
          gsap.to(items, { x: direction * 30, autoAlpha: 0, duration: duration * 0.55, ease: "power2.in", overwrite: true });
        }
      }

      return () => {
        // Unmounting (or React's StrictMode remount) starts over without a swing
        firstSwing.current = true;
      };
    },
    { scope: rootRef, dependencies: [isOpen, tilt, shift, duration, direction, reducedMotion] },
  );

  // Focus goes into the menu when the menu button opens it, and back to the button when it closes.
  // An open from outside (the `open` prop) leaves focus where it is; Tab still lands in the menu,
  // because the page is inert while the menu is open.
  const lastOpen = useRef(isOpen);
  const openedByButton = useRef(false);
  useEffect(() => {
    if (lastOpen.current === isOpen) return;
    lastOpen.current = isOpen;
    if (isOpen) {
      if (openedByButton.current) closeRef.current?.focus({ preventScroll: true });
      openedByButton.current = false;
    } else {
      const active = document.activeElement;
      if (!active || active === document.body || navRef.current?.contains(active)) toggleRef.current?.focus({ preventScroll: true });
    }
  }, [isOpen]);

  // Escape closes the menu, wherever focus is
  useEffect(() => {
    if (!isOpen) return;
    const onKeyDown = (event: globalThis.KeyboardEvent) => {
      if (event.key === "Escape") setOpenRef.current(false);
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [isOpen]);

  // Tab and Shift+Tab go round the menu instead of leaving it
  const trapFocus = (event: KeyboardEvent<HTMLElement>) => {
    if (event.key !== "Tab" || !navRef.current) return;
    const focusable = Array.from(navRef.current.querySelectorAll<HTMLElement>("a[href], button:not([disabled])"));
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    if (!first || !last) return;
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  };

  const menuSide = side === "right" ? "right-[30px] text-right" : "left-[30px] text-left";

  return (
    <div ref={rootRef} className={`relative h-full w-full overflow-hidden ${className}`}>
      <nav id={menuId} ref={navRef} aria-label="Menu" inert={!isOpen} onKeyDown={trapFocus} className={`absolute inset-0 ${menuClassName}`}>
        <button ref={closeRef} type="button" onClick={() => setOpen(false)} className={`absolute top-7 z-10 cursor-pointer border-0 bg-transparent p-0 [color:inherit] outline-offset-4 focus-visible:outline-2 focus-visible:outline-current ${menuSide} ${buttonClassName}`}>
          <RollText label={closeLabel} />
        </button>

        <ul className={`absolute inset-0 m-0 flex list-none flex-col justify-center gap-2 p-0 px-[30px] ${side === "right" ? "items-end" : "items-start"}`}>
          {links.map((link) => (
            <li key={`${link.label}-${link.href}`} data-menu-item>
              <a
                href={link.href}
                data-side={side}
                onClick={() => setOpen(false)}
                className={`flex items-center no-underline outline-offset-4 [color:inherit] focus-visible:outline-2 focus-visible:outline-current motion-reduce:transition-opacity motion-reduce:hover:opacity-60 ${side === "right" ? "flex-row" : "flex-row-reverse"} ${linkClassName}`}
              >
                {link.image ? (
                  <span data-thumb aria-hidden="true" className="mx-[0.14em] inline-block h-[0.78em] w-[1.25em] shrink-0 overflow-hidden rounded-[0.08em]" style={{ clipPath: side === "right" ? "inset(0% 0% 0% 100%)" : "inset(0% 100% 0% 0%)" }}>
                    <img src={link.image} alt="" draggable={false} decoding="async" className="block h-full w-full object-cover" />
                  </span>
                ) : null}
                <RollText label={link.label} />
              </a>
            </li>
          ))}
        </ul>

        {menuFooter ? (
          <div data-menu-item className={`absolute bottom-[30px] ${menuSide}`}>
            {menuFooter}
          </div>
        ) : null}
      </nav>

      <div ref={pageRef} className="absolute inset-0 z-10 overflow-hidden shadow-[0_40px_120px_-20px_rgba(0,0,0,0.55)]" style={{ willChange: "transform" }}>
        <div inert={isOpen} className={`h-full w-full overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${pageClassName}`}>
          <header className="sticky top-0 z-10 flex items-center justify-between gap-6 bg-inherit px-[30px] py-7">
            <div className="min-w-0">{brand}</div>
            <button
              ref={toggleRef}
              type="button"
              aria-expanded={isOpen}
              aria-controls={menuId}
              onClick={() => {
                openedByButton.current = true;
                setOpen(true);
              }}
              className={`cursor-pointer border-0 bg-transparent p-0 [color:inherit] outline-offset-4 focus-visible:outline-2 focus-visible:outline-current ${buttonClassName}`}
            >
              <RollText label={menuLabel} />
            </button>
          </header>
          {children}
        </div>
        {/* While the menu is open, a click on the tilted page closes it (the page itself is inert) */}
        {isOpen && !reducedMotion ? <div aria-hidden="true" onClick={() => setOpen(false)} className="absolute inset-0 cursor-pointer" /> : null}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { TiltMenu, type TiltMenuProps } from "@/components/dimaac/tilt-menu";

// The page from the video: a project page with the tilt menu behind it.
// Photos from Lummi (Lummi License), credited in meta.ts.

const links = [
  { label: "Work", href: "#work", image: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg" },
  { label: "Studio", href: "#studio", image: "https://ui.dimaac.com/media/portraits/calm-studio-4x5.jpg" },
  { label: "Process", href: "#process", image: "https://ui.dimaac.com/media/portraits/red-brushstroke-4x5.jpg" },
  { label: "Contact", href: "#contact", image: "https://ui.dimaac.com/media/portraits/tinted-glasses-4x5.jpg" },
];

const contents = [
  ["01", "Warm-ups", "12 frames"],
  ["02", "Colour tests", "Studies"],
  ["03", "Final prints", "Finals"],
];

const bebas = { fontFamily: '"Bebas Neue", Impact, sans-serif' };
const mono = "font-['Geist_Mono',ui-monospace,monospace] text-[14px] leading-[1.6] tracking-[-0.03em] uppercase";

type DemoProps = Partial<Omit<TiltMenuProps, "links" | "open" | "onOpenChange">>;

export default function Demo(props: DemoProps) {
  // The demo holds the open state itself. The playground's "Start open" switch sets it, and a change
  // to the swing (tilt, shift, side or duration) opens the menu too, so you can see what it did.
  const [open, setOpen] = useState(props.defaultOpen ?? false);
  const swing = `${props.tilt}|${props.shift}|${props.side}|${props.duration}`;
  const [seen, setSeen] = useState({ defaultOpen: props.defaultOpen, swing });
  if (seen.defaultOpen !== props.defaultOpen || seen.swing !== swing) {
    setSeen({ defaultOpen: props.defaultOpen, swing });
    setOpen(seen.defaultOpen !== props.defaultOpen ? (props.defaultOpen ?? false) : true);
  }

  return (
    <>
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Bebas+Neue&family=Geist+Mono&display=swap" precedence="default" />
      <TiltMenu
        {...props}
        links={links}
        open={open}
        onOpenChange={setOpen}
        className={`h-full w-full font-['Anton',Impact,sans-serif] ${props.className ?? ""}`}
        pageClassName="bg-white text-black dark:bg-[#0c0c0c] dark:text-[#f1eee8]"
        menuClassName="bg-black text-white dark:bg-[#f1eee8] dark:text-black"
        linkClassName="text-[clamp(2.5rem,8vw,4.5rem)] leading-[0.95] tracking-[-0.02em] uppercase"
        buttonClassName="font-['Bebas_Neue',Impact,sans-serif] text-[1.5rem] leading-none uppercase"
        brand={
          <a href="#top" className="text-[1.5rem] leading-none tracking-[0.02em] uppercase no-underline [color:inherit]" style={bebas}>
            DiMaac
          </a>
        }
        menuFooter={
          <p className={`${mono} m-0 opacity-55`}>
            ui.dimaac.com
          </p>
        }
      >
        <div className="pb-24">
          <section className="mx-auto max-w-[1080px] px-[30px] pt-[clamp(3rem,9vw,6rem)] pb-[clamp(2rem,5vw,3.5rem)]">
            <h1 className="m-0 text-[clamp(3rem,10.5vw,9.5rem)] leading-[0.89] font-normal tracking-[-0.02em] uppercase">Big Heads</h1>
            <p className={`${mono} mt-[clamp(1.5rem,3vw,2.25rem)] max-w-[46ch]`}>Forty portraits shot over one winter. Loud colour, close crops, one clear expression each.</p>
          </section>

          <figure className="mx-auto m-0 max-w-[1080px] px-[30px]">
            <img src="https://ui.dimaac.com/media/portraits/platinum-blonde-4x5.jpg" alt="A young man with platinum blonde hair and a yellow jacket against a bold orange background" className="block aspect-[4/5] w-full object-cover sm:aspect-[5/4]" />
          </figure>

          <section className="mx-auto max-w-[1080px] px-[30px] pt-[clamp(2.5rem,6vw,4.5rem)]">
            <p className="m-0 max-w-[40ch] text-[clamp(1.35rem,2.9vw,1.75rem)] leading-[1.2] tracking-[-0.01em]" style={bebas}>
              DiMaac shoots the faces that still read at thumbnail size: strong colour, a single clear expression, nothing spare.
            </p>
          </section>

          <section className="mx-auto max-w-[1080px] px-[30px] py-[clamp(4.5rem,11vw,7.5rem)]">
            <h2 className="m-0 mb-4 text-[18px] leading-[1.2] font-normal" style={bebas}>
              In this set
            </h2>
            <ul className="m-0 list-none p-0">
              {contents.map(([number, name, kind]) => (
                <li key={number} className={`${mono} grid grid-cols-[2.5rem_1fr] gap-4 border-t border-current py-[0.7rem] last:border-b sm:grid-cols-[3rem_1fr_auto]`}>
                  <span>{number}</span>
                  <span>{name}</span>
                  <span className="col-start-2 sm:col-start-auto">{kind}</span>
                </li>
              ))}
            </ul>
          </section>

          <section className="mx-auto grid max-w-[1080px] grid-cols-1 gap-[30px] px-[30px] sm:grid-cols-2">
            <figure className="m-0">
              <img loading="lazy" src="https://ui.dimaac.com/media/portraits/neon-profile-4x5.jpg" alt="A woman in neon pink sunglasses in profile against a vibrant blue background" className="block aspect-[4/5] w-full object-cover" />
            </figure>
            <figure className="m-0">
              <img loading="lazy" src="https://ui.dimaac.com/media/portraits/red-coat-4x5.jpg" alt="A person in a red coat against a deep blue background" className="block aspect-[4/5] w-full object-cover" />
            </figure>
          </section>

          <footer className="mx-auto mt-[clamp(2.5rem,6vw,4.5rem)] flex max-w-[1080px] flex-col justify-between gap-8 border-t border-current px-[30px] pt-[clamp(2.5rem,6vw,4rem)] sm:flex-row">
            <p className={`${mono} m-0 text-[12px] opacity-55`}>DiMaac</p>
          </footer>
        </div>
      </TiltMenu>
    </>
  );
}
```

## Agent prompt

```text
Add the Tilt Menu component (<TiltMenu />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/tilt-menu
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/tilt-menu.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/tilt-menu.json

Then use it like this:
import { TiltMenu } from "@/components/dimaac/tilt-menu";
<TiltMenu links={links} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/tilt-menu.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Velocity Marquee
description: A marquee that speeds up, leans and turns around with your scroll.
category: Text
tier: free
url: https://ui.dimaac.com/components/velocity-marquee
registry: https://ui.dimaac.com/r/velocity-marquee.json
---

# Velocity Marquee

An endless marquee of big words and small pictures. It drifts on its own; scroll the page and it speeds up and leans into the movement, scroll back up and it turns around. Stack up to three rows and they run in alternating directions, with every second row drawn as outlines if you like. The loop is measured and repeated to fit any width, so it never jumps, it sleeps while it is off screen, and under reduced motion it becomes a still, wrapping row of the same words and pictures. Works with the window or any scrolling element.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/velocity-marquee`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/velocity-marquee.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { VelocityMarquee } from "@/components/dimaac/velocity-marquee" and give its parent a size: it fills it.

## Usage

```tsx
import { VelocityMarquee } from "@/components/dimaac/velocity-marquee";

<VelocityMarquee items={items} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `VelocityMarqueeItem[]` | - | What the rows are made of, in order: words and images. |
| `speed` | `number` | `1` | How fast it drifts when nobody scrolls. 1 is about 90 pixels a second. |
| `velocityBoost` | `number` | `4` | How much scrolling speeds it up. 0 ignores the scroll speed, 4 is a strong push. |
| `skew` | `number` | `8` | The most it leans, in degrees, when the page scrolls fast. 0 never leans. |
| `direction` | `"left" \| "right"` | `"left"` | Which way the first row flows when the visitor scrolls down. Scrolling up flips it. |
| `gap` | `number` | `48` | Space between the items, in pixels. |
| `rows` | `number` | `2` | How many rows to stack, one to three. They flow in alternating directions. |
| `outlineAlternate` | `boolean` | `true` | Draw the words of every second row as outlines. |
| `scroller` | `HTMLElement \| string \| null` | `null` | The element that scrolls, or a selector for it. Leave it out to follow the window. |
| `className` | `string` | `""` | Extra classes for the outer box. |
| `itemClassName` | `string` | `"text-[clamp(64px,13vw,200px)] font-medium leading-[1.15] tracking-[-0.04em]"` | Classes for every item: the type size and weight live here. Images are sized in em, so they follow the text. |
| `imageClassName` | `string` | `"rounded-full"` | Classes for the image items. |

## Credits

- Photo: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Photo: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Photo: Pablo Stanley, Lummi (https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd), Lummi License
- Photo: marisol, Lummi (https://www.lummi.ai/photo/glossy-pink-sphere-45si5), Lummi License
- Photo: Amino, Lummi (https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow), Lummi License
- Photo: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License
- Photo: Viri Gutiérrez, Lummi (https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa), Lummi License

## Source

### velocity-marquee.tsx → `components/dimaac/velocity-marquee.tsx`

```tsx
"use client";

import { useLayoutEffect, useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// An endless line of big words and small pictures that drifts on its own. Scroll the page and it
// speeds up and leans into the movement; scroll back up and it turns around. With more than one
// row, every row runs against its neighbour.
//
// How the loop stays seamless: one "set" is the whole list of items. We measure it, then render
// as many copies as it takes to fill the box (plus one spare). Sliding the track by exactly one
// set's width lands on a picture identical to where it started, so we wrap with a modulo and
// nobody sees the jump. Every item carries its gap as padding, so a set's width includes the
// gap after its last item.

export type VelocityMarqueeTextItem = {
  /** A word or phrase. */
  text: string;
};

export type VelocityMarqueeImageItem = {
  /** The image address. */
  src: string;
  /** Alt text. Leave it out for a purely decorative image. */
  alt?: string;
};

export type VelocityMarqueeItem = VelocityMarqueeTextItem | VelocityMarqueeImageItem;

export type VelocityMarqueeProps = {
  /** What the rows are made of, in order: words and images. */
  items: VelocityMarqueeItem[];
  /** How fast it drifts when nobody scrolls. 1 is about 90 pixels a second. */
  speed?: number;
  /** How much scrolling speeds it up. 0 ignores the scroll speed, 4 is a strong push. */
  velocityBoost?: number;
  /** The most it leans, in degrees, when the page scrolls fast. 0 never leans. */
  skew?: number;
  /** Which way the first row flows when the visitor scrolls down. Scrolling up flips it. */
  direction?: "left" | "right";
  /** Space between the items, in pixels. */
  gap?: number;
  /** How many rows to stack, one to three. They flow in alternating directions. */
  rows?: number;
  /** Draw the words of every second row as outlines. */
  outlineAlternate?: boolean;
  /** The element that scrolls, or a selector for it. Leave it out to follow the window. */
  scroller?: HTMLElement | string | null;
  /** Extra classes for the outer box. */
  className?: string;
  /** Classes for every item: the type size and weight live here. Images are sized in em, so they follow the text. */
  itemClassName?: string;
  /** Classes for the image items. */
  imageClassName?: string;
};

// useLayoutEffect warns during server rendering, so fall back to useEffect there
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;

function resolveScroller(scroller: VelocityMarqueeProps["scroller"]): HTMLElement | null {
  if (!scroller) return null;
  if (typeof scroller === "string") return document.querySelector<HTMLElement>(scroller);
  return scroller;
}

/** Row 1 starts a few items further along than row 0, so the rows never read the same line. */
function rotate<T>(list: T[], by: number): T[] {
  if (list.length === 0) return list;
  const shift = ((by % list.length) + list.length) % list.length;
  return [...list.slice(shift), ...list.slice(0, shift)];
}

export function VelocityMarquee({
  items,
  speed = 1,
  velocityBoost = 4,
  skew = 8,
  direction = "left",
  gap = 48,
  rows = 2,
  outlineAlternate = true,
  scroller = null,
  className = "",
  itemClassName = "text-[clamp(64px,13vw,200px)] font-medium leading-[1.15] tracking-[-0.04em]",
  imageClassName = "rounded-full",
}: VelocityMarqueeProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const stackRef = useRef<HTMLDivElement>(null);
  const rowCount = Math.min(3, Math.max(1, Math.round(rows)));
  const [copies, setCopies] = useState(2);
  // The width of one set per row, in pixels. Written by the measuring effect, read every frame.
  const setWidths = useRef<number[]>([]);

  // These change from the playground without rebuilding anything: the loop reads them each frame
  const live = useRef({ speed, velocityBoost, skew, direction });
  live.current = { speed, velocityBoost, skew, direction };

  // Measure one set per row and work out how many copies fill the box
  useIsomorphicLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const sets = Array.from(root.querySelectorAll<HTMLElement>("[data-first-set]"));
    const widths = sets.map(() => 0);

    // The observer's contentRect is the layout size with its fraction (offsetWidth would round,
    // and getBoundingClientRect would include the skew), so the wrap lands exactly on the repeat
    const observer = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        const row = sets.indexOf(entry.target as HTMLElement);
        if (row !== -1) widths[row] = entry.contentRect.width;
      });
      setWidths.current = [...widths];
      const boxWidth = root.clientWidth;
      const needed = widths.reduce((most, width) => (width > 0 ? Math.max(most, Math.ceil(boxWidth / width) + 1) : most), 2);
      setCopies((current) => (current === needed ? current : needed));
    });
    // The box resizing, fonts loading and a changed gap all change a set's width
    observer.observe(root);
    sets.forEach((set) => observer.observe(set));
    return () => observer.disconnect();
  }, [rowCount]);

  useGSAP(
    () => {
      const root = rootRef.current;
      const stack = stackRef.current;
      if (!root || !stack) return;
      const tracks = Array.from(root.querySelectorAll<HTMLElement>("[data-track]"));
      const scrollElement = resolveScroller(scroller);
      const readScroll = () => (scrollElement ? scrollElement.scrollTop : window.scrollY);

      // Reduced motion: the CSS already lays the words out as a still, wrapping row. We add nothing.
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const slideTo = tracks.map((track) => gsap.quickSetter(track, "x", "px"));
        const lean = gsap.quickSetter(stack, "skewX", "deg");
        const offsets = tracks.map(() => 0);

        let lastScroll = readScroll();
        let velocity = 0; // pixels per second, smoothed. Positive means scrolling down.
        let scrollSign = 1; // the last direction the visitor scrolled in
        let flip = 1; // eases from 1 to -1 and back, so a turn-around slows down first
        let visible = true;

        const tick = (_time: number, deltaMs: number) => {
          // A long frame (a background tab, a debugger) would fling the rows, so cap it
          const dt = Math.min(deltaMs, 50) / 1000;
          if (dt <= 0) return;

          const scrollNow = readScroll();
          const rawVelocity = (scrollNow - lastScroll) / dt;
          lastScroll = scrollNow;
          if (!visible) return;

          // Wheel ticks arrive in bursts, so smooth them: it speeds up fast and settles slowly
          velocity += (rawVelocity - velocity) * (1 - Math.exp(-dt * 7));
          if (Math.abs(velocity) > 30) scrollSign = Math.sign(velocity);
          flip += (scrollSign - flip) * (1 - Math.exp(-dt * 5));

          const { speed: baseSpeed, velocityBoost: boost, skew: maxSkew, direction: baseDirection } = live.current;
          const push = 1 + boost * Math.min(Math.abs(velocity) / 1500, 2);
          const pixelsPerSecond = baseSpeed * 90 * push * flip;
          const firstRowSign = baseDirection === "left" ? 1 : -1;

          tracks.forEach((_, row) => {
            const width = setWidths.current[row] ?? 0;
            if (width <= 0) return;
            const rowSign = row % 2 === 0 ? firstRowSign : -firstRowSign;
            offsets[row] = (offsets[row]! + rowSign * pixelsPerSecond * dt) % width;
            // Always between -width and 0, so a copy is on screen before the previous one leaves
            slideTo[row]!(-(((offsets[row]! % width) + width) % width));
          });

          lean(firstRowSign * Math.max(-1, Math.min(1, velocity / 1500)) * maxSkew);
        };
        gsap.ticker.add(tick);

        // No work while the marquee is off screen
        const visibility = new IntersectionObserver(([entry]) => {
          visible = entry?.isIntersecting ?? true;
        });
        visibility.observe(root);

        return () => {
          gsap.ticker.remove(tick);
          visibility.disconnect();
          gsap.set([...tracks, stack], { clearProps: "transform" });
        };
      });

      return () => mm.revert();
    },
    { scope: rootRef, dependencies: [rowCount, scroller], revertOnUpdate: true },
  );

  const renderItem = (item: VelocityMarqueeItem, index: number, outline: boolean) => (
    <div key={index} className={`flex shrink-0 items-center ${itemClassName}`} style={{ paddingRight: gap }}>
      {"src" in item ? (
        <img src={item.src} alt={item.alt ?? ""} draggable={false} decoding="async" className={`block h-[0.8em] w-[1.4em] object-cover ${imageClassName}`} />
      ) : (
        <span className="whitespace-nowrap" style={outline ? { WebkitTextFillColor: "transparent", WebkitTextStroke: "0.02em currentColor", letterSpacing: "-0.01em" } : undefined}>
          {item.text}
        </span>
      )}
    </div>
  );

  return (
    <div ref={rootRef} className={`relative w-full overflow-hidden ${className}`}>
      <div ref={stackRef} className="flex flex-col will-change-transform">
        {Array.from({ length: rowCount }, (_, row) => {
          const rowItems = rotate(items, row * Math.ceil(items.length / 3));
          const outline = outlineAlternate && row % 2 === 1;
          return (
            // Rows after the first say the same thing again, so screen readers skip them
            <div
              key={row}
              data-track=""
              aria-hidden={row > 0 ? true : undefined}
              className={`flex w-max will-change-transform motion-reduce:w-full motion-reduce:flex-wrap motion-reduce:justify-center ${row > 0 ? "motion-reduce:hidden" : ""}`}
            >
              {Array.from({ length: copies }, (_, copy) => (
                <div
                  key={copy}
                  data-first-set={copy === 0 ? "" : undefined}
                  aria-hidden={copy > 0 ? true : undefined}
                  className={`flex shrink-0 items-center motion-reduce:w-full motion-reduce:shrink motion-reduce:flex-wrap motion-reduce:justify-center ${copy > 0 ? "motion-reduce:hidden" : ""}`}
                >
                  {rowItems.map((item, index) => renderItem(item, index, outline))}
                </div>
              ))}
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { VelocityMarquee, type VelocityMarqueeItem, type VelocityMarqueeProps } from "@/components/dimaac/velocity-marquee";

// A hero made of the marquee, a few pictures to scroll past, and a second,
// quieter marquee before the footer. Photos from Lummi (Lummi License).

const heroItems: VelocityMarqueeItem[] = [
  { text: "Scroll faster" },
  { src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg", alt: "A woman in an orange visor and navy dress against a teal background" },
  { text: "Slow down" },
  { src: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg", alt: "Yellow and beige walls under a clear blue sky" },
  { text: "Turn around" },
  { src: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg", alt: "A glossy pink sphere reflecting a cloudy sky" },
  { text: "Keep moving" },
  { src: "https://ui.dimaac.com/media/portraits/lime-blazer-4x5.jpg", alt: "A person with curly red hair in a lime green blazer" },
];

const bandItems: VelocityMarqueeItem[] = [
  { text: "Keep scrolling" },
  { src: "https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg", alt: "Colourful cylinders on a geometric background" },
  { text: "Almost there" },
  { src: "https://ui.dimaac.com/media/nature/golden-dunes-3x2.jpg", alt: "Golden sand dunes under a blue sky" },
];

const pictures = [
  { src: "https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg", alt: "An amber glass perfume bottle against a green background with leaf shadows", label: "No. 01", title: "Amber bottle" },
  { src: "https://ui.dimaac.com/media/architecture/pastel-rooftop-4x5.jpg", alt: "A purple wall and a pink building under a blue sky", label: "No. 02", title: "Pastel rooftop" },
  { src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg", alt: "A woman in an orange visor and navy dress against a teal background", label: "No. 03", title: "Orange visor" },
];

export default function Demo(props: Partial<VelocityMarqueeProps>) {
  return (
    <div className="w-full bg-[#efece6] text-[#15130f] dark:bg-[#0d0c0b] dark:text-[#ece9e2]">
      <section className="flex min-h-[100dvh] flex-col justify-between py-6">
        <div aria-hidden="true" />

        <VelocityMarquee items={heroItems} {...props} />

        <footer className="flex items-end justify-between gap-6 px-6">
          <p className="max-w-[30ch] text-[15px] leading-snug opacity-65">The faster you scroll, the faster it runs. Scroll back up and it turns around.</p>
        </footer>
      </section>

      <section className="px-6 py-24 md:py-36">
        <p className="max-w-[22ch] text-[clamp(32px,5.5vw,76px)] leading-[1] font-medium tracking-[-0.04em]">Text that speeds up when you do.</p>
        <div className="mt-16 grid grid-cols-1 gap-6 md:mt-24 md:grid-cols-3">
          {pictures.map((picture) => (
            <figure key={picture.label} className="m-0">
              <img src={picture.src} alt={picture.alt} loading="lazy" className="aspect-[4/5] w-full rounded-[4px] object-cover" />
              <figcaption className="mt-3 flex justify-between font-mono text-[11px] tracking-wide uppercase opacity-70">
                <span>{picture.label}</span>
                <span>{picture.title}</span>
              </figcaption>
            </figure>
          ))}
        </div>
      </section>

      <section className="bg-[#d6ef4a] py-10 text-[#141414]">
        <VelocityMarquee items={bandItems} {...props} rows={1} itemClassName="text-[clamp(40px,7vw,100px)] font-medium leading-[1.2] tracking-[-0.04em]" />
      </section>

      <div className="h-8" aria-hidden="true" />
    </div>
  );
}
```

## Agent prompt

```text
Add the Velocity Marquee component (<VelocityMarquee />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/velocity-marquee
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/velocity-marquee.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/velocity-marquee.json

Then use it like this:
import { VelocityMarquee } from "@/components/dimaac/velocity-marquee";
<VelocityMarquee items={items} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/velocity-marquee.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Card Fan
description: A hand of photo cards that fans out, lifts and turns over.
category: Cards
tier: free
url: https://ui.dimaac.com/components/card-fan
registry: https://ui.dimaac.com/r/card-fan.json
---

# Card Fan

A stack of photo cards held like a hand of playing cards. Point at the stack and it opens into an arc around a pivot far below it; the card under the pointer rises while its neighbours lean away. Click a card and GSAP Flip carries it to the middle at full size while it turns over in 3D to show a title and a line of text on its back; click again, click outside or press Escape to put it back. Every card can be dragged out of the hand and springs home with an elastic ease. The cards are real buttons with a single tab stop: arrow keys walk along the fan, Enter turns a card over. On touch screens the hand opens whenever it is on screen. With reduced motion the hand sits open, nothing travels and the back simply fades in over the front.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/card-fan.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/card-fan.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/card-fan.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/card-fan`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/card-fan.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { CardFan } from "@/components/dimaac/card-fan" and give its parent a size: it fills it.

## Usage

```tsx
import { CardFan } from "@/components/dimaac/card-fan";

<CardFan cards={cards} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cards` (required) | `CardFanCard[]` | - | The cards, left to right. Four to eight look best. |
| `spread` | `number` | `64` | How wide the open fan is, from the first card to the last, in degrees. |
| `radius` | `number` | `560` | How far below the cards the fan's pivot sits, in pixels. Bigger is a flatter arc. |
| `lift` | `number` | `36` | How far a card rises when you point at it, in pixels. |
| `flipDuration` | `number` | `0.9` | How long the trip to the middle and the turn take, in seconds. |
| `cardWidth` | `number` | `210` | Width of a card in the hand, in pixels (smaller on narrow screens). |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `cardClassName` | `string` | `"rounded-[14px] shadow-[0_24px_48px_-20px_rgba(0,0,0,0.45),0_2px_6px_-2px_rgba(0,0,0,0.2)]"` | Extra classes for both faces of every card, e.g. a radius or a shadow. |
| `backdropClassName` | `string` | `"bg-black/25"` | Classes for the layer behind an open card, e.g. a tint or a blur. |

## Credits

- Photo: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Photo: Beth Design Studio, Lummi (https://www.lummi.ai/photo/desert-wanderer-9puhu), Lummi License
- Photo: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Photo: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Photo: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License

## Source

### card-fan.tsx → `components/dimaac/card-fan.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { Draggable } from "gsap/Draggable";
import { useGSAP } from "@gsap/react";

// A hand of photo cards. At rest they sit in a tight stack; hover (or focus) opens them into an arc
// around a pivot far below the stack, like a hand of playing cards. Pointing at a card lifts it.
// Clicking a card sends it to the middle at full size with GSAP Flip and turns it over in 3D to
// show its back. Any card can be dragged out of the hand and springs back when you let go.

export type CardFanCard = {
  /** The photo on the front. */
  src: string;
  /** Describes the photo for screen readers. */
  alt: string;
  /** The title on the back. */
  title: string;
  /** One line of text on the back. */
  text: string;
};

export type CardFanProps = {
  /** The cards, left to right. Four to eight look best. */
  cards: CardFanCard[];
  /** How wide the open fan is, from the first card to the last, in degrees. */
  spread?: number;
  /** How far below the cards the fan's pivot sits, in pixels. Bigger is a flatter arc. */
  radius?: number;
  /** How far a card rises when you point at it, in pixels. */
  lift?: number;
  /** How long the trip to the middle and the turn take, in seconds. */
  flipDuration?: number;
  /** Width of a card in the hand, in pixels (smaller on narrow screens). */
  cardWidth?: number;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Extra classes for both faces of every card, e.g. a radius or a shadow. */
  cardClassName?: string;
  /** Classes for the layer behind an open card, e.g. a tint or a blur. */
  backdropClassName?: string;
};

// How open the hand is at rest, as a share of the full spread
const REST = 0.16;
// Cards are 4:5, like the photos
const ASPECT = 1.25;

export function CardFan({
  cards,
  spread = 64,
  radius = 560,
  lift = 36,
  flipDuration = 0.9,
  cardWidth = 210,
  className = "",
  cardClassName = "rounded-[14px] shadow-[0_24px_48px_-20px_rgba(0,0,0,0.45),0_2px_6px_-2px_rgba(0,0,0,0.2)]",
  backdropClassName = "bg-black/25",
}: CardFanProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const backdropRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
  // True once the deal-in has played, so a prop change re-lays the hand instead of dealing again
  const dealt = useRef(false);
  // Which card shows its back (drives the blurred back photo and what screen readers hear)
  const [turned, setTurned] = useState(-1);
  // Roving tab stop: one card is in the tab order, arrows move between them
  const [tabStop, setTabStop] = useState(0);

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const backdrop = backdropRef.current;
      const shells = cardRefs.current.slice(0, cards.length).filter((card): card is HTMLButtonElement => card !== null);
      if (!box || !backdrop || shells.length === 0 || !contextSafe) return;
      gsap.registerPlugin(Flip, Draggable);

      const lifters = shells.map((shell) => shell.querySelector<HTMLElement>("[data-fan-lift]")!);
      const flippers = shells.map((shell) => shell.querySelector<HTMLElement>("[data-fan-flip]")!);
      const backs = shells.map((shell) => shell.querySelector<HTMLElement>("[data-fan-back]")!);
      const count = shells.length;
      const middle = (count - 1) / 2;
      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const touch = window.matchMedia("(hover: none)").matches;
      const doc = box.ownerDocument;

      // Everything the layout needs, measured from the box
      const size = { boxW: 0, boxH: 0, cardW: 0, cardH: 0, pivot: radius, centreY: 0, bigW: 0, spread };
      const measure = () => {
        size.boxW = box.clientWidth;
        size.boxH = box.clientHeight;
        // Phones get wider cards (a share of a small box), so the inner cards stay big enough to tap
        const narrow = size.boxW < 600;
        size.cardW = Math.min(cardWidth, size.boxW * (narrow ? 0.4 : 0.34), (size.boxH * 0.46) / ASPECT);
        size.cardH = size.cardW * ASPECT;
        // On a narrow box the pivot gets pulled in hard, so open the fan wider to keep the cards apart
        size.spread = narrow ? Math.min(spread * 1.35, 120) : spread;
        // Pull the pivot in when the open fan would spill past the sides of the box
        const half = (size.spread / 2) * (Math.PI / 180);
        // The end card leans out a little further when a neighbour is pointed at (see seat())
        const edge = half + Math.min(4, (size.spread / count) * 0.4) * (Math.PI / 180);
        // How far the end card's outer corner reaches past its centre once it is rotated
        const reach = (size.cardW / 2) * Math.cos(edge) + (size.cardH / 2) * Math.sin(edge);
        const room = size.boxW / 2 - 16 - reach;
        size.pivot = edge > 0 && Math.sin(edge) * radius > room ? Math.max(room / Math.sin(edge), 0) : radius;
        // Centre the whole open hand (raised middle card down to the dipped end corners), so big
        // cards on a short box don't sink into whatever sits below the hand
        const above = size.cardH / 2 + lift;
        const below = (1 - Math.cos(half)) * size.pivot + (size.cardW / 2) * Math.sin(half) + (size.cardH / 2) * Math.cos(half);
        size.centreY = size.boxH * 0.5 + (above - below) / 2;
        size.bigW = Math.min(440, size.boxW - 48, (size.boxH - 72) / ASPECT);
      };

      // The state of the hand. Plain variables: they change on every pointer move and React
      // doesn't need to hear about it.
      let fanned = reduced;
      let hovered = -1;
      let open = -1;
      let dragging = -1;
      let ready = false;
      // A prop change rebuilds the context while the pointer may be resting over the hand
      let pointerInside = box.matches(":hover");
      // A prop change rebuilds the context while a card may still have focus
      let focusInside = box.contains(doc.activeElement);
      let visible = true;
      let justDragged = false;
      // Cards in the middle of a Flip are left alone by the layout
      const flying = new Set<number>();

      // Where card i sits: on a circle around a pivot below the hand, rotated to face out from it
      const seat = (i: number, amount: number, around: number) => {
        const t = count > 1 ? (i - middle) / (count - 1) : 0;
        let degrees = t * size.spread * amount;
        // Neighbours lean away from the card you point at, so it has room to rise
        if (around >= 0 && around !== i) degrees += (i < around ? -1 : 1) * Math.min(4, (size.spread / count) * 0.4) * amount;
        const angle = degrees * (Math.PI / 180);
        // x and y place the card's top-left corner (Flip works in plain pixels, so no xPercent)
        return {
          x: size.boxW / 2 + Math.sin(angle) * size.pivot - size.cardW / 2,
          y: size.centreY + (1 - Math.cos(angle)) * size.pivot - size.cardH / 2,
          rotation: degrees,
        };
      };

      // Where an open card goes: big, in the middle of the box
      const openSpot = () => ({
        x: (size.boxW - size.bigW) / 2,
        y: (size.boxH - size.bigW * ASPECT) / 2,
        width: size.bigW,
        height: size.bigW * ASPECT,
      });

      // contextSafe: layout also runs from tween and observer callbacks, and every tween it makes
      // must belong to this context so a prop change reverts it
      const layout = contextSafe((duration = 0.8) => {
        if (!ready) return;
        const amount = fanned ? 1 : REST;
        shells.forEach((shell, i) => {
          if (i === open || i === dragging || flying.has(i)) return;
          // The lifted card comes to the front so its neighbours don't cover it
          gsap.set(shell, { zIndex: i === hovered && open < 0 ? count + 1 : i + 1 });
          gsap.to(shell, {
            ...seat(i, amount, fanned ? hovered : -1),
            opacity: open >= 0 ? 0.35 : 1,
            duration: reduced ? 0 : duration,
            ease: "power3.out",
            overwrite: "auto",
          });
          const rising = i === hovered && open < 0;
          gsap.to(lifters[i]!, {
            y: rising ? -lift : open >= 0 ? 28 : 0,
            scale: rising ? 1.03 : 1,
            duration: reduced ? 0.15 : 0.55,
            ease: rising ? "back.out(2)" : "power3.out",
            overwrite: "auto",
          });
        });
      });

      const setFanned = (next: boolean) => {
        if (reduced) next = true;
        if (next === fanned) return;
        fanned = next;
        layout();
      };

      measure();
      gsap.set(shells, { width: size.cardW, height: size.cardH, zIndex: (i: number) => i + 1, force3D: true });
      gsap.set(flippers, { transformPerspective: 1400, rotationY: 0 });
      // Reduced motion: the back sits flat on top of the front and fades in instead of turning round
      gsap.set(backs, reduced ? { rotationY: 0, opacity: 0 } : { rotationY: 180, opacity: 1 });
      gsap.set(backdrop, { autoAlpha: 0 });

      if (touch && !reduced) fanned = visible;

      if (reduced || dealt.current) {
        // Already dealt (or no motion): put the hand straight down
        gsap.set(shells, { x: (i: number) => seat(i, fanned ? 1 : REST, -1).x, y: (i: number) => seat(i, fanned ? 1 : REST, -1).y, rotation: (i: number) => seat(i, fanned ? 1 : REST, -1).rotation, opacity: 1 });
        ready = true;
        if (!reduced) {
          // A prop just changed: open the hand and raise the middle card for a moment so the change shows
          fanned = true;
          hovered = Math.round(middle);
          layout(0.7);
          gsap.delayedCall(1.6, () => {
            // Keep the card under a resting pointer raised, else the focused one
            const under = shells.findIndex((shell) => shell.matches(":hover"));
            hovered = under >= 0 && !touch ? under : shells.indexOf(doc.activeElement as HTMLButtonElement);
            pointerInside = pointerInside || box.matches(":hover");
            setFanned(pointerInside || focusInside || (touch && visible));
            layout();
          });
        }
      } else {
        // The deal: cards rise from below the box one after another and settle into the stack
        gsap.set(shells, {
          x: (i: number) => seat(i, REST, -1).x + (i - middle) * 30,
          y: size.boxH + size.cardH,
          rotation: (i: number) => (i - middle) * -14,
          opacity: 1,
        });
        gsap.to(shells, {
          x: (i: number) => seat(i, REST, -1).x,
          y: (i: number) => seat(i, REST, -1).y,
          rotation: (i: number) => seat(i, REST, -1).rotation,
          duration: 1.3,
          ease: "expo.out",
          stagger: 0.07,
          delay: 0.15,
          onComplete: () => {
            dealt.current = true;
            ready = true;
            layout();
          },
        });
      }

      const openCard = (i: number) => {
        const shell = shells[i]!;
        open = i;
        hovered = -1;
        flying.add(i);
        setTurned(i);
        draggables[i]?.disable();
        Flip.killFlipsOf(shell);
        // A hover tween or a drag spring may still be moving this card: stop it, or it keeps
        // pulling the open card back toward its seat (rotated, off-centre) while the Flip runs
        gsap.killTweensOf(shell, "x,y,rotation,opacity,zIndex");
        // Flip: note where the card is now, jump it to its open spot, then animate from the old spot
        const state = Flip.getState(shell);
        gsap.set(shell, { ...openSpot(), rotation: 0, zIndex: 50, opacity: 1 });
        gsap.to(backdrop, { autoAlpha: 1, duration: reduced ? 0.2 : flipDuration * 0.7, ease: "power2.out" });
        if (reduced) {
          gsap.fromTo(shell, { opacity: 0 }, { opacity: 1, duration: 0.25 });
          gsap.set(lifters[i]!, { y: 0, scale: 1 });
          gsap.to(backs[i]!, { opacity: 1, duration: 0.25 });
          flying.delete(i);
        } else {
          Flip.from(state, {
            duration: flipDuration,
            ease: "expo.inOut",
            scale: true,
            onComplete: contextSafe(() => {
              flying.delete(i);
              // Backstop: if the box changed size during the flight, land on the new centre
              if (open === i) gsap.set(shell, { ...openSpot(), rotation: 0, scaleX: 1, scaleY: 1 });
            }),
          });
          gsap.to(lifters[i]!, { y: 0, scale: 1, duration: flipDuration * 0.6, ease: "power2.inOut", overwrite: "auto" });
          // The turn starts a beat after the flight so the card leaves the hand face up
          gsap.to(flippers[i]!, { rotationY: 180, duration: flipDuration * 0.9, delay: flipDuration * 0.1, ease: "power3.inOut", overwrite: "auto" });
        }
        layout(0.7);
      };

      const closeCard = () => {
        const i = open;
        if (i < 0) return;
        const shell = shells[i]!;
        open = -1;
        flying.add(i);
        Flip.killFlipsOf(shell);
        gsap.killTweensOf(shell, "x,y,rotation,opacity,zIndex");
        const state = Flip.getState(shell);
        gsap.set(shell, { ...seat(i, fanned ? 1 : REST, -1), width: size.cardW, height: size.cardH });
        gsap.to(backdrop, { autoAlpha: 0, duration: reduced ? 0.2 : flipDuration * 0.6, ease: "power2.out" });
        const land = contextSafe(() => {
          flying.delete(i);
          gsap.set(shell, { zIndex: i + 1 });
          draggables[i]?.enable();
          // Focus came back to this card (Escape or Enter), so it stays raised like any focused card
          if (doc.activeElement === shell && open < 0) hovered = i;
          setTurned((current) => (current === i ? -1 : current));
          layout(0.4);
        });
        if (reduced) {
          gsap.set(backs[i]!, { opacity: 0 });
          gsap.fromTo(shell, { opacity: 0 }, { opacity: 1, duration: 0.25 });
          land();
        } else {
          Flip.from(state, { duration: flipDuration * 0.85, ease: "expo.inOut", scale: true, onComplete: land });
          gsap.to(flippers[i]!, { rotationY: 0, duration: flipDuration * 0.75, ease: "power3.inOut", overwrite: "auto" });
        }
        layout(0.8);
      };

      // Dragging: a card follows the pointer, tilts with its speed and springs back to its seat.
      // The work lives in contextSafe functions (Draggable calls its callbacks outside the context,
      // and contextSafe replaces `this`, so the Draggable's numbers are passed in instead).
      const dragStart = contextSafe((i: number) => {
        dragging = i;
        justDragged = true;
        gsap.set(shells[i]!, { zIndex: 45 });
        gsap.to(lifters[i]!, { y: 0, scale: 1.05, duration: 0.3, ease: "power3.out", overwrite: "auto" });
      });
      const dragMove = contextSafe((i: number, deltaX: number) => {
        const lean = gsap.utils.clamp(-16, 16, deltaX * 1.2);
        gsap.to(shells[i]!, { rotation: lean, duration: 0.5, ease: "power3.out", overwrite: "auto" });
      });
      const dragRelease = contextSafe((i: number) => {
        const shell = shells[i]!;
        // The click that follows a drag must not open the card
        gsap.delayedCall(0.05, () => {
          justDragged = false;
        });
        if (dragging !== i) return;
        dragging = -1;
        // The spring home is its own tween, so the layout leaves this card alone until it lands
        flying.add(i);
        gsap.to(shell, {
          ...seat(i, fanned ? 1 : REST, fanned ? hovered : -1),
          opacity: 1,
          duration: reduced ? 0 : 1.1,
          ease: "elastic.out(1, 0.6)",
          overwrite: "auto",
          onComplete: contextSafe(() => {
            // If the card was opened while springing, this tween was killed; still, never touch an open card
            if (open === i) return;
            flying.delete(i);
            gsap.set(shell, { zIndex: i + 1 });
            layout(0.4);
          }),
        });
        gsap.to(lifters[i]!, { scale: 1, duration: 0.4, ease: "power3.out", overwrite: "auto" });
        layout();
      });
      const draggables = shells.map((shell, i) => {
        const [draggable] = Draggable.create(shell, {
          type: "x,y",
          minimumMovement: 6,
          zIndexBoost: false,
          cursor: "grab",
          activeCursor: "grabbing",
          onDragStart: () => dragStart(i),
          onDrag() {
            dragMove(i, this.deltaX);
          },
          onRelease: () => dragRelease(i),
        });
        return draggable;
      });

      // Where the last finger came down. Chrome's touch adjustment can hand a tap on a thin visible
      // sliver of one card to the neighbour whose box overlaps it, so a tap opens the card that is
      // actually drawn under the finger instead
      const press = { x: 0, y: 0, touch: false };
      const onPress = (event: PointerEvent) => {
        press.x = event.clientX;
        press.y = event.clientY;
        press.touch = event.pointerType !== "mouse";
      };

      const onClick = contextSafe((event: MouseEvent) => {
        if (justDragged) return;
        let i = shells.indexOf(event.currentTarget as HTMLButtonElement);
        if (press.touch && open < 0) {
          const drawn = shells.indexOf(doc.elementFromPoint(press.x, press.y)?.closest("button") as HTMLButtonElement);
          if (drawn >= 0) i = drawn;
        }
        if (i < 0) return;
        if (i === open) closeCard();
        else if (open < 0) openCard(i);
      });

      const onCardEnter = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "mouse" || dragging >= 0) return;
        hovered = shells.indexOf(event.currentTarget as HTMLButtonElement);
        if (fanned) layout(0.6);
      });
      const onCardLeave = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "mouse") return;
        if (hovered === shells.indexOf(event.currentTarget as HTMLButtonElement)) hovered = -1;
        layout(0.6);
      });

      // The hand opens while the pointer is near it: inside the cards' combined outline plus a margin
      const onBoxMove = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "mouse" || open >= 0) return;
        let left = Infinity;
        let top = Infinity;
        let right = -Infinity;
        let bottom = -Infinity;
        for (const shell of shells) {
          const rect = shell.getBoundingClientRect();
          left = Math.min(left, rect.left);
          top = Math.min(top, rect.top);
          right = Math.max(right, rect.right);
          bottom = Math.max(bottom, rect.bottom);
        }
        const margin = 48;
        pointerInside = event.clientX > left - margin && event.clientX < right + margin && event.clientY > top - margin && event.clientY < bottom + margin;
        if (dragging < 0) setFanned(pointerInside || focusInside);
      });
      const onBoxLeave = contextSafe(() => {
        pointerInside = false;
        hovered = -1;
        if (dragging < 0 && open < 0) setFanned(focusInside || (touch && visible));
      });

      // Keyboard: focus opens the hand and lifts the focused card; arrows walk along it
      const onFocusIn = contextSafe((event: FocusEvent) => {
        const i = shells.indexOf(event.target as HTMLButtonElement);
        if (i < 0) return;
        focusInside = true;
        setTabStop(i);
        if (open < 0) {
          hovered = i;
          fanned = true;
          layout(0.6);
        }
      });
      const onFocusOut = contextSafe((event: FocusEvent) => {
        if (box.contains(event.relatedTarget as Node | null)) return;
        focusInside = false;
        hovered = -1;
        setFanned(pointerInside || (touch && visible));
        layout(0.6);
      });
      const onKeyDown = contextSafe((event: KeyboardEvent) => {
        // Enter or Space clicks the focused card itself, not whatever was last under a finger
        press.touch = false;
        const i = shells.indexOf(doc.activeElement as HTMLButtonElement);
        // While a card is open, Tab stays on it rather than leaving the card open behind its backdrop
        if (event.key === "Tab" && open >= 0 && box.contains(doc.activeElement)) {
          event.preventDefault();
          shells[open]?.focus({ preventScroll: true });
          return;
        }
        if (event.key === "Escape" && open >= 0) {
          event.preventDefault();
          const was = open;
          closeCard();
          shells[was]?.focus({ preventScroll: true });
          return;
        }
        if (i < 0 || open >= 0) return;
        let next = -1;
        if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (i + 1) % count;
        else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (i - 1 + count) % count;
        else if (event.key === "Home") next = 0;
        else if (event.key === "End") next = count - 1;
        if (next < 0) return;
        event.preventDefault();
        shells[next]!.focus({ preventScroll: true });
      });
      const onBackdropClick = contextSafe(() => closeCard());

      shells.forEach((shell) => {
        shell.addEventListener("click", onClick);
        shell.addEventListener("pointerenter", onCardEnter);
        shell.addEventListener("pointerleave", onCardLeave);
      });
      box.addEventListener("pointerdown", onPress, true);
      box.addEventListener("pointermove", onBoxMove);
      box.addEventListener("pointerleave", onBoxLeave);
      box.addEventListener("focusin", onFocusIn);
      box.addEventListener("focusout", onFocusOut);
      backdrop.addEventListener("click", onBackdropClick);
      // Escape works wherever focus is on the page, as long as a card is open
      doc.addEventListener("keydown", onKeyDown);

      // Touch screens have no hover, so the hand opens whenever it's on screen
      const watcher = new IntersectionObserver(
        contextSafe((entries: IntersectionObserverEntry[]) => {
          visible = entries[0]?.isIntersecting ?? true;
          if (touch && open < 0 && dragging < 0) setFanned(visible);
        }),
      );
      watcher.observe(box);

      const resizer = new ResizeObserver(
        contextSafe(() => {
          measure();
          shells.forEach((shell, i) => {
            if (i === open) {
              // A Flip in flight would still land on the centre it measured before the resize:
              // finish it now, then put the card on the new centre (the turn keeps going)
              Flip.killFlipsOf(shell, true);
              // Anything else still moving the card would drag it off the new centre
              gsap.killTweensOf(shell, "x,y,rotation,scaleX,scaleY,width,height");
              flying.delete(i);
              gsap.set(shell, { ...openSpot(), rotation: 0, scaleX: 1, scaleY: 1 });
            } else gsap.set(shell, { width: size.cardW, height: size.cardH });
          });
          layout(0);
        }),
      );
      resizer.observe(box);

      return () => {
        shells.forEach((shell) => {
          shell.removeEventListener("click", onClick);
          shell.removeEventListener("pointerenter", onCardEnter);
          shell.removeEventListener("pointerleave", onCardLeave);
          Flip.killFlipsOf(shell);
        });
        box.removeEventListener("pointerdown", onPress, true);
        box.removeEventListener("pointermove", onBoxMove);
        box.removeEventListener("pointerleave", onBoxLeave);
        box.removeEventListener("focusin", onFocusIn);
        box.removeEventListener("focusout", onFocusOut);
        backdrop.removeEventListener("click", onBackdropClick);
        doc.removeEventListener("keydown", onKeyDown);
        draggables.forEach((draggable) => draggable?.kill());
        watcher.disconnect();
        resizer.disconnect();
        setTurned(-1);
      };
    },
    { scope: boxRef, dependencies: [cards.length, spread, radius, lift, flipDuration, cardWidth], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} role="group" aria-label="Photo cards" className={`relative h-full w-full overflow-hidden ${className}`}>
      <div ref={backdropRef} aria-hidden="true" className={`invisible absolute inset-0 z-[35] opacity-0 ${backdropClassName}`} />
      {cards.map((card, index) => (
        <button
          key={`${index}-${card.src}`}
          ref={(button) => {
            cardRefs.current[index] = button;
          }}
          type="button"
          tabIndex={index === tabStop ? 0 : -1}
          aria-pressed={turned === index}
          className="group absolute top-0 left-0 block cursor-pointer appearance-none border-0 bg-transparent p-0 text-left outline-none select-none"
          style={{ width: cardWidth, height: cardWidth * ASPECT, WebkitTapHighlightColor: "transparent" }}
        >
          <span data-fan-lift className="block h-full w-full will-change-transform">
            <span data-fan-flip className="relative block h-full w-full" style={{ transformStyle: "preserve-3d" }}>
              {/* Front: the photo */}
              <span
                className={`absolute inset-0 overflow-hidden outline-offset-4 group-focus-visible:outline-2 group-focus-visible:outline-current ${cardClassName}`}
                style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
              >
                <img src={card.src} alt={card.alt} className="pointer-events-none block h-full w-full object-cover" draggable={false} decoding="async" />
              </span>
              {/* Back: the same photo, blurred and dark, under the title and the line of text */}
              <span
                data-fan-back
                aria-hidden={turned !== index}
                className={`absolute inset-0 flex flex-col justify-between overflow-hidden bg-[#141210] p-[7%] text-white ${cardClassName}`}
                style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
              >
                {turned === index ? (
                  <img src={card.src} alt="" aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full scale-125 object-cover opacity-90 blur-2xl brightness-110 saturate-[1.6]" draggable={false} />
                ) : null}
                <span className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/25 via-transparent to-black/75" />
                <span className="relative flex items-center justify-between text-[11px] tracking-[0.16em] text-white/70 uppercase tabular-nums">
                  <span>
                    {String(index + 1).padStart(2, "0")} / {String(cards.length).padStart(2, "0")}
                  </span>
                  <span>Photograph</span>
                </span>
                <span className="relative block border-t border-white/20 pt-5">
                  <span className="block text-[30px] sm:text-[36px] leading-[0.95] font-medium tracking-[-0.03em]">{card.title}</span>
                  <span className="mt-3 block max-w-[30ch] text-[14px] leading-[1.45] text-white/75">{card.text}</span>
                </span>
              </span>
            </span>
          </span>
        </button>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { CardFan, type CardFanCard, type CardFanProps } from "@/components/dimaac/card-fan";

// Six photographs held like a hand of cards, on a paper stage that follows the site theme.
// Photos: Lummi (Lummi License), see meta.ts for each photographer.

const cards: CardFanCard[] = [
  {
    src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg",
    alt: "A woman in an orange visor and navy dress with red lips against a teal background",
    title: "Orange Visor",
    text: "Navy, citrus and a red lip, shot against a wall the colour of the sea.",
  },
  {
    src: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg",
    alt: "Yellow and beige walls meeting at sharp angles under a clear blue sky",
    title: "Yellow Planes",
    text: "Two walls and one sky. The shadow does all the drawing.",
  },
  {
    src: "https://ui.dimaac.com/media/nature/desert-wanderer-4x5.jpg",
    alt: "A person in a flowing garment walking across a desert under a golden sky",
    title: "Desert Walk",
    text: "Late light on the dunes and a long garment caught by the wind.",
  },
  {
    src: "https://ui.dimaac.com/media/portraits/light-streaks-4x5.jpg",
    alt: "Close-up of a face with blue eyes crossed by colourful streaks of light",
    title: "Light Streaks",
    text: "A close-up painted with long exposures of coloured light.",
  },
  {
    src: "https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg",
    alt: "A glass perfume bottle with amber liquid on green, with shadows of leaves",
    title: "Amber Glass",
    text: "A bottle of scent on green, lit like an old still life.",
  },
  {
    src: "https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg",
    alt: "Colourful cylinders standing on a bold geometric background",
    title: "Colour Study",
    text: "Painted cylinders stood on a grid of flat orange, red and blue.",
  },
];

export default function Demo(props: Partial<CardFanProps>) {
  return (
    <div
      className="relative h-full w-full overflow-hidden bg-[#ebe7df] text-[#1a1714] dark:bg-[#0f0e0c] dark:text-[#ece6dc]"
      style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
    >
      {/* A soft pool of light under the hand */}
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_55%_at_50%_58%,rgba(255,255,255,0.75),transparent_70%)] dark:bg-[radial-gradient(60%_55%_at_50%_58%,rgba(255,240,220,0.07),transparent_70%)]" />

      <footer className="pointer-events-none absolute inset-x-5 bottom-6 flex flex-col gap-3 select-none md:inset-x-10 md:bottom-9 md:flex-row md:items-end md:justify-between">
        <h1 className="text-[clamp(40px,7vw,96px)] leading-[0.9] font-medium tracking-[-0.045em]">Pick a card</h1>
        <p className="max-w-[34ch] text-[14px] leading-[1.45] opacity-60 md:text-right">
          Six photographs held like a hand, each with a note on its back. <span className="hidden sm:inline">Hover to fan them and click one to turn it over.</span>
          <span className="sm:hidden">Tap a card to turn it over.</span>
        </p>
      </footer>

      <CardFan
        cards={cards}
        backdropClassName="bg-[#ebe7df]/70 backdrop-blur-[3px] dark:bg-[#0f0e0c]/75"
        {...props}
        className={`absolute inset-0 z-10 ${props.className ?? ""}`}
      />
    </div>
  );
}
```

## Agent prompt

```text
Add the Card Fan component (<CardFan />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/card-fan
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/card-fan.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/card-fan.json

Then use it like this:
import { CardFan } from "@/components/dimaac/card-fan";
<CardFan cards={cards} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/card-fan.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Dynamic Island
description: A black pill that springs between live activities.
category: Navigation
tier: free
url: https://ui.dimaac.com/components/dynamic-island
registry: https://ui.dimaac.com/r/dynamic-island.json
---

# Dynamic Island

A black, rounded pill at the top of the page that morphs between live activities: idle, music (album art, title, waveform, progress and controls), an incoming call (photo, name, a talk timer, answer and decline), a countdown timer with rolling digits, and a notification. The shape springs between sizes with motion's layout animations (transforms only, so it stays at 60fps), and each state's content fades and un-blurs in once the shape has settled. Use it controlled or let it keep its own state, cycle through the states by itself (pausing while someone uses it), and tune the spring. Every control is a real, labelled button, screen readers hear each change, and with reduced motion it simply crossfades between sizes.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/dynamic-island`

Dependencies:

```bash
pnpm add motion
npm install motion
```

By hand:

1. Install the packages it uses: motion.
2. Copy the component file below into components/dimaac/dynamic-island.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { DynamicIsland } from "@/components/dimaac/dynamic-island" and give its parent a size: it fills it.

## Usage

```tsx
import { DynamicIsland } from "@/components/dimaac/dynamic-island";

<DynamicIsland />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `IslandState` | - | The state to show. Set it (with onStateChange) to control the island; leave it out to let the island keep its own. |
| `defaultState` | `IslandState` | `"idle"` | The first state when the island keeps its own state. |
| `onStateChange` | `(state: IslandState) => void` | - | Called whenever the island wants a new state: auto cycle, a tap on the idle pill, declining a call, dismissing a notification. |
| `autoCycle` | `boolean` | `false` | Move through the states by itself. It pauses while the pointer or keyboard focus is on the island, while the visitor is using it (a call answered, music or the timer paused), and each tap or key press on it starts the wait again. |
| `cycleDelay` | `number` | `3.6` | How long each state stays before the auto cycle moves on, in seconds. |
| `springStiffness` | `number` | `380` | Spring stiffness of the shape morph. Higher is snappier. |
| `springDamping` | `number` | `30` | Spring damping of the shape morph. Lower overshoots and wobbles more. It never goes below a damping ratio of 0.4, so the pill can't swell far past its size. |
| `music` | `IslandMusic` | `defaultMusic` | The music state's track. |
| `call` | `IslandCall` | `defaultCall` | The call state's caller. |
| `timer` | `IslandTimer` | `defaultTimer` | The timer state's countdown. |
| `notification` | `IslandNotification` | `defaultNotification` | The notification state's two lines. |
| `className` | `string` | `""` | Extra classes for the outer box. The pill sits centred at its top. |

## Credits

- Album art: marisol, Lummi (https://www.lummi.ai/photo/glossy-pink-sphere-45si5), Lummi License
- Caller photo: rena, Lummi (https://www.lummi.ai/photo/stylish-portrait-upjeu), Lummi License

## Source

### dynamic-island.tsx → `components/dimaac/dynamic-island.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, animate, motion, useReducedMotion, type Transition } from "motion/react";

// A black pill that morphs between live activities, like the island at the top of a phone.
// The pill is one motion element with `layout`: when its size changes, motion measures the old
// and new box and springs between them with transforms (no width/height animation, so it stays
// at 60fps). Each state's content waits for the shape to settle, then fades and
// un-blurs in. Content sits in a child with `layout` too, so the pill's scale never squashes it.

export type IslandState = "idle" | "music" | "call" | "timer" | "notification";

export type IslandMusic = {
  /** The track title */
  title: string;
  /** The line under it, e.g. the artist */
  subtitle: string;
  /** Album art (a square photo looks best) */
  image?: string;
  /** Describes the album art for screen readers. Empty means it is decoration. */
  imageAlt?: string;
  /** Track length, in seconds */
  length?: number;
};

export type IslandCall = {
  /** The caller's name */
  name: string;
  /** The line under the name before the call is answered */
  subtitle: string;
  /** The caller's photo */
  image?: string;
  /** Describes the photo for screen readers */
  imageAlt?: string;
};

export type IslandTimer = {
  /** What the countdown starts from, in seconds */
  seconds: number;
  /** A short name for the timer */
  label: string;
};

export type IslandNotification = {
  /** The first, bold line */
  title: string;
  /** The second line */
  body: string;
};

export type DynamicIslandProps = {
  /** The state to show. Set it (with onStateChange) to control the island; leave it out to let the island keep its own. */
  state?: IslandState;
  /** The first state when the island keeps its own state. */
  defaultState?: IslandState;
  /** Called whenever the island wants a new state: auto cycle, a tap on the idle pill, declining a call, dismissing a notification. */
  onStateChange?: (state: IslandState) => void;
  /** Move through the states by itself. It pauses while the pointer or keyboard focus is on the island, while the visitor is using it (a call answered, music or the timer paused), and each tap or key press on it starts the wait again. */
  autoCycle?: boolean;
  /** How long each state stays before the auto cycle moves on, in seconds. */
  cycleDelay?: number;
  /** Spring stiffness of the shape morph. Higher is snappier. */
  springStiffness?: number;
  /** Spring damping of the shape morph. Lower overshoots and wobbles more. It never goes below a damping ratio of 0.4, so the pill can't swell far past its size. */
  springDamping?: number;
  /** The music state's track. */
  music?: IslandMusic;
  /** The call state's caller. */
  call?: IslandCall;
  /** The timer state's countdown. */
  timer?: IslandTimer;
  /** The notification state's two lines. */
  notification?: IslandNotification;
  /** Extra classes for the outer box. The pill sits centred at its top. */
  className?: string;
};

/** The order the auto cycle (and a tap on the idle pill) moves through */
export const islandStates: IslandState[] = ["idle", "music", "call", "timer", "notification"];

// Each state's shape: width, height and corner radius in pixels. Widths shrink to fit narrow boxes.
const shapes: Record<IslandState, { width: number; height: number; radius: number }> = {
  idle: { width: 126, height: 36, radius: 18 },
  music: { width: 364, height: 158, radius: 46 },
  call: { width: 372, height: 76, radius: 38 },
  timer: { width: 318, height: 70, radius: 35 },
  notification: { width: 368, height: 84, radius: 34 },
};

const defaultMusic: IslandMusic = { title: "Slow Light", subtitle: "Harbour Rooms", length: 214 };
const defaultCall: IslandCall = { name: "Maya", subtitle: "Incoming call" };
const defaultTimer: IslandTimer = { seconds: 300, label: "Tea" };
const defaultNotification: IslandNotification = { title: "Stand-up in 5 minutes", body: "Studio B, second floor. Bring the new cuts." };

const pad = (value: number) => String(value).padStart(2, "0");
const clock = (seconds: number) => `${pad(Math.floor(seconds / 60))}:${pad(Math.floor(seconds % 60))}`;

/** Counts whole seconds while `running` is true. `reset` changes start it again from 0. */
function useSeconds(running: boolean, reset: unknown) {
  const [seconds, setSeconds] = useState(0);
  useEffect(() => setSeconds(0), [reset]);
  useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => window.clearInterval(id);
  }, [running]);
  return seconds;
}

export function DynamicIsland({
  state,
  defaultState = "idle",
  onStateChange,
  autoCycle = false,
  cycleDelay = 3.6,
  springStiffness = 380,
  springDamping = 30,
  music = defaultMusic,
  call = defaultCall,
  timer = defaultTimer,
  notification = defaultNotification,
  className = "",
}: DynamicIslandProps) {
  const reduced = useReducedMotion() ?? false;
  const [ownState, setOwnState] = useState<IslandState>(defaultState);
  const current = state ?? ownState;

  // Kept in a ref so the auto cycle timer doesn't restart when a parent passes a new function
  const onChangeRef = useRef(onStateChange);
  onChangeRef.current = onStateChange;
  const change = (next: IslandState) => {
    if (state === undefined) setOwnState(next);
    onChangeRef.current?.(next);
  };
  const changeRef = useRef(change);
  changeRef.current = change;

  // A change made from a button inside the island unmounts that button, which would drop keyboard
  // focus to the page. This flag asks for focus to move into the new content once it has settled.
  const refocus = useRef(false);
  const changeFromInside = (next: IslandState) => {
    refocus.current = true;
    change(next);
  };

  // "Engaged" is set by the content while the visitor is using it (call answered, music or timer
  // paused): the auto cycle waits until they let go, so it never undoes what they just did.
  const [engaged, setEngaged] = useState(false);
  // Bumped on every pointer down or key press on the pill; it restarts the cycle's wait
  const [nudge, setNudge] = useState(0);

  // Warm the browser cache for both photos as soon as the island mounts, so the music and call
  // content never fades in with an empty hole where the picture is still downloading.
  useEffect(() => {
    for (const src of [music.image, call.image]) {
      if (!src) continue;
      const img = new Image();
      img.decoding = "async";
      img.src = src;
    }
  }, [music.image, call.image]);

  // The room the pill has, so it never grows wider than its box (phones)
  const boxRef = useRef<HTMLDivElement>(null);
  const [room, setRoom] = useState(Infinity);
  useEffect(() => {
    const box = boxRef.current;
    if (!box) return;
    const observer = new ResizeObserver(() => setRoom(box.clientWidth - 24));
    observer.observe(box);
    return () => observer.disconnect();
  }, []);

  // Auto cycle: one timeout per state, paused while a mouse is over the island or keyboard focus is
  // inside it. Focus is checked when the timeout fires instead of tracked with focus/blur: a focused
  // button that unmounts (Decline, Dismiss) never fires blur, which would pause the cycle for good.
  // Only :focus-visible counts, so a tap on a phone (which focuses the button too) doesn't pause it.
  const pillRef = useRef<HTMLDivElement>(null);
  const [hovered, setHovered] = useState(false);
  useEffect(() => {
    if (!autoCycle || hovered || engaged) return;
    let id = 0;
    const tick = () => {
      const focused = document.activeElement;
      if (focused && pillRef.current?.contains(focused) && focused.matches(":focus-visible")) {
        id = window.setTimeout(tick, cycleDelay * 1000);
        return;
      }
      changeRef.current(islandStates[(islandStates.indexOf(current) + 1) % islandStates.length]!);
    };
    id = window.setTimeout(tick, cycleDelay * 1000);
    return () => window.clearTimeout(id);
  }, [autoCycle, hovered, engaged, nudge, current, cycleDelay]);

  const shape = shapes[current];
  const width = Math.max(shapes.idle.width, Math.min(shape.width, room));

  // The content waits until the shape has settled, so it never shows clipped by a pill that is
  // still growing or wobbling. "Settled" is whichever comes first: motion reporting the layout
  // animation done, or the time the spring's envelope has decayed to a few percent (motion's own
  // completion can trail a soft wobble by a long, invisible tail). The time comes from the spring's
  // natural frequency w and damping ratio z (mass 1), so it accounts for stiffness and damping.
  const [settled, setSettled] = useState(true);
  const [settledFor, setSettledFor] = useState(current);
  if (settledFor !== current) {
    // Adjusting state during render (not in an effect) so the new content never gets a frame as "settled"
    setSettledFor(current);
    setSettled(false);
  }
  const w = Math.sqrt(springStiffness);
  // Floor the damping ratio at 0.4 (about 25% overshoot): softer springs swell the pill far past its box
  const damping = Math.max(springDamping, 0.8 * w);
  const z = damping / (2 * w);
  const decay = z < 1 ? z * w : w * (z - Math.sqrt(z * z - 1));
  // Reduced motion: a short, plain change of size with no overshoot and no blur; the content crossfades.
  const morph: Transition = reduced ? { type: "tween", duration: 0.15, ease: [0.4, 0, 0.2, 1] } : { type: "spring", stiffness: springStiffness, damping, mass: 1 };
  const settleTime = reduced ? 0.15 : Math.min(2.5, Math.max(0.15, 3.2 / decay));
  useEffect(() => {
    if (settled) return;
    const id = window.setTimeout(() => setSettled(true), settleTime * 1000);
    return () => window.clearTimeout(id);
  }, [settled, settleTime]);

  // Once the new content is shown (and no longer inert), hand focus to its first control, or the pill
  useEffect(() => {
    if (!settled || !refocus.current) return;
    refocus.current = false;
    const pill = pillRef.current;
    const target = pill?.querySelector<HTMLElement>("[data-island-content] button") ?? pill;
    target?.focus({ preventScroll: true });
  }, [settled, current]);

  return (
    <div ref={boxRef} className={`relative flex w-full justify-center ${className}`}>
      <motion.div
        ref={pillRef}
        tabIndex={-1}
        layout
        transition={{ layout: morph, borderRadius: morph }}
        onLayoutAnimationComplete={() => setSettled(true)}
        initial={false}
        animate={{ borderRadius: shape.radius }}
        className="relative overflow-hidden bg-black text-white outline-none"
        style={{
          width,
          height: shape.height,
          borderRadius: shape.radius,
          // A hairline ring keeps the pill readable on dark pages, the shadow lifts it on light ones
          boxShadow: "0 0 0 1px rgba(255,255,255,0.07), 0 18px 40px -18px rgba(0,0,0,0.55)",
          WebkitTapHighlightColor: "transparent",
        }}
        onPointerEnter={(event) => event.pointerType === "mouse" && setHovered(true)}
        onPointerLeave={() => setHovered(false)}
        onPointerDown={() => setNudge((n) => n + 1)}
        onKeyDown={() => setNudge((n) => n + 1)}
      >
        <AnimatePresence initial={false}>
          <motion.div
            key={current}
            layout
            transition={{ layout: morph }}
            data-island-content
            className="absolute top-0 left-0"
            style={{ width, height: shape.height }}
          >
            <Reveal reduced={reduced} shown={settled}>
              {current === "idle" && <IdleContent onOpen={() => changeFromInside("music")} />}
              {current === "music" && <MusicContent music={music} reduced={reduced} onEngage={setEngaged} />}
              {current === "call" && <CallContent call={call} onEnd={() => changeFromInside("idle")} onEngage={setEngaged} />}
              {current === "timer" && <TimerContent timer={timer} reduced={reduced} onEngage={setEngaged} />}
              {current === "notification" && <NotificationContent notification={notification} onDismiss={() => changeFromInside("idle")} />}
            </Reveal>
          </motion.div>
        </AnimatePresence>
      </motion.div>

      {/* Screen readers hear what the island now shows */}
      <p className="sr-only" aria-live="polite">
        {current === "music" && `Now playing ${music.title} by ${music.subtitle}`}
        {current === "call" && `${call.subtitle} from ${call.name}`}
        {current === "timer" && `${timer.label} timer running`}
        {current === "notification" && `${notification.title}. ${notification.body}`}
      </p>
    </div>
  );
}

const hiddenContent = { opacity: 0, scale: 0.94, filter: "blur(8px)" };

/** Tells the island the visitor is using this content while `active` is true; lets go on unmount. */
function useEngage(active: boolean, onEngage: (engaged: boolean) => void) {
  useEffect(() => {
    onEngage(active);
    return () => onEngage(false);
  }, [active, onEngage]);
}

/**
 * Fades and un-blurs a state's content in once `shown` turns on (the shape has settled). Until then
 * it is inert, so the invisible content can't be clicked, focused or read out mid-morph. Leaving
 * content only fades, and very fast: it is pinned to the pill's top-left corner, so if it lingered
 * while the pill shrinks it would show sliced off on the right and bottom.
 */
function Reveal({ reduced, shown, children }: { reduced: boolean; shown: boolean; children: ReactNode }) {
  const hidden = reduced ? { opacity: 0 } : hiddenContent;
  return (
    <motion.div
      className="h-full w-full"
      inert={!shown}
      initial={hidden}
      animate={shown ? (reduced ? { opacity: 1 } : { opacity: 1, scale: 1, filter: "blur(0px)" }) : hidden}
      exit={{ opacity: 0, transition: { duration: 0.08, ease: "easeOut" } }}
      transition={{ duration: reduced ? 0.2 : 0.38, ease: [0.22, 1, 0.36, 1] }}
    >
      {children}
    </motion.div>
  );
}

function IdleContent({ onOpen }: { onOpen: () => void }) {
  return (
    <button type="button" onClick={onOpen} aria-label="Open live activity" className="flex h-full w-full cursor-pointer items-center justify-end pr-3.5 outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-inset" style={{ borderRadius: "inherit" }}>
      {/* The camera lens: decoration */}
      <span aria-hidden className="block size-2.5 rounded-full" style={{ background: "radial-gradient(circle at 35% 35%, #3a3f5c 0%, #15172a 55%, #0a0a0f 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.05)" }} />
    </button>
  );
}

function Art({ src, alt, className }: { src?: string; alt?: string; className: string }) {
  if (!src) return <span aria-hidden className={`block bg-gradient-to-br from-[#ff6a88] via-[#ff99ac] to-[#fbd38d] ${className}`} />;
  // A soft tile behind the photo, so a slow network shows a quiet placeholder rather than a hole
  return <img src={src} alt={alt ?? ""} draggable={false} decoding="async" className={`block bg-white/10 object-cover ${className}`} />;
}

function MusicContent({ music, reduced, onEngage }: { music: IslandMusic; reduced: boolean; onEngage: (engaged: boolean) => void }) {
  const length = music.length ?? 214;
  const [playing, setPlaying] = useState(true);
  useEngage(!playing, onEngage);
  // Previous and next start the track again (there is only one); before that it plays from 1:12
  const [skips, setSkips] = useState(0);
  const played = (useSeconds(playing, `${music.title}-${skips}`) + (skips === 0 ? 72 : 0)) % length;

  return (
    <div className="flex h-full flex-col justify-between px-5 pt-4.5 pb-4">
      <div className="flex items-center gap-3.5">
        <Art src={music.image} alt={music.imageAlt} className="size-[52px] shrink-0 rounded-[14px]" />
        <div className="min-w-0 flex-1">
          <p className="truncate text-[15px] leading-tight font-semibold tracking-[-0.01em]">{music.title}</p>
          <p className="mt-0.5 truncate text-[13px] leading-tight text-white/55">{music.subtitle}</p>
        </div>
        <Waveform playing={playing && !reduced} />
      </div>

      <div className="flex items-center gap-2.5 text-[11px] text-white/50 tabular-nums">
        <span className="w-7">{clock(played)}</span>
        <span className="relative h-[5px] flex-1 overflow-hidden rounded-full bg-white/15">
          <span className="absolute inset-0 origin-left rounded-full bg-white/85 transition-transform duration-1000 ease-linear" style={{ transform: `scaleX(${played / length})` }} />
        </span>
        <span className="w-8 text-right">-{clock(length - played)}</span>
      </div>

      <div className="flex items-center justify-center gap-7">
        <IconButton label="Previous track" onClick={() => setSkips((n) => n + 1)}>
          <path d="M6 6h2v12H6zM20 6v12l-9-6z" />
        </IconButton>
        <IconButton label={playing ? "Pause" : "Play"} onClick={() => setPlaying((p) => !p)} big>
          {playing ? <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /> : <path d="M8 5.5v13l11-6.5z" />}
        </IconButton>
        <IconButton label="Next track" onClick={() => setSkips((n) => n + 1)}>
          <path d="M16 6h2v12h-2zM4 6v12l9-6z" />
        </IconButton>
      </div>
    </div>
  );
}

// Six bars that bounce while the music plays. Only scaleY moves, so it stays on the compositor.
// Started by hand in an effect: the island's AnimatePresence skips mount animations for its
// first content, which would leave the bars frozen.
function Waveform({ playing }: { playing: boolean }) {
  const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
  useEffect(() => {
    const bars = barRefs.current.filter((bar): bar is HTMLSpanElement => bar !== null);
    const runs = bars.map((bar, i) =>
      playing
        ? animate(bar, { scaleY: [0.3, 1, 0.45, 0.85, 0.3] }, { duration: 1 + i * 0.13, repeat: Infinity, repeatType: "mirror", ease: "easeInOut", delay: i * 0.07 })
        : animate(bar, { scaleY: 0.2 + (i % 3) * 0.1 }, { duration: 0.3 }),
    );
    return () => runs.forEach((run) => run.stop());
  }, [playing]);

  return (
    <span aria-hidden className="flex h-6 shrink-0 items-center gap-[3px]">
      {[0, 1, 2, 3, 4, 5].map((i) => (
        <span
          key={i}
          ref={(bar) => {
            barRefs.current[i] = bar;
          }}
          className="block h-full w-[3px] rounded-full bg-gradient-to-t from-[#ff6a88] to-[#fbd38d]"
          style={{ transform: `scaleY(${0.3 + (i % 3) * 0.2})` }}
        />
      ))}
    </span>
  );
}

function IconButton({ label, onClick, big = false, children }: { label: string; onClick: () => void; big?: boolean; children: ReactNode }) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      className="grid size-9 cursor-pointer place-items-center rounded-full text-white outline-none transition-[transform,background-color] duration-150 hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/60 active:scale-90"
    >
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={big ? "size-7" : "size-5 opacity-80"}>
        {children}
      </svg>
    </button>
  );
}

function CallContent({ call, onEnd, onEngage }: { call: IslandCall; onEnd: () => void; onEngage: (engaged: boolean) => void }) {
  const [answered, setAnswered] = useState(false);
  useEngage(answered, onEngage);
  const [muted, setMuted] = useState(false);
  const talked = useSeconds(answered, call.name);

  return (
    <div className="flex h-full items-center gap-3 pr-3.5 pl-3.5">
      <Art src={call.image} alt={call.imageAlt} className="size-12 shrink-0 rounded-full" />
      <div className="min-w-0 flex-1">
        <p className="truncate text-[12px] leading-tight text-white/50">{answered ? "On call" : call.subtitle}</p>
        <p className="mt-0.5 truncate text-[16px] leading-tight font-semibold tracking-[-0.01em]">
          {call.name}
          {answered && <span className="ml-2 font-normal text-[#30d158] tabular-nums">{clock(talked)}</span>}
        </p>
      </div>
      <button
        type="button"
        onClick={onEnd}
        aria-label={answered ? "End call" : "Decline call"}
        className="grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-[#ff453a] outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black active:scale-90"
      >
        <svg viewBox="0 0 24 24" aria-hidden className="size-[22px] rotate-[135deg]" fill="white">
          <path d="M6.6 10.8a15.1 15.1 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1A17 17 0 0 1 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1z" />
        </svg>
      </button>
      {answered ? (
        <button
          type="button"
          onClick={() => setMuted((m) => !m)}
          aria-label="Mute"
          aria-pressed={muted}
          className={`grid size-11 shrink-0 cursor-pointer place-items-center rounded-full outline-none transition-[transform,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black active:scale-90 ${muted ? "bg-white text-black" : "bg-white/15 text-white"}`}
        >
          <svg viewBox="0 0 24 24" aria-hidden className="size-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3zM19 11a7 7 0 0 1-14 0M12 18v3" />
            {muted && <path d="M4 4l16 16" />}
          </svg>
        </button>
      ) : (
        <button
          type="button"
          onClick={() => setAnswered(true)}
          aria-label="Accept call"
          className="grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-[#30d158] outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black active:scale-90"
        >
          <svg viewBox="0 0 24 24" aria-hidden className="size-[22px]" fill="white">
            <path d="M6.6 10.8a15.1 15.1 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1A17 17 0 0 1 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1z" />
          </svg>
        </button>
      )}
    </div>
  );
}

function TimerContent({ timer, reduced, onEngage }: { timer: IslandTimer; reduced: boolean; onEngage: (engaged: boolean) => void }) {
  const [running, setRunning] = useState(true);
  useEngage(!running, onEngage);
  const passed = useSeconds(running, timer.seconds);
  // Counts down, then starts over
  const left = timer.seconds - (passed % (timer.seconds + 1));
  const ring = 2 * Math.PI * 17;

  return (
    <div className="flex h-full items-center gap-3 pr-6 pl-3">
      <button
        type="button"
        onClick={() => setRunning((r) => !r)}
        aria-label={running ? `Pause ${timer.label} timer` : `Resume ${timer.label} timer`}
        className="relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-[#ff9f0a]/20 text-[#ff9f0a] outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-[#ff9f0a] active:scale-90"
      >
        <svg viewBox="0 0 40 40" aria-hidden className="absolute inset-0 size-full -rotate-90">
          <circle cx="20" cy="20" r="17" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={ring} strokeDashoffset={ring * (1 - left / timer.seconds)} className="transition-[stroke-dashoffset] duration-1000 ease-linear" />
        </svg>
        <svg viewBox="0 0 24 24" aria-hidden className="size-4" fill="currentColor">
          {running ? <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /> : <path d="M8 5.5v13l11-6.5z" />}
        </svg>
      </button>
      <p className="flex-1 text-[13px] leading-tight text-[#ff9f0a]/80">{timer.label}</p>
      <Digits text={clock(left)} reduced={reduced} />
    </div>
  );
}

// Each digit rolls in from above when it changes. popLayout keeps the old one out of the flow.
function Digits({ text, reduced }: { text: string; reduced: boolean }) {
  return (
    <span className="flex text-[38px] leading-none font-light tracking-[-0.03em] text-[#ff9f0a] tabular-nums" aria-label={text} role="timer">
      {text.split("").map((char, i) => (
        <span key={i} aria-hidden className="relative inline-block overflow-hidden" style={{ width: char === ":" ? "0.3em" : "0.6em" }}>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={char}
              className="inline-block w-full text-center"
              initial={reduced ? { opacity: 0 } : { y: "-70%", opacity: 0, filter: "blur(3px)" }}
              animate={reduced ? { opacity: 1 } : { y: "0%", opacity: 1, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0 } : { y: "70%", opacity: 0, filter: "blur(3px)" }}
              transition={{ duration: reduced ? 0.15 : 0.42, ease: [0.22, 1, 0.36, 1] }}
            >
              {char}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

function NotificationContent({ notification, onDismiss }: { notification: IslandNotification; onDismiss: () => void }) {
  return (
    <div className="flex h-full items-center gap-3 pr-3 pl-4">
      <span aria-hidden className="grid size-11 shrink-0 place-items-center rounded-[13px] bg-gradient-to-br from-[#5e9bff] to-[#7a5cff] shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]">
        <svg viewBox="0 0 24 24" className="size-[22px]" fill="white">
          <path d="M12 22a2.5 2.5 0 0 0 2.45-2h-4.9A2.5 2.5 0 0 0 12 22zm7-6V11a7 7 0 0 0-5.5-6.84V3.5a1.5 1.5 0 0 0-3 0v.66A7 7 0 0 0 5 11v5l-2 2v1h18v-1z" />
        </svg>
      </span>
      <div className="min-w-0 flex-1">
        <div className="flex items-baseline justify-between gap-2">
          <p className="truncate text-[15px] leading-tight font-semibold tracking-[-0.01em]">{notification.title}</p>
          <span className="shrink-0 text-[11px] text-white/40">now</span>
        </div>
        <p className="mt-1 truncate text-[13px] leading-tight text-white/60">{notification.body}</p>
      </div>
      <button
        type="button"
        onClick={onDismiss}
        aria-label="Dismiss notification"
        className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-full bg-white/10 text-white/70 outline-none transition-[transform,background-color] duration-150 hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white/60 active:scale-90"
      >
        <svg viewBox="0 0 24 24" aria-hidden className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
          <path d="M6 6l12 12M18 6L6 18" />
        </svg>
      </button>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { DynamicIsland, islandStates, type DynamicIslandProps, type IslandState } from "@/components/dimaac/dynamic-island";

// The island at the top of a soft lock-screen stage, with a quiet row of buttons to pick a live
// activity. The demo owns the state, so the island, the buttons and the auto cycle stay in step.
// Photos: marisol (album art) and rena (caller) on Lummi (Lummi License).

const labels: Record<IslandState, string> = { idle: "Idle", music: "Music", call: "Call", timer: "Timer", notification: "Alert" };

export default function Demo({ state: chosen = "music", autoCycle = true, ...props }: Partial<DynamicIslandProps>) {
  const [state, setState] = useState<IslandState>(chosen);
  // A new pick in the playground wins over whatever the cycle reached, and so does turning the
  // cycle off: the island snaps back to the picked state, so it always matches the State control
  useEffect(() => setState(chosen), [chosen, autoCycle]);

  return (
    <div className="relative flex h-full min-h-[560px] w-full flex-col items-center overflow-hidden bg-[#efece6] text-[#16140f] dark:bg-[#0b0b0d] dark:text-[#f2efe9]">
      {/* Soft colour behind everything, like a wallpaper. Painted with radial gradients rather than
          blurred blobs: a big blur filter is costly to repaint on low-end phones while the pill morphs. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 dark:hidden"
        style={{
          background:
            "radial-gradient(46vmax 46vmax at 35% 52%, rgba(255,179,193,0.55), transparent 70%), radial-gradient(40vmax 40vmax at 78% 92%, rgba(255,213,154,0.6), transparent 70%), radial-gradient(30vmax 30vmax at 64% 8%, rgba(185,204,255,0.55), transparent 70%)",
        }}
      />
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 hidden dark:block"
        style={{
          background:
            "radial-gradient(46vmax 46vmax at 35% 52%, rgba(255,179,193,0.16), transparent 70%), radial-gradient(40vmax 40vmax at 78% 92%, rgba(255,159,106,0.13), transparent 70%), radial-gradient(30vmax 30vmax at 64% 8%, rgba(94,123,255,0.16), transparent 70%)",
        }}
      />

      {/* A fixed-height slot, taller than the biggest pill: the pill's real height changes at once
          while its visible size springs, so if it sat in the flow the clock below would jump */}
      <div className="relative z-10 h-[190px] w-full shrink-0 pt-5">
      <DynamicIsland
        {...props}
        state={state}
        onStateChange={setState}
        autoCycle={autoCycle}
        music={{ title: "Slow Light", subtitle: "Harbour Rooms", image: "https://ui.dimaac.com/media/art/pink-sphere-1x1.jpg", imageAlt: "A glossy pink sphere against a dreamy sky", length: 214 }}
        call={{ name: "Maya", subtitle: "Incoming call", image: "https://ui.dimaac.com/media/portraits/sunglasses-1x1.jpg", imageAlt: "Maya in dark sunglasses" }}
        className={props.className ?? ""}
      />
      </div>

      <div className="pointer-events-none relative z-0 flex flex-1 flex-col items-center justify-center px-6 text-center select-none">
        <p className="text-[clamp(96px,19vw,236px)] leading-[0.9] font-semibold tracking-[-0.06em] tabular-nums opacity-90">9:41</p>
      </div>

      <div role="group" aria-label="Live activity" className="relative z-10 mb-8 flex gap-5 sm:gap-7">
        {islandStates.map((name) => (
          <button
            key={name}
            type="button"
            onClick={() => setState(name)}
            aria-pressed={state === name}
            className={`cursor-pointer rounded-md px-1 py-1.5 text-[14px] outline-none transition-opacity duration-200 focus-visible:ring-2 focus-visible:ring-current ${state === name ? "opacity-100" : "opacity-40 hover:opacity-75"}`}
          >
            {labels[name]}
          </button>
        ))}
      </div>
    </div>
  );
}
```

## Agent prompt

```text
Add the Dynamic Island component (<DynamicIsland />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/dynamic-island
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/dynamic-island.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/dynamic-island.json

Then use it like this:
import { DynamicIsland } from "@/components/dimaac/dynamic-island";
<DynamicIsland />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/dynamic-island.tsx and install motion.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Magnetic Dot Grid
description: A dot grid that parts around the cursor and springs back.
category: Backgrounds
tier: free
url: https://ui.dimaac.com/components/magnetic-dot-grid
registry: https://ui.dimaac.com/r/magnetic-dot-grid.json
---

# Magnetic Dot Grid

A full-bleed grid of small dots drawn on one 2D canvas. Near the cursor the dots are pushed away (or pulled in), swell and take the accent colour, and when it leaves a damped spring carries each one home with a soft overshoot. A click sends a shockwave ring through the whole grid. Resting dots follow the text colour, so the grid retunes with the theme. It is sharp on retina screens, lays itself out again on resize, sleeps when nothing is moving or the box is off screen, follows the finger on touch screens without blocking scroll, and only changes colour (no motion) when the visitor prefers reduced motion. No libraries.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-dot-grid.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/magnetic-dot-grid.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/magnetic-dot-grid.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/magnetic-dot-grid`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/magnetic-dot-grid.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { MagneticDotGrid } from "@/components/dimaac/magnetic-dot-grid" and give its parent a size: it fills it.

## Usage

```tsx
import { MagneticDotGrid } from "@/components/dimaac/magnetic-dot-grid";

<MagneticDotGrid />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `gap` | `number` | `24` | Distance between dots, in pixels. |
| `dotSize` | `number` | `1.6` | Radius of a dot at rest, in pixels. |
| `radius` | `number` | `160` | How far the cursor reaches, in pixels. |
| `mode` | `"repel" \| "attract"` | `"repel"` | "repel" pushes the dots away from the cursor, "attract" pulls them in. |
| `stiffness` | `number` | `170` | How hard each dot's spring pulls it home. Higher is snappier. |
| `damping` | `number` | `13` | How quickly the springs stop wobbling. Lower overshoots more. |
| `accentColour` | `string` | `"#ddfc3e"` | The colour dots take near the cursor and in a shockwave. Any CSS colour, including var(--x). |
| `baseColour` | `string` | `"currentColor"` | The colour of a dot at rest. "currentColor" follows the text colour around it. |
| `baseOpacity` | `number` | `0.32` | How strong a resting dot's colour is, from 0 to 1, so the grid stays quiet behind content. |
| `shockwave` | `boolean` | `true` | A click (or tap) sends a ring through the grid. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `children` | `ReactNode` | - | Content shown over the grid, e.g. a headline and a button. The grid keeps reacting under it. |

## Source

### magnetic-dot-grid.tsx → `components/dimaac/magnetic-dot-grid.tsx`

```tsx
"use client";

import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";

// A full-bleed grid of dots on a 2D canvas. Every dot has a home spot and a spring that pulls it
// back there. Near the cursor the dots are pushed away (or pulled in), swell and take the accent
// colour; when the cursor leaves the springs carry them home with a little overshoot. A click
// sends a shockwave ring through the grid. The loop sleeps when nothing is moving.

export type MagneticDotGridProps = {
  /** Distance between dots, in pixels. */
  gap?: number;
  /** Radius of a dot at rest, in pixels. */
  dotSize?: number;
  /** How far the cursor reaches, in pixels. */
  radius?: number;
  /** "repel" pushes the dots away from the cursor, "attract" pulls them in. */
  mode?: "repel" | "attract";
  /** How hard each dot's spring pulls it home. Higher is snappier. */
  stiffness?: number;
  /** How quickly the springs stop wobbling. Lower overshoots more. */
  damping?: number;
  /** The colour dots take near the cursor and in a shockwave. Any CSS colour, including var(--x). */
  accentColour?: string;
  /** The colour of a dot at rest. "currentColor" follows the text colour around it. */
  baseColour?: string;
  /** How strong a resting dot's colour is, from 0 to 1, so the grid stays quiet behind content. */
  baseOpacity?: number;
  /** A click (or tap) sends a ring through the grid. */
  shockwave?: boolean;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Content shown over the grid, e.g. a headline and a button. The grid keeps reacting under it. */
  children?: ReactNode;
};

type Dot = { homeX: number; homeY: number; x: number; y: number; vx: number; vy: number; heat: number };
type Ring = { x: number; y: number; age: number };
type Rgb = [number, number, number];

// How many colour steps between base and accent. Dots are drawn in one path per step, which keeps
// a few thousand dots cheap to draw.
const STEPS = 10;
const RING_SPEED = 1100; // px per second
const RING_WIDTH = 80; // px

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// Any CSS colour (currentColor and var(--x) included) to [r, g, b]. A hidden probe inside the box
// lets the browser resolve it in place, then the canvas normalises whatever format comes back.
function resolveRgb(probe: HTMLElement, context: CanvasRenderingContext2D, colour: string): Rgb {
  probe.style.color = "";
  probe.style.color = colour;
  const computed = getComputedStyle(probe).color;
  context.fillStyle = "#000";
  context.fillStyle = computed;
  const value = String(context.fillStyle);
  if (value.startsWith("#")) {
    const n = parseInt(value.slice(1, 7), 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  const parts = value.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
  return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}

// sRGB <-> OKLab. Blending in OKLab keeps the steps between ink and lime clean (an RGB blend runs
// through muddy olive on the way).
const toLinear = (c: number) => {
  const v = c / 255;
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
const fromLinear = (v: number) => {
  const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.max(0, v) ** (1 / 2.4) - 0.055;
  return Math.round(Math.min(1, Math.max(0, c)) * 255);
};
function rgbToOklab([r8, g8, b8]: Rgb): Rgb {
  const r = toLinear(r8);
  const g = toLinear(g8);
  const b = toLinear(b8);
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  return [
    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  ];
}
function oklabToRgb([L, A, B]: Rgb): Rgb {
  const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
  const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
  const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
  return [
    fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
    fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
    fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
  ];
}

// 0 at the edge of the reach, 1 under the cursor, with soft ends
function falloff(distance: number, reach: number) {
  const t = Math.max(0, 1 - distance / reach);
  return t * t * (3 - 2 * t);
}

export function MagneticDotGrid({
  gap = 24,
  dotSize = 1.6,
  radius = 160,
  mode = "repel",
  stiffness = 170,
  damping = 13,
  accentColour = "#ddfc3e",
  baseColour = "currentColor",
  baseOpacity = 0.32,
  shockwave = true,
  className = "",
  children,
}: MagneticDotGridProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const probeRef = useRef<HTMLSpanElement>(null);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
  // The loop reads these every frame, so changing a prop never rebuilds the grid
  const settings = useRef({ dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave });
  // Set by the effect below: wakes the loop after a prop change
  const wakeRef = useRef<() => void>(() => {});

  useEffect(() => {
    settings.current = { dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave };
    wakeRef.current();
  }, [dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave]);

  useEffect(() => {
    const box = boxRef.current;
    const canvas = canvasRef.current;
    const probe = probeRef.current;
    const context = canvas?.getContext("2d");
    if (!box || !canvas || !probe || !context) return;
    const ctx = context;

    let dots: Dot[] = [];
    let rings: Ring[] = [];
    let width = 0;
    let height = 0;
    let frame = 0;
    let running = false;
    let visible = true;
    let lastTime = 0;
    // The biggest change any dot made in the last frame, so the loop can sleep once nothing moves,
    // even with the cursor resting on the grid
    let motion = { speed: 0, offset: 0, heat: 0 };
    // One fill style per colour step, worked out only when a colour, the theme or the size changes
    let bucketStyles: string[] = [];

    function computeColours() {
      const { accentColour: accent, baseColour: base, baseOpacity: opacity } = settings.current;
      const from = rgbToOklab(resolveRgb(probe!, ctx, base));
      const to = rgbToOklab(resolveRgb(probe!, ctx, accent));
      bucketStyles = [];
      for (let index = 0; index <= STEPS; index++) {
        const t = index / STEPS;
        const [r, g, b] = oklabToRgb([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]);
        const alpha = opacity + (1 - opacity) * Math.min(1, t * 1.6);
        bucketStyles.push(`rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`);
      }
    }

    // The raw pointer, and a smoothed copy the dots actually react to, so fast flicks feel liquid
    const pointer = { x: 0, y: 0, active: false };
    const follow = { x: 0, y: 0, strength: 0 };

    // Lay the dots out on a grid centred in the box, so the margins match on every side
    function build() {
      const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
      width = Math.max(1, box!.clientWidth);
      height = Math.max(1, box!.clientHeight);
      canvas!.width = Math.round(width * pixelRatio);
      canvas!.height = Math.round(height * pixelRatio);
      ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
      computeColours();

      const columns = Math.floor(width / gap) + 1;
      const rows = Math.floor(height / gap) + 1;
      const left = (width - (columns - 1) * gap) / 2;
      const top = (height - (rows - 1) * gap) / 2;
      dots = [];
      for (let row = 0; row < rows; row++) {
        for (let column = 0; column < columns; column++) {
          const homeX = left + column * gap;
          const homeY = top + row * gap;
          dots.push({ homeX, homeY, x: homeX, y: homeY, vx: 0, vy: 0, heat: 0 });
        }
      }
      wake();
    }

    // One physics step of dt seconds
    function step(dt: number) {
      const { radius: reach, mode: pull, stiffness: k, damping: c } = settings.current;
      motion = { speed: 0, offset: 0, heat: 0 };

      // The smoothed pointer catches up in about a tenth of a second, and fades in and out
      const catchUp = 1 - Math.exp(-dt * 16);
      follow.x += (pointer.x - follow.x) * catchUp;
      follow.y += (pointer.y - follow.y) * catchUp;
      follow.strength += ((pointer.active ? 1 : 0) - follow.strength) * (1 - Math.exp(-dt * 7));

      for (const ring of rings) ring.age += dt;
      rings = rings.filter((ring) => ring.age * RING_SPEED < Math.hypot(width, height) + RING_WIDTH);

      for (const dot of dots) {
        let targetX = dot.homeX;
        let targetY = dot.homeY;
        let warmth = 0;

        if (follow.strength > 0.001) {
          const dx = dot.homeX - follow.x;
          const dy = dot.homeY - follow.y;
          const distance = Math.hypot(dx, dy) || 0.0001;
          const force = falloff(distance, reach) * follow.strength;
          if (force > 0) {
            // Repel opens a clearing about a third of the reach wide; attract gathers the dots
            // toward the cursor but stops them a little over half a gap short, so they never pile up
            const shift = pull === "repel" ? force * reach * 0.34 : -Math.min(force * reach * 0.3, Math.max(0, distance - gap * 0.6));
            targetX += (dx / distance) * shift;
            targetY += (dy / distance) * shift;
            warmth = force;
          }
        }

        // Each ring kicks the dots it passes outward and lights them up, fading as it travels
        for (const ring of rings) {
          const dx = dot.homeX - ring.x;
          const dy = dot.homeY - ring.y;
          const distance = Math.hypot(dx, dy) || 0.0001;
          const front = ring.age * RING_SPEED;
          const band = Math.max(0, 1 - Math.abs(distance - front) / RING_WIDTH);
          if (band <= 0) continue;
          const fade = Math.max(0, 1 - front / (Math.hypot(width, height) * 0.9));
          const kick = band * band * fade * 5200 * dt;
          dot.vx += (dx / distance) * kick;
          dot.vy += (dy / distance) * kick;
          warmth = Math.max(warmth, band * fade);
        }

        // A damped spring toward the target spot (semi-implicit Euler: stable at these steps)
        dot.vx += (k * (targetX - dot.x) - c * dot.vx) * dt;
        dot.vy += (k * (targetY - dot.y) - c * dot.vy) * dt;
        dot.x += dot.vx * dt;
        dot.y += dot.vy * dt;

        // Heat rises fast and cools a little slower, so the colour leaves a short afterglow
        const rate = warmth > dot.heat ? 14 : 4.5;
        dot.heat += (warmth - dot.heat) * (1 - Math.exp(-dt * rate));

        motion.speed = Math.max(motion.speed, Math.abs(dot.vx), Math.abs(dot.vy));
        motion.offset = Math.max(motion.offset, Math.abs(targetX - dot.x), Math.abs(targetY - dot.y));
        motion.heat = Math.max(motion.heat, Math.abs(warmth - dot.heat));
      }
    }

    // Reduced motion: no springs, no rings. The dots stay home and only the colour follows.
    function stepStill(dt: number) {
      const { radius: reach } = settings.current;
      motion = { speed: 0, offset: 0, heat: 0 };
      follow.x = pointer.x;
      follow.y = pointer.y;
      follow.strength += ((pointer.active ? 1 : 0) - follow.strength) * (1 - Math.exp(-dt * 7));
      rings = [];
      for (const dot of dots) {
        dot.x = dot.homeX;
        dot.y = dot.homeY;
        dot.vx = 0;
        dot.vy = 0;
        const warmth = falloff(Math.hypot(dot.homeX - follow.x, dot.homeY - follow.y), reach) * follow.strength;
        dot.heat += (warmth - dot.heat) * (1 - Math.exp(-dt * 10));
        motion.heat = Math.max(motion.heat, Math.abs(warmth - dot.heat));
      }
    }

    // Colour buckets made once and emptied each frame, so long hovers make no garbage
    const buckets: Dot[][] = Array.from({ length: STEPS + 1 }, () => []);

    function draw() {
      const { dotSize: size, mode: pull } = settings.current;
      // Attract packs the dots closer, so they swell less there and never overlap
      const swell = pull === "attract" ? 1 : 1.6;
      ctx.clearRect(0, 0, width, height);

      // Sort the dots into colour steps, then draw each step as one path
      for (const bucket of buckets) bucket.length = 0;
      for (const dot of dots) buckets[Math.min(STEPS, Math.round(dot.heat * STEPS))]!.push(dot);

      buckets.forEach((bucket, index) => {
        if (bucket.length === 0) return;
        const t = index / STEPS;
        // Hot dots swell to about 2.6 times their size (not in reduced motion: colour only)
        const dotRadius = reducedMotion ? size : size * (1 + t * swell);
        ctx.fillStyle = bucketStyles[index] ?? "transparent";
        ctx.beginPath();
        for (const dot of bucket) {
          ctx.moveTo(dot.x + dotRadius, dot.y);
          ctx.arc(dot.x, dot.y, dotRadius, 0, Math.PI * 2);
        }
        ctx.fill();
      });
    }

    // Nothing left to animate: no rings, the smoothed pointer has caught up and no dot moved or
    // changed colour last frame. With the cursor gone, every dot must also be home and cool.
    function settled() {
      if (rings.length > 0) return false;
      if (Math.abs(pointer.x - follow.x) + Math.abs(pointer.y - follow.y) > 0.1) return false;
      if (Math.abs((pointer.active ? 1 : 0) - follow.strength) > 0.002) return false;
      if (motion.speed > 0.5 || motion.offset > 0.05 || motion.heat > 0.01) return false;
      if (pointer.active) return true;
      for (const dot of dots) {
        if (Math.abs(dot.vx) > 0.5 || Math.abs(dot.vy) > 0.5 || Math.abs(dot.x - dot.homeX) > 0.05 || Math.abs(dot.y - dot.homeY) > 0.05 || dot.heat > 0.01) return false;
      }
      return true;
    }

    function tick(now: number) {
      // Fixed 1/120 s physics steps, so the springs feel the same at 60 Hz and 120 Hz
      let elapsed = Math.min((now - lastTime) / 1000, 1 / 20);
      lastTime = now;
      while (elapsed > 0) {
        const dt = Math.min(elapsed, 1 / 120);
        if (reducedMotion) stepStill(dt);
        else step(dt);
        elapsed -= dt;
      }
      draw();
      if (settled()) {
        // With the cursor gone, snap the last fractions home so the final frame is exact. With it
        // resting on the grid, leave the dots where they are: the next move wakes the loop.
        if (!pointer.active) {
          for (const dot of dots) {
            dot.x = dot.homeX;
            dot.y = dot.homeY;
            dot.heat = 0;
          }
          draw();
        }
        running = false;
        return;
      }
      frame = requestAnimationFrame(tick);
    }

    function wake() {
      if (!visible) return;
      if (running) return;
      running = true;
      lastTime = performance.now();
      frame = requestAnimationFrame(tick);
    }
    // Draw once even when asleep (a new colour or size shows straight away)
    function wakeAndDraw() {
      computeColours();
      draw();
      wake();
    }
    wakeRef.current = wakeAndDraw;

    function place(clientX: number, clientY: number) {
      const rect = box!.getBoundingClientRect();
      pointer.x = clientX - rect.left;
      pointer.y = clientY - rect.top;
      // The first touch of a visit starts at the finger, not at the old spot
      if (follow.strength < 0.01) {
        follow.x = pointer.x;
        follow.y = pointer.y;
      }
      pointer.active = true;
      wake();
    }

    // Mouse and pen: pointer events. Touch: touch events, which keep firing while the page scrolls,
    // so the grid follows the finger without blocking scroll.
    function onPointerMove(event: PointerEvent) {
      if (event.pointerType === "touch") return;
      place(event.clientX, event.clientY);
    }
    function onPointerLeave(event: PointerEvent) {
      if (event.pointerType === "touch") return;
      pointer.active = false;
      // The loop may be asleep with the cursor parked: wake it so the dots go home
      wake();
    }
    function onTouch(event: TouchEvent) {
      const touch = event.touches[0];
      if (touch) place(touch.clientX, touch.clientY);
    }
    function onTouchEnd(event: TouchEvent) {
      if (event.touches.length === 0) {
        pointer.active = false;
        wake();
      }
    }
    // Click rather than pointerdown, so a finger that starts a scroll does not fire a ring. A click
    // made with Enter or Space on a button inside the grid has detail 0 and sits at (0, 0): skip it.
    function onClick(event: MouseEvent) {
      if (!settings.current.shockwave || reducedMotion || event.detail === 0) return;
      const rect = box!.getBoundingClientRect();
      rings.push({ x: event.clientX - rect.left, y: event.clientY - rect.top, age: 0 });
      if (rings.length > 4) rings.shift();
      wake();
    }

    box.addEventListener("pointermove", onPointerMove);
    box.addEventListener("pointerdown", onPointerMove);
    box.addEventListener("pointerleave", onPointerLeave);
    box.addEventListener("touchstart", onTouch, { passive: true });
    box.addEventListener("touchmove", onTouch, { passive: true });
    box.addEventListener("touchend", onTouchEnd);
    box.addEventListener("touchcancel", onTouchEnd);
    box.addEventListener("click", onClick);

    // A new size lays the grid out again
    const resizer = new ResizeObserver(build);
    resizer.observe(box);

    // No work while the box is off screen
    const visibility = new IntersectionObserver(([entry]) => {
      visible = entry?.isIntersecting ?? true;
      if (visible) wakeAndDraw();
      else {
        cancelAnimationFrame(frame);
        running = false;
      }
    });
    visibility.observe(box);

    // "currentColor" changes with the site theme, so redraw when the theme flips
    const themeWatcher = new MutationObserver(wakeAndDraw);
    themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
    const scheme = window.matchMedia("(prefers-color-scheme: dark)");
    scheme.addEventListener("change", wakeAndDraw);

    build();

    return () => {
      cancelAnimationFrame(frame);
      running = false;
      wakeRef.current = () => {};
      resizer.disconnect();
      visibility.disconnect();
      themeWatcher.disconnect();
      scheme.removeEventListener("change", wakeAndDraw);
      box.removeEventListener("pointermove", onPointerMove);
      box.removeEventListener("pointerdown", onPointerMove);
      box.removeEventListener("pointerleave", onPointerLeave);
      box.removeEventListener("touchstart", onTouch);
      box.removeEventListener("touchmove", onTouch);
      box.removeEventListener("touchend", onTouchEnd);
      box.removeEventListener("touchcancel", onTouchEnd);
      box.removeEventListener("click", onClick);
    };
  }, [gap, reducedMotion]);

  return (
    <div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`}>
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 block h-full w-full" />
      {/* Resolves the colour props (currentColor, var(--x)) in place */}
      <span ref={probeRef} aria-hidden="true" className="pointer-events-none absolute h-0 w-0 overflow-hidden" />
      {children !== undefined && <div className="relative h-full w-full">{children}</div>}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { MagneticDotGrid, type MagneticDotGridProps } from "@/components/dimaac/magnetic-dot-grid";

// A landing hero on the dot grid: paper and ink in light mode, ink and paper in dark mode.
// The grid's resting dots follow the text colour, so they retune with the theme by themselves.
// The playground's values arrive as props and go straight to the component.

export default function Demo(props: Partial<MagneticDotGridProps>) {
  // Bright lime vanishes on paper, so the default accent follows the theme: a deep lime in light
  // mode, the brand lime in dark mode. A colour picked in the playground is used as it is.
  const accentColour = !props.accentColour || props.accentColour.toLowerCase() === "#ddfc3e" ? "var(--mdg-accent)" : props.accentColour;
  return (
    <div
      className="h-full w-full bg-[#f4f3ef] text-[#141413] [--mdg-accent:#4d7c0f] dark:bg-[#0c0c0b] dark:text-[#ecebe6] dark:[--mdg-accent:#ddfc3e]"
      style={{ fontFamily: '"Inter Tight", "Helvetica Neue", Arial, sans-serif' }}
    >
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&display=swap" precedence="default" />
      <MagneticDotGrid {...props} accentColour={accentColour} className={`cursor-crosshair select-none ${props.className ?? ""}`}>
        {/* Fade the grid out toward the edges so the middle carries the eye */}
        <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,#f4f3ef_96%)] dark:bg-[radial-gradient(ellipse_at_center,transparent_45%,#0c0c0b_96%)]" />

        <div className="relative flex h-full flex-col px-6 py-6 sm:px-10 sm:py-8">
          <div className="pointer-events-none relative isolate flex flex-1 flex-col items-center justify-center text-center">
            {/* A soft scrim behind the copy, so hot dots passing under the small text stay calm */}
            <div aria-hidden="true" className="absolute top-1/2 left-1/2 -z-10 h-[min(560px,80%)] w-[min(760px,96%)] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(closest-side,#f4f3ef_45%,transparent)] opacity-75 dark:bg-[radial-gradient(closest-side,#0c0c0b_45%,transparent)]" />
            <h1 className="max-w-[12ch] text-[clamp(42px,7vw,96px)] leading-[0.92] font-medium tracking-[-0.05em]">Every point leans away</h1>
            <p className="mt-6 max-w-[36ch] text-[15px] leading-relaxed opacity-60">Move across the grid to part it. Click anywhere to send a ripple through every dot.</p>
          </div>
        </div>
      </MagneticDotGrid>
    </div>
  );
}
```

## Agent prompt

```text
Add the Magnetic Dot Grid component (<MagneticDotGrid />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/magnetic-dot-grid
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/magnetic-dot-grid.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-dot-grid.json

Then use it like this:
import { MagneticDotGrid } from "@/components/dimaac/magnetic-dot-grid";
<MagneticDotGrid />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/magnetic-dot-grid.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Mesh Gradient
description: Soft fields of colour that drift, swirl and blend, with fine film grain.
category: Backgrounds
tier: free
url: https://ui.dimaac.com/components/mesh-gradient
registry: https://ui.dimaac.com/r/mesh-gradient.json
---

# Mesh Gradient

A full-bleed animated gradient made of four or five soft colour fields. Each field drifts on a slow loop of its own, and two layers of noise bend the space they live in, so the edges fold and swirl like wet paint instead of sliding about as round blobs. Fine film grain sits on top and hides banding, and the colours lean slowly towards the cursor or a finger. It is one small WebGL fragment shader with no libraries, capped at a 1.5 pixel ratio, paused while off screen. Four presets come tuned for light and dark pages (aurora teal to mint, dusk, DiMaac lime and ink, and mono paper), or you pass your own colours. Content goes on top as children and can use var(--mesh-ink) for a text colour that reads on the gradient. When the visitor prefers reduced motion it shows a single still frame.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/mesh-gradient.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/mesh-gradient.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/mesh-gradient.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/mesh-gradient`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/mesh-gradient.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { MeshGradient } from "@/components/dimaac/mesh-gradient" and give its parent a size: it fills it.

## Usage

```tsx
import { MeshGradient } from "@/components/dimaac/mesh-gradient";

<MeshGradient />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors` | `readonly string[] \| MeshGradientPreset` | `"auto"` | Four or five hex colours, or a preset name. "auto" is aurora, tuned for the page's light or dark theme. |
| `speed` | `number` | `0.4` | How fast the colours drift, from 0 (still) to 1. |
| `warp` | `number` | `0.6` | How much the noise bends and swirls the colour fields, from 0 (soft blobs) to 1. |
| `grain` | `number` | `0.06` | Film grain strength, from 0 to 0.2. It also hides colour banding. |
| `cursorInfluence` | `number` | `0.5` | How much the colours lean towards the cursor (or finger), from 0 to 1. |
| `theme` | `"auto" \| "light" \| "dark"` | `"auto"` | Which tuning the presets use. "auto" follows the page: data-theme, a dark class, or the system setting. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `children` | `ReactNode` | - | Content shown over the gradient. It can use var(--mesh-ink) for a text colour that reads on it. |

## Source

### mesh-gradient.tsx → `components/dimaac/mesh-gradient.tsx`

```tsx
"use client";

import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";

// A calm, painterly mesh gradient. One small WebGL1 fragment shader: four or five colour
// fields drift on slow loops of their own, the space they live in is bent by two layers of
// noise (domain warping) so the edges swirl like wet paint, and a fine film grain sits on top.
// The colours lean towards the cursor. No libraries, one full-screen triangle, one draw a frame.

export type MeshGradientPreset = "auto" | "aurora" | "dusk" | "lime-ink" | "paper";

export type MeshGradientProps = {
  /** Four or five hex colours, or a preset name. "auto" is aurora, tuned for the page's light or dark theme. */
  colors?: readonly string[] | MeshGradientPreset;
  /** How fast the colours drift, from 0 (still) to 1. */
  speed?: number;
  /** How much the noise bends and swirls the colour fields, from 0 (soft blobs) to 1. */
  warp?: number;
  /** Film grain strength, from 0 to 0.2. It also hides colour banding. */
  grain?: number;
  /** How much the colours lean towards the cursor (or finger), from 0 to 1. */
  cursorInfluence?: number;
  /** Which tuning the presets use. "auto" follows the page: data-theme, a dark class, or the system setting. */
  theme?: "auto" | "light" | "dark";
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Content shown over the gradient. It can use var(--mesh-ink) for a text colour that reads on it. */
  children?: ReactNode;
};

// ---------- palettes, each tuned once for light pages and once for dark ----------

type NamedPreset = Exclude<MeshGradientPreset, "auto">;

// Dark tunings keep the base deep, because light text sits on them. A bright colour may still
// appear, but only as the "accent" field: a tighter, smaller glow that drifts through the deep
// colours instead of washing over them. Light tunings are the mirror.
type Tuning = { colors: string[]; accent?: number };
const presets: Record<NamedPreset, { light: Tuning; dark: Tuning }> = {
  // The owner's favourite: deep aurora teal into mint
  aurora: {
    light: { colors: ["#e6fbf6", "#99f6e4", "#5eead4", "#0f766e", "#f4fbf8"] },
    dark: { colors: ["#021c1a", "#0f766e", "#0a4d47", "#14b8a6", "#99f6e4"], accent: 4 },
  },
  dusk: {
    light: { colors: ["#fff1e8", "#f9a8d4", "#fdba74", "#c4b5fd", "#fde4ef"] },
    dark: { colors: ["#140f2e", "#5b21b6", "#be185d", "#c2410c", "#1e1b4b"] },
  },
  // DiMaac's paper, ink and lime. On dark, ink leads and the brand lime is one tight accent
  // bloom: spread wide, lime mixed into black reads as olive, so it is kept small and bright
  "lime-ink": {
    light: { colors: ["#f7f4ee", "#e9e4d8", "#d6fd51", "#f1f7c9", "#c8c1b0"] },
    dark: { colors: ["#0d0c0a", "#161512", "#d6fd51", "#1c1a16", "#24221d"], accent: 2 },
  },
  paper: {
    light: { colors: ["#faf9f6", "#ebe6dc", "#d8d1c3", "#f3f0e9", "#bfb7a7"] },
    dark: { colors: ["#100f0e", "#24211d", "#3d3832", "#161512", "#5b554b"] },
  },
};

// ---------- shaders ----------

const vertexShader = `
attribute vec2 position;
varying vec2 uv;
void main() {
  uv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

const fragmentShader = `
precision highp float;
varying vec2 uv;
uniform vec3 colors[5];
uniform float count;
uniform float time;
uniform float aspect;
uniform float warp;
uniform float grain;
uniform vec2 pointer;
uniform float pull;
uniform float seed;
uniform float lead;
uniform float accent;

float random(vec2 point) {
  return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
}
// A cleaner hash for the grain, so it reads as film and not as a pattern
float speckle(vec2 point) {
  vec3 p = fract(vec3(point.xyx) * 0.1031);
  p += dot(p, p.yzx + 33.33);
  return fract((p.x + p.y) * p.z);
}
float noise(vec2 point) {
  vec2 cell = floor(point);
  vec2 f = fract(point);
  vec2 blend = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  float a = random(cell);
  float b = random(cell + vec2(1.0, 0.0));
  float c = random(cell + vec2(0.0, 1.0));
  float d = random(cell + vec2(1.0, 1.0));
  return mix(mix(a, b, blend.x), mix(c, d, blend.x), blend.y);
}
// A few layers of noise, each smaller and turned, for an organic, non-grid look
float fbm(vec2 point) {
  float total = 0.0;
  float amount = 0.5;
  mat2 turn = mat2(0.8, 0.6, -0.6, 0.8);
  for (int i = 0; i < 4; i++) {
    total += amount * noise(point);
    point = turn * point * 2.03 + 3.1;
    amount *= 0.5;
  }
  return total;
}

void main() {
  // Measured in shorter-sides, so the fields keep their size on a tall phone as on a wide screen
  vec2 size = vec2(aspect, 1.0) / min(aspect, 1.0);
  vec2 point = uv * size;
  vec2 cursor = pointer * size;

  // The cursor gently draws the space around it in
  vec2 toCursor = cursor - point;
  point += toCursor * exp(-dot(toCursor, toCursor) * 3.0) * pull * 0.3;

  // Domain warping: noise moves the point, then noise of that moves it again
  vec2 q = vec2(fbm(point * 1.2 + vec2(0.0, time * 0.05)), fbm(point * 1.2 + vec2(5.2, 1.3) - time * 0.04));
  vec2 r = vec2(fbm(point * 1.4 + q * 2.2 + vec2(1.7, 9.2) + time * 0.03), fbm(point * 1.4 + q * 2.2 + vec2(8.3, 2.8) - time * 0.035));
  vec2 bent = point + (r - 0.5) * warp * 1.4;

  // Each colour is a soft field around a centre that drifts on its own slow loop.
  // Mixing happens in (roughly) linear light so blends glow instead of going muddy.
  vec3 sum = vec3(0.0);
  float total = 0.0;
  for (int i = 0; i < 5; i++) {
    if (float(i) >= count) break;
    float f = float(i);
    vec2 centre = size * vec2(
      0.5 + cos(time * (0.21 + f * 0.047) + f * 2.4) * 0.45,
      0.5 + sin(time * (0.17 + f * 0.039) + f * 1.7) * 0.42
    );
    centre += (cursor - centre) * pull * 0.22;
    vec2 away = bent - centre;
    // The accent field (if any) is a tighter glow but stronger at its heart, so a bright colour
    // blooms clearly through the deep base and then falls away fast, instead of flooding
    bool isAccent = abs(f - accent) < 0.5;
    float weight = exp(-dot(away, away) * (isAccent ? 9.0 : 4.2)) * (isAccent ? 1.8 : 1.0) + 0.0005;
    // The first colour (the base) can be given a floor, so it always leads the mix
    if (i == 0) weight += lead;
    sum += colors[i] * colors[i] * weight;
    total += weight;
  }
  vec3 color = sqrt(sum / total);

  // A faint sheen along the folds, like light on a brushstroke
  color *= 1.0 + (r.x - 0.5) * 0.12 * warp;

  // Film grain, new every frame, plus a hair of dither so smooth ramps never band
  float speck = speckle(gl_FragCoord.xy + seed * 17.0) - 0.5;
  color += speck * (grain + 1.5 / 255.0);
  gl_FragColor = vec4(color, 1.0);
}`;

// ---------- small helpers ----------

function hexToRgb(hex: string): [number, number, number] {
  let value = hex.trim().replace(/^#/, "");
  if (value.length === 3) value = value.replace(/./g, (c) => c + c);
  const n = /^[0-9a-f]{6}$/i.test(value) ? parseInt(value, 16) : 0x808080;
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

// Is the gradient light overall? Then text on it should be ink, otherwise paper
function isLight(colors: readonly string[]): boolean {
  const lightness = colors.map((hex) => {
    const [r, g, b] = hexToRgb(hex);
    return 0.2126 * r * r + 0.7152 * g * g + 0.0722 * b * b;
  });
  return lightness.reduce((a, b) => a + b, 0) / lightness.length > 0.3;
}

function pageIsDark(): boolean {
  const root = document.documentElement;
  const picked = root.getAttribute("data-theme");
  if (picked === "dark") return true;
  if (picked === "light") return false;
  if (root.classList.contains("dark")) return true;
  if (root.classList.contains("light")) return false;
  return window.matchMedia("(prefers-color-scheme: dark)").matches;
}

function watchPageTheme(onChange: () => void): () => void {
  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
  const query = window.matchMedia("(prefers-color-scheme: dark)");
  query.addEventListener("change", onChange);
  return () => {
    observer.disconnect();
    query.removeEventListener("change", onChange);
  };
}

function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const noSubscription = () => () => {};

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

// ---------- the component ----------

export function MeshGradient({ colors = "auto", speed = 0.4, warp = 0.6, grain = 0.06, cursorInfluence = 0.5, theme = "auto", className = "", children }: MeshGradientProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const hostRef = useRef<HTMLDivElement>(null);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
  // The server can't know the page theme, so it renders no colours at all (see style below)
  const pageDark = useSyncExternalStore(watchPageTheme, pageIsDark, () => true);
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const dark = theme === "auto" ? pageDark : theme === "dark";

  // The colours actually used: a preset (in this theme's tuning) or the caller's own
  const presetName: NamedPreset | null = typeof colors === "string" ? (colors === "auto" ? "aurora" : colors) : null;
  const tuning = presetName ? presets[presetName][dark ? "dark" : "light"] : null;
  const palette = tuning ? tuning.colors : (colors as readonly string[]).slice(0, 5);
  const accent = tuning?.accent ?? -1;
  const paletteKey = palette.join(",");
  // Presets follow the theme (pale on light pages, deep on dark); your own colours are measured
  const light = presetName ? !dark : isLight(palette);

  // Dark presets let the deep base colour always lead, so light text never sits on a bright patch
  const lead = presetName && dark ? (presetName === "aurora" ? 0.35 : 0.45) : 0;
  const settings = useRef({ palette, speed, warp, grain, cursorInfluence, lead, accent });
  // In reduced motion there is no loop, so a prop change asks for one fresh still frame
  const redraw = useRef<(() => void) | null>(null);
  const [failed, setFailed] = useState(false);
  // A lost context (GPU reset, or the browser reclaiming contexts on a busy page) is retried
  const [lost, setLost] = useState(false);
  const retries = useRef(0);
  const [ready, setReady] = useState(false);

  // The render loop reads these every frame, so changing a prop never rebuilds WebGL
  useEffect(() => {
    settings.current = { palette: paletteKey ? paletteKey.split(",") : [], speed, warp, grain, cursorInfluence, lead, accent };
    redraw.current?.();
  }, [paletteKey, speed, warp, grain, cursorInfluence, lead, accent]);

  // After a lost context, wait a moment and build a fresh canvas (a few times at most, so a
  // GPU that keeps failing settles on the CSS fallback instead of flickering)
  useEffect(() => {
    if (!lost) return;
    const id = window.setTimeout(() => {
      retries.current += 1;
      setLost(false);
    }, 1200);
    return () => window.clearTimeout(id);
  }, [lost]);

  useEffect(() => {
    const box = boxRef.current;
    const host = hostRef.current;
    if (!box || !host || failed || lost) return;

    // A fresh canvas every time: a canvas whose context was released can't make a new one
    const canvas = document.createElement("canvas");
    canvas.setAttribute("aria-hidden", "true");
    canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block";
    host.appendChild(canvas);

    const gl = canvas.getContext("webgl", { antialias: false, alpha: false, depth: false, powerPreference: "low-power" });
    if (!gl) {
      canvas.remove();
      setFailed(true);
      return;
    }
    const context = gl;

    const program = context.createProgram()!;
    for (const [type, source] of [
      [context.VERTEX_SHADER, vertexShader],
      [context.FRAGMENT_SHADER, fragmentShader],
    ] as const) {
      const shader = context.createShader(type)!;
      context.shaderSource(shader, source);
      context.compileShader(shader);
      context.attachShader(program, shader);
      context.deleteShader(shader);
    }
    context.bindAttribLocation(program, 0, "position");
    context.linkProgram(program);
    if (!context.getProgramParameter(program, context.LINK_STATUS)) {
      context.deleteProgram(program);
      context.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
      setFailed(true);
      return;
    }
    context.useProgram(program);

    // One triangle that covers the whole screen
    const buffer = context.createBuffer();
    context.bindBuffer(context.ARRAY_BUFFER, buffer);
    context.bufferData(context.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), context.STATIC_DRAW);
    context.enableVertexAttribArray(0);
    context.vertexAttribPointer(0, 2, context.FLOAT, false, 0, 0);

    const uniform = (name: string) => context.getUniformLocation(program, name);
    const at = {
      colors: uniform("colors"),
      count: uniform("count"),
      time: uniform("time"),
      aspect: uniform("aspect"),
      warp: uniform("warp"),
      grain: uniform("grain"),
      pointer: uniform("pointer"),
      pull: uniform("pull"),
      seed: uniform("seed"),
      lead: uniform("lead"),
      accent: uniform("accent"),
    };

    // The shown colours glide to new ones instead of jumping when the palette changes
    function targetColors() {
      const list = settings.current.palette.length ? settings.current.palette : presets.aurora.dark.colors;
      const rgb = list.map(hexToRgb);
      while (rgb.length < 5) rgb.push(rgb[rgb.length % list.length]!);
      return { values: rgb.flat(), count: clamp(list.length, 1, 5) };
    }
    const shown = targetColors().values;

    let alive = true;
    let frame = 0;
    let visible = true;
    // Start part-way into the loops so the first frame is already a nice composition
    let time = 14;
    let lastTime = performance.now();
    let seed = 0;

    // Where the colours lean: the eased pointer, and how present it is (0 when away)
    const pointer = { x: 0.5, y: 0.5, targetX: 0.5, targetY: 0.5, presence: 0, targetPresence: 0 };

    function resize() {
      // Soft colour needs few pixels: cap the pixel ratio at 1.5 to stay light on the GPU
      const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
      canvas.width = Math.max(1, Math.round(box!.clientWidth * pixelRatio));
      canvas.height = Math.max(1, Math.round(box!.clientHeight * pixelRatio));
      context.viewport(0, 0, canvas.width, canvas.height);
      if (reducedMotion) drawFrame();
    }

    function drawFrame() {
      const { count } = targetColors();
      const { warp: bend, grain: grainAmount, cursorInfluence: lean, lead, accent } = settings.current;
      context.uniform3fv(at.colors, shown);
      context.uniform1f(at.count, count);
      context.uniform1f(at.time, time);
      context.uniform1f(at.aspect, canvas.width / canvas.height);
      context.uniform1f(at.warp, clamp(bend, 0, 1));
      context.uniform1f(at.grain, clamp(grainAmount, 0, 0.2));
      context.uniform2f(at.pointer, pointer.x, pointer.y);
      context.uniform1f(at.pull, clamp(lean, 0, 1) * pointer.presence);
      context.uniform1f(at.seed, seed);
      context.uniform1f(at.lead, lead);
      context.uniform1f(at.accent, accent);
      context.drawArrays(context.TRIANGLES, 0, 3);
    }

    // Easing written per 60 Hz frame, turned into the right amount for any frame length
    const ease = (perFrame: number, frames: number) => 1 - Math.pow(1 - perFrame, frames);

    // Advance by the real time since the last frame (measured in 60 Hz frames), so the drift
    // is smooth whatever the refresh rate and never stalls or doubles up on a jittery frame
    function step(frames: number) {
      time += (frames / 60) * clamp(settings.current.speed, 0, 1) * 1.6;
      // The lean is slow on purpose: the colours drift towards the cursor, they don't chase it
      const follow = ease(0.035, frames);
      pointer.x += (pointer.targetX - pointer.x) * follow;
      pointer.y += (pointer.targetY - pointer.y) * follow;
      pointer.presence += (pointer.targetPresence - pointer.presence) * ease(0.03, frames);
      const glide = ease(0.05, frames);
      const target = targetColors().values;
      for (let i = 0; i < shown.length; i++) shown[i]! += (target[i]! - shown[i]!) * glide;
    }

    function render(now: number) {
      if (!alive) return;
      frame = requestAnimationFrame(render);
      if (!visible) {
        lastTime = now;
        return;
      }
      // Capped, so coming back to a tab doesn't jump the colours forward
      const frames = Math.min(Math.max(now - lastTime, 0), 100) / (1000 / 60);
      lastTime = now;
      step(frames);
      seed = (seed + 1.618) % 97;
      drawFrame();
    }

    function onPointerMove(event: PointerEvent) {
      if (!event.isPrimary) return;
      const rect = box!.getBoundingClientRect();
      pointer.targetX = (event.clientX - rect.left) / rect.width;
      pointer.targetY = 1 - (event.clientY - rect.top) / rect.height;
      pointer.targetPresence = 1;
    }
    function onPointerLeave(event: PointerEvent) {
      if (event.pointerType === "mouse") pointer.targetPresence = 0;
    }
    // A lifted finger lets the colours drift back by themselves
    function onPointerUp(event: PointerEvent) {
      if (event.pointerType !== "mouse") pointer.targetPresence = 0;
    }

    function onContextLost(event: Event) {
      event.preventDefault();
      cancelAnimationFrame(frame);
      if (!alive) return;
      if (retries.current < 3) setLost(true);
      else setFailed(true);
    }

    const resizeObserver = new ResizeObserver(resize);
    // Nothing to draw while the box is off screen
    const visibility = new IntersectionObserver(([entry]) => {
      visible = entry?.isIntersecting ?? true;
    });

    resize();
    resizeObserver.observe(box);
    canvas.addEventListener("webglcontextlost", onContextLost);

    if (reducedMotion) {
      // A still frame: the colours snap straight to the palette and stay put
      redraw.current = () => {
        shown.splice(0, shown.length, ...targetColors().values);
        drawFrame();
      };
      redraw.current();
    } else {
      visibility.observe(box);
      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerdown", onPointerMove);
      box.addEventListener("pointerleave", onPointerLeave);
      box.addEventListener("pointerup", onPointerUp);
      box.addEventListener("pointercancel", onPointerUp);
      frame = requestAnimationFrame(render);
    }
    setReady(true);

    return () => {
      alive = false;
      redraw.current = null;
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibility.disconnect();
      box.removeEventListener("pointermove", onPointerMove);
      box.removeEventListener("pointerdown", onPointerMove);
      box.removeEventListener("pointerleave", onPointerLeave);
      box.removeEventListener("pointerup", onPointerUp);
      box.removeEventListener("pointercancel", onPointerUp);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      context.deleteBuffer(buffer);
      context.deleteProgram(program);
      // Hand the context back now: browsers only allow a few at a time
      context.getExtension("WEBGL_lose_context")?.loseContext();
      canvas.remove();
      setReady(false);
    };
  }, [reducedMotion, failed, lost]);

  // Under the canvas (and instead of it without WebGL): the same colours as soft CSS glows
  const [base = "#808080", ...rest] = palette;
  const spots = ["18% 22%", "82% 28%", "70% 82%", "24% 78%"];
  // The accent stays a small glow here too, as in the shader
  const fallback = rest
    .map((hex, i) => {
      const reach = i + 1 === accent ? "34% 34%" : "60% 60%";
      return `radial-gradient(${reach} at ${spots[i % spots.length]}, ${hex} 0%, transparent 70%)`;
    })
    .join(", ");

  // Until hydration the theme is unknown, so nothing commits to a tuning: no fallback colours
  // and no ink (text inherits the page colour). That avoids a flash of the dark palette on a
  // light page. The colours then ease in with the canvas.
  const style = (
    hydrated
      ? {
          "--mesh-ink": light ? "#191713" : "#f7f4ee",
          "--mesh-ink-soft": light ? "rgba(25, 23, 19, 0.68)" : "rgba(247, 244, 238, 0.78)",
          color: "var(--mesh-ink)",
          backgroundColor: base,
          backgroundImage: fallback,
          touchAction: "pan-y",
        }
      : { touchAction: "pan-y" }
  ) as CSSProperties;

  return (
    <div ref={boxRef} className={`relative overflow-hidden ${className}`} style={style}>
      {failed ? null : (
        <div
          ref={hostRef}
          aria-hidden="true"
          className="absolute inset-0"
          style={{ opacity: ready ? 1 : 0, transition: "opacity 1.6s cubic-bezier(0.2, 0.7, 0.2, 1)" }}
        />
      )}
      {/* Pointer events from here bubble up to the box, so the colours still follow the cursor under the content */}
      {children ? <div className="relative h-full w-full">{children}</div> : null}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useEffect, useState } from "react";
import { MeshGradient, type MeshGradientProps } from "@/components/dimaac/mesh-gradient";

// A quiet hero over the gradient. The text takes var(--mesh-ink), so it stays readable on
// every preset in both themes.

export default function Demo(props: Partial<MeshGradientProps>) {
  // The text rises in once the page is up (only when motion is allowed)
  const [shown, setShown] = useState(false);
  useEffect(() => {
    const id = requestAnimationFrame(() => setShown(true));
    return () => cancelAnimationFrame(id);
  }, []);
  const rise = `motion-safe:transition-[transform,opacity] motion-safe:duration-[1400ms] motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] ${shown ? "translate-y-0 opacity-100" : "motion-safe:translate-y-6 motion-safe:opacity-0"}`;

  return (
    <MeshGradient {...props} className={`h-full w-full ${props.className ?? ""}`}>
      <div
        className="pointer-events-none absolute inset-6 flex flex-col justify-center select-none md:inset-x-[72px] md:inset-y-[52px]"
        style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
      >
        <div>
          <h1 className={`max-w-[12ch] text-[clamp(44px,8.4vw,120px)] leading-[0.94] font-medium tracking-[-0.045em] ${rise}`} style={{ transitionDelay: "450ms" }}>
            Colour that slowly breathes
          </h1>
          <p className={`mt-6 max-w-[36ch] text-[16px] leading-[1.45] md:text-[18px] ${rise}`} style={{ color: "var(--mesh-ink-soft)", transitionDelay: "560ms" }}>
            Soft fields of colour drift, fold and blend behind your words, with a fine grain like film.
          </p>
        </div>
      </div>
    </MeshGradient>
  );
}
```

## Agent prompt

```text
Add the Mesh Gradient component (<MeshGradient />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/mesh-gradient
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/mesh-gradient.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/mesh-gradient.json

Then use it like this:
import { MeshGradient } from "@/components/dimaac/mesh-gradient";
<MeshGradient />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/mesh-gradient.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```

---

---
title: Physics Letters
description: Huge letters that drop in, bounce, pile up and can be thrown.
category: Text
tier: free
url: https://ui.dimaac.com/components/physics-letters
registry: https://ui.dimaac.com/r/physics-letters.json
---

# Physics Letters

A word set in huge, heavy letters that fall in one by one with real Matter.js physics, land with a soft squash and a bounce, and settle into a loose pile on the floor. Grab any letter with the mouse or a finger and fling it: it swings from the spot you grabbed it and knocks the others over. The letters are real SVG text sized from the box, so the word nearly fills it at any width and stays crisp; screen readers read the word once from a hidden copy. The physics runs at a fixed 60 steps a second, pauses off screen, keeps the pile in place when the box resizes, and a Drop again button drops the letters once more. With reduced motion the letters sit settled in a neat row and can still be dragged, without being thrown.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/physics-letters.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/physics-letters.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/physics-letters.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/physics-letters`

Dependencies:

```bash
pnpm add matter-js
npm install matter-js
```

By hand:

1. Install the packages it uses: matter-js.
2. Copy the component file below into components/dimaac/physics-letters.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { PhysicsLetters } from "@/components/dimaac/physics-letters" and give its parent a size: it fills it.

## Usage

```tsx
import { PhysicsLetters } from "@/components/dimaac/physics-letters";

<PhysicsLetters />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | `"DIMAAC"` | The word. Spaces leave a gap in the row. Up to 24 characters. |
| `gravity` | `number` | `1` | How hard the letters fall. 1 is normal, 3 is heavy. |
| `bounciness` | `number` | `0.35` | How much a letter bounces when it lands, from 0 (dead) to 0.9 (rubber). |
| `dropStagger` | `number` | `0.12` | The time between one letter dropping and the next, in seconds. |
| `fontWeight` | `number` | `800` | The weight of the font, 500 to 900. Heavy weights look best as physics blocks. |
| `fontFamily` | `string` | `"inherit"` | The font family. "inherit" uses the font of the box around it. |
| `letterColour` | `string` | `"currentColor"` | The colour of the letters. "currentColor" follows the text colour around it. |
| `accentEvery` | `number` | `0` | Paint every Nth letter in the accent colour. 0 paints none. |
| `accentColour` | `string` | `"#ddfc3e"` | The colour of the accent letters. |
| `resetLabel` | `string` | `"Drop again"` | The text of the button that drops the letters again. An empty string hides the button. |
| `resetClassName` | `string` | `""` | Extra classes for the reset button, e.g. to move it. |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |

## Source

### physics-letters.tsx → `components/dimaac/physics-letters.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Bodies, Body, Composite, Constraint, Engine, Events, Vector, type IEventCollision } from "matter-js";

// A word set in huge letters that drop in one by one, bounce and settle into a pile on the floor.
// Matter.js does the physics: every letter's body is the convex hull of its ink (traced once from
// the glyph's pixels), so an A lands as a triangle and nothing leans on empty air. The letters
// themselves are real SVG text, moved to their body's position and angle on every frame, so they
// stay crisp at any size. Grab one with the mouse or a finger and throw it.

export type PhysicsLettersProps = {
  /** The word. Spaces leave a gap in the row. Up to 24 characters. */
  text?: string;
  /** How hard the letters fall. 1 is normal, 3 is heavy. */
  gravity?: number;
  /** How much a letter bounces when it lands, from 0 (dead) to 0.9 (rubber). */
  bounciness?: number;
  /** The time between one letter dropping and the next, in seconds. */
  dropStagger?: number;
  /** The weight of the font, 500 to 900. Heavy weights look best as physics blocks. */
  fontWeight?: number;
  /** The font family. "inherit" uses the font of the box around it. */
  fontFamily?: string;
  /** The colour of the letters. "currentColor" follows the text colour around it. */
  letterColour?: string;
  /** Paint every Nth letter in the accent colour. 0 paints none. */
  accentEvery?: number;
  /** The colour of the accent letters. */
  accentColour?: string;
  /** The text of the button that drops the letters again. An empty string hides the button. */
  resetLabel?: string;
  /** Extra classes for the reset button, e.g. to move it. */
  resetClassName?: string;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
};

// The physics runs at a fixed 60 steps a second, whatever the screen's refresh rate
const STEP = 1000 / 60;
// Letters are measured once at this size, then scaled
const MEASURE_SIZE = 100;
// No letter moves faster than this (pixels per step), so a hard throw can't pass through a wall
const MAX_SPEED = 55;
const WALL = 400;

// A glyph at MEASURE_SIZE: its ink box, the hull of its ink (from the ink box's top-left corner),
// and how far the hull's centre of mass sits from the ink box's centre. Matter puts a body's
// position at its centre of mass, so the element is drawn back from it by that offset.
type Glyph = { char: string; width: number; height: number; left: number; ascent: number; hull: Vector[] | null; offset: Vector };
type Letter = Glyph & { index: number; body: Body | null; dropAt: number; lastSquash: number };

export function PhysicsLetters({
  text = "DIMAAC",
  gravity = 1,
  bounciness = 0.35,
  dropStagger = 0.12,
  fontWeight = 800,
  fontFamily = "inherit",
  letterColour = "currentColor",
  accentEvery = 0,
  accentColour = "#ddfc3e",
  resetLabel = "Drop again",
  resetClassName = "",
  className = "",
}: PhysicsLettersProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const letterRefs = useRef<(HTMLDivElement | null)[]>([]);
  // Set by the physics effect, so the button can drop the letters again without a rebuild
  const resetRef = useRef<() => void>(() => {});

  // Read on every step, so these change live without rebuilding anything
  const live = useRef({ gravity, bounciness, dropStagger });
  // Bumped when the reduced-motion setting changes, so the world rebuilds with the new mode
  const [motionKey, setMotionKey] = useState(0);
  live.current = { gravity, bounciness, dropStagger };

  const word = text.slice(0, 24);
  const chars = [...word];
  const letters = chars.map((char, position) => ({ char, position })).filter(({ char }) => char.trim() !== "");

  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onChange = () => setMotionKey((key) => key + 1);
    query.addEventListener("change", onChange);
    return () => query.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    const box = boxRef.current;
    if (!box || letters.length === 0) return;
    const elements = letterRefs.current.slice(0, letters.length);
    // Read fresh on every build; a change of the setting bumps motionKey and rebuilds
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let cancelled = false;
    let frame = 0;
    let cleanupWorld = () => {};

    const start = async () => {
      const family = getComputedStyle(box).fontFamily;
      const font = `${fontWeight} ${MEASURE_SIZE}px ${family}`;
      // Measure only once the font is in, or the bodies get the fallback font's shapes
      try {
        await document.fonts.load(font, word);
      } catch {
        // A font that fails to load still measures (as the fallback), so carry on
      }
      if (cancelled) return;
      cleanupWorld = buildWorld(box, elements, measure(font, chars), reduced);
    };

    // Each letter's ink box and ink hull at MEASURE_SIZE. Spaces are kept (as a gap) so the row reads right.
    const measure = (font: string, all: string[]): (Glyph | null)[] => {
      const canvas = document.createElement("canvas");
      const context = canvas.getContext("2d", { willReadFrequently: true });
      if (!context) return all.map(() => null);
      context.font = font;
      return all.map((char) => {
        if (char.trim() === "") return null;
        const m = context.measureText(char);
        const glyph = { char, width: m.actualBoundingBoxLeft + m.actualBoundingBoxRight, height: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent, left: m.actualBoundingBoxLeft, ascent: m.actualBoundingBoxAscent };
        const hull = traceHull(canvas, context, font, glyph);
        const centre = hull ? centroid(hull) : { x: glyph.width / 2, y: glyph.height / 2 };
        return { ...glyph, hull, offset: { x: centre.x - glyph.width / 2, y: centre.y - glyph.height / 2 } };
      });
    };

    // Paint the glyph once and take the convex hull of its inked pixels. A hull needs no
    // poly-decomp and is cheap to collide; holes and notches (the inside of a C) don't matter
    // for a pile. Returns null (a rectangle is used) if the canvas can't be read.
    const traceHull = (canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, font: string, glyph: Omit<Glyph, "hull" | "offset">) => {
      const pad = 2;
      const w = Math.ceil(glyph.width) + pad * 2;
      const h = Math.ceil(glyph.height) + pad * 2;
      canvas.width = w;
      canvas.height = h;
      context.font = font;
      context.fillStyle = "#000";
      context.fillText(glyph.char, pad + glyph.left, pad + glyph.ascent);
      let pixels: Uint8ClampedArray;
      try {
        pixels = context.getImageData(0, 0, w, h).data;
      } catch {
        return null;
      }
      // The first and last inked pixel of every row is all a convex hull needs
      const points: Vector[] = [];
      for (let y = 0; y < h; y++) {
        let first = -1;
        let last = -1;
        for (let x = 0; x < w; x++) {
          if (pixels[(y * w + x) * 4 + 3]! > 60) {
            if (first < 0) first = x;
            last = x;
          }
        }
        if (first < 0) continue;
        points.push({ x: first - pad, y: y - pad }, { x: first - pad, y: y + 1 - pad }, { x: last + 1 - pad, y: y - pad }, { x: last + 1 - pad, y: y + 1 - pad });
      }
      if (points.length < 3) return null;
      return simplify(convexHull(points));
    };

    // Andrew's monotone chain: the smallest convex outline around the points, clockwise on screen
    const convexHull = (points: Vector[]) => {
      const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
      const turn = (o: Vector, a: Vector, b: Vector) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
      const half = (list: Vector[]) => {
        const out: Vector[] = [];
        list.forEach((point) => {
          while (out.length >= 2 && turn(out[out.length - 2]!, out[out.length - 1]!, point) <= 0) out.pop();
          out.push(point);
        });
        out.pop();
        return out;
      };
      return [...half(sorted), ...half([...sorted].reverse())];
    };

    // The centre of mass of a polygon (the same point Matter puts a body's position on)
    const centroid = (points: Vector[]) => {
      let area = 0;
      let x = 0;
      let y = 0;
      points.forEach((a, i) => {
        const b = points[(i + 1) % points.length]!;
        const cross = a.x * b.y - b.x * a.y;
        area += cross;
        x += (a.x + b.x) * cross;
        y += (a.y + b.y) * cross;
      });
      return { x: x / (3 * area), y: y / (3 * area) };
    };

    // Round letters give hulls with dozens of tiny edges. Drop the corners that barely bend the
    // outline (under a fifth of a pixel at MEASURE_SIZE) so collisions stay cheap and stable.
    const simplify = (hull: Vector[]) => {
      const points = [...hull];
      let removed = true;
      while (removed && points.length > 8) {
        removed = false;
        for (let i = 0; i < points.length && points.length > 8; i++) {
          const a = points[(i + points.length - 1) % points.length]!;
          const b = points[i]!;
          const c = points[(i + 1) % points.length]!;
          const area = Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)) / 2;
          const base = Math.hypot(c.x - a.x, c.y - a.y) || 1;
          if ((area * 2) / base < 0.2) {
            points.splice(i, 1);
            removed = true;
            i--;
          }
        }
      }
      return points;
    };

    const buildWorld = (box: HTMLDivElement, elements: (HTMLDivElement | null)[], glyphs: (Glyph | null)[], reduced: boolean) => {
      const engine = Engine.create({ gravity: { x: 0, y: live.current.gravity }, positionIterations: 10, velocityIterations: 8 });
      let walls: Body[] = [];
      let width = 0;
      let height = 0;
      let scale = 1;
      let clock = 0;
      let visible = false;
      let grab: { constraint: Constraint; letter: Letter; pointerId: number } | null = null;

      const shapes = glyphs.filter((glyph): glyph is Glyph => glyph !== null);
      const all: Letter[] = shapes.map((glyph, index) => ({ ...glyph, index, body: null, dropAt: 0, lastSquash: 0 }));
      const gap = MEASURE_SIZE * 0.07;
      const spaceWidth = MEASURE_SIZE * 0.32;

      // Where each letter sits in a neat row, at MEASURE_SIZE: its centre from the row's left edge
      const rowCentres: number[] = [];
      let rowWidth = 0;
      glyphs.forEach((glyph) => {
        if (!glyph) {
          rowWidth += spaceWidth;
          return;
        }
        if (rowCentres.length > 0) rowWidth += gap;
        rowCentres.push(rowWidth + glyph.width / 2);
        rowWidth += glyph.width;
      });
      const tallest = Math.max(...shapes.map((glyph) => glyph.height));

      // The word nearly fills the box. On a tall, narrow box (a phone) it's set bigger than the
      // width, so the letters can't fit in one row: they land on each other in a pile (or, with
      // reduced motion, sit in two neat lines).
      const fit = () => {
        const portrait = height > width * 1.2;
        const fill = portrait ? 1.6 : 0.9;
        const byWidth = (width * fill) / rowWidth;
        const byHeight = (height * (portrait ? 0.3 : 0.5)) / tallest;
        const byLetter = (width * 0.42) / Math.max(...shapes.map((glyph) => glyph.width));
        return Math.max(0.05, Math.min(byWidth, byHeight, byLetter));
      };

      // Size each letter's element and its SVG text to the current scale
      const sizeElements = () => {
        all.forEach((letter) => {
          const element = elements[letter.index];
          const svg = element?.firstElementChild;
          const label = svg?.firstElementChild;
          if (!element || !svg || !label) return;
          const w = letter.width * scale;
          const h = letter.height * scale;
          element.style.width = `${w}px`;
          element.style.height = `${h}px`;
          svg.setAttribute("viewBox", `0 0 ${letter.width} ${letter.height}`);
          label.setAttribute("x", String(letter.left));
          label.setAttribute("y", String(letter.ascent));
          label.setAttribute("font-size", String(MEASURE_SIZE));
        });
      };

      // The letter's ink-box offset from its body's position, at the current scale and angle
      const offsetAt = (letter: Letter, angle: number) => Vector.rotate(Vector.mult(letter.offset, scale), angle);

      // A body for the letter whose ink box is centred on (x, y), turned by angle
      const makeBody = (letter: Letter, x: number, y: number, angle: number) => {
        const w = letter.width * scale;
        const h = letter.height * scale;
        const options = { restitution: live.current.bounciness, friction: 0.45, frictionStatic: 0.9, frictionAir: 0.008, density: 0.002, slop: 0.02 };
        const position = Vector.add({ x, y }, offsetAt(letter, angle));
        const body = letter.hull
          ? Bodies.fromVertices(position.x, position.y, [letter.hull.map((point) => Vector.mult(point, scale))], options)
          : Bodies.rectangle(position.x, position.y, w, h, { ...options, chamfer: { radius: Math.min(w, h) * 0.06 } });
        // fromVertices centres the body on its centre of mass; set it exactly, then turn it
        Body.setPosition(body, position);
        Body.setAngle(body, angle);
        return body;
      };

      // The centre of the letter's ink box, where its element is drawn
      const inkCentre = (letter: Letter, body: Body, atScale = scale) =>
        Vector.sub(body.position, Vector.rotate(Vector.mult(letter.offset, atScale), body.angle));

      // Letters keep a little air from the side walls, so the pile doesn't hug one edge
      const gutter = () => Math.max(8, width * 0.015);

      const buildWalls = () => {
        if (walls.length) Composite.remove(engine.world, walls);
        const options = { isStatic: true, friction: 0.6, restitution: 0.2 };
        const inset = gutter();
        walls = [
          Bodies.rectangle(width / 2, height + WALL / 2, width + WALL * 4, WALL, options),
          Bodies.rectangle(inset - WALL / 2, height / 2 - height, WALL, height * 4, options),
          Bodies.rectangle(width - inset + WALL / 2, height / 2 - height, WALL, height * 4, options),
          // The lid sits far above the box, so letters can drop in from outside and a thrown one comes back
          Bodies.rectangle(width / 2, -height * 2.5 - WALL / 2, width + WALL * 4, WALL, options),
        ];
        Composite.add(engine.world, walls);
      };

      // The letters' neat row, wrapped into lines that fit, resting on the floor. Used for reduced
      // motion, and as the x each letter drops from.
      const neatRow = () => {
        const lines: { letter: Letter; centre: number }[][] = [[]];
        let lineStart = 0;
        all.forEach((letter, i) => {
          const centre = rowCentres[i]! * scale;
          const right = centre + (letter.width * scale) / 2;
          if (right - lineStart > width * 0.92 && lines[lines.length - 1]!.length > 0) {
            lineStart = centre - (letter.width * scale) / 2;
            lines.push([]);
          }
          lines[lines.length - 1]!.push({ letter, centre: centre - lineStart });
        });
        // A little air between lines, so round letters (which overshoot) don't touch the line above
        const lineHeight = tallest * scale * 1.14;
        return lines.flatMap((line, lineIndex) => {
          const last = line[line.length - 1]!;
          const lineWidth = last.centre + (last.letter.width * scale) / 2;
          const offset = (width - lineWidth) / 2;
          const floor = height - (lines.length - 1 - lineIndex) * lineHeight;
          return line.map(({ letter, centre }) => ({ letter, x: offset + centre, y: floor - (letter.height * scale) / 2 }));
        });
      };

      const removeLetters = () => {
        if (grab) release();
        all.forEach((letter) => {
          if (letter.body) Composite.remove(engine.world, letter.body);
          letter.body = null;
        });
      };

      // Queue every letter to drop (or, with reduced motion, set them straight down in a row)
      const drop = () => {
        removeLetters();
        clock = 0;
        if (reduced) {
          // Pinned in place, so the rows stay neat. A letter comes loose when it's grabbed.
          neatRow().forEach(({ letter, x, y }) => {
            letter.body = makeBody(letter, x, y, 0);
            Body.setStatic(letter.body, true);
            Composite.add(engine.world, letter.body);
          });
          render();
          return;
        }
        all.forEach((letter, i) => {
          letter.dropAt = 0.15 + i * live.current.dropStagger;
        });
        render();
      };

      const spawn = (letter: Letter) => {
        // Spread the drop points over the width in word order, so the pile still reads left to right
        const centre = rowCentres[letter.index]! / rowWidth;
        const w = letter.width * scale;
        const h = letter.height * scale;
        const inset = gutter();
        const x = Math.min(width - inset - w / 2, Math.max(inset + w / 2, inset + w / 2 + centre * (width - inset * 2 - w) + (Math.random() - 0.5) * w * 0.3));
        const y = -h * 0.8 - Math.random() * h * 0.4;
        letter.body = makeBody(letter, x, y, (Math.random() - 0.5) * 0.6);
        Body.setVelocity(letter.body, { x: (Math.random() - 0.5) * 2, y: 4 });
        Body.setAngularVelocity(letter.body, (Math.random() - 0.5) * 0.06);
        Composite.add(engine.world, letter.body);
      };

      // Move every letter's element to its body. Letters still waiting to drop stay hidden.
      const render = () => {
        all.forEach((letter) => {
          const element = elements[letter.index];
          if (!element) return;
          const body = letter.body;
          if (!body) {
            element.style.visibility = "hidden";
            return;
          }
          const w = letter.width * scale;
          const h = letter.height * scale;
          const centre = inkCentre(letter, body);
          element.style.visibility = "visible";
          element.style.transform = `translate3d(${centre.x - w / 2}px, ${centre.y - h / 2}px, 0) rotate(${body.angle}rad)`;
        });
      };

      // A quick squash on a hard landing makes the letters feel heavy and soft at the same time
      const squash = (letter: Letter, speed: number) => {
        const svg = elements[letter.index]?.firstElementChild;
        if (reduced || !svg || clock - letter.lastSquash < 0.15) return;
        letter.lastSquash = clock;
        const amount = Math.min(0.14, (speed - 5) * 0.012);
        svg.animate([{ transform: `scale(${1 + amount}, ${1 - amount})` }, { transform: "scale(1, 1)" }], { duration: 520, easing: "cubic-bezier(0.25, 1.8, 0.5, 1)" });
      };

      const onCollision = (event: IEventCollision<Engine>) => {
        event.pairs.forEach((pair) => {
          const speed = Vector.magnitude(Vector.sub(pair.bodyA.velocity, pair.bodyB.velocity));
          if (speed < 6) return;
          all.forEach((letter) => {
            if (letter.body === pair.bodyA || letter.body === pair.bodyB) squash(letter, speed);
          });
        });
      };
      Events.on(engine, "collisionStart", onCollision);

      const step = () => {
        engine.gravity.y = live.current.gravity;
        clock += STEP / 1000;
        all.forEach((letter) => {
          if (!letter.body && !reduced && clock >= letter.dropAt) spawn(letter);
          const body = letter.body;
          if (!body) return;
          body.restitution = live.current.bounciness;
          const speed = Vector.magnitude(body.velocity);
          if (speed > MAX_SPEED) Body.setVelocity(body, Vector.mult(body.velocity, MAX_SPEED / speed));
          // A letter that still escaped (squeezed past a wall) comes back from the top
          if (body.position.x < -WALL || body.position.x > width + WALL || body.position.y > height + WALL) {
            Body.setPosition(body, Vector.add({ x: width / 2, y: -letter.height * scale }, offsetAt(letter, body.angle)));
            Body.setVelocity(body, { x: 0, y: 0 });
          }
        });
        Engine.update(engine, STEP);
      };

      let last = performance.now();
      let carry = 0;
      const loop = (now: number) => {
        frame = requestAnimationFrame(loop);
        const elapsed = Math.min(now - last, 100);
        last = now;
        // Nothing moves while the box is off screen, and the drop waits until it's seen
        if (!visible) return;
        carry += elapsed;
        let steps = 0;
        while (carry >= STEP && steps < 4) {
          step();
          carry -= STEP;
          steps++;
        }
        if (steps === 4) carry = 0;
        if (steps > 0) render();
      };

      const layout = (first: boolean) => {
        const nextWidth = box.clientWidth;
        const nextHeight = box.clientHeight;
        if (nextWidth === 0 || nextHeight === 0) return;
        const oldWidth = width;
        const oldHeight = height;
        const oldScale = scale;
        width = nextWidth;
        height = nextHeight;
        scale = fit();
        buildWalls();
        sizeElements();
        if (first) {
          drop();
          return;
        }
        // Keep each fallen letter where it was, in proportion, at its new size
        all.forEach((letter) => {
          const body = letter.body;
          if (!body) return;
          const centre = inkCentre(letter, body, oldScale);
          const x = (centre.x / oldWidth) * width;
          const y = Math.min((centre.y / oldHeight) * height, height - (letter.height * scale) / 2);
          const angle = body.angle;
          if (grab?.letter === letter) release();
          Composite.remove(engine.world, body);
          letter.body = makeBody(letter, x, y, angle);
          if (body.isStatic) Body.setStatic(letter.body, true);
          Composite.add(engine.world, letter.body);
        });
        if (reduced && oldScale !== scale) drop();
        render();
      };

      // Grabbing: a springy constraint pulls the letter toward the pointer, from the spot you
      // grabbed it, so it swings and tumbles like a real object. Letting go keeps its speed.
      const pointFrom = (event: PointerEvent) => {
        const rect = box.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
      };

      const release = () => {
        if (!grab) return;
        const { constraint, letter } = grab;
        Composite.remove(engine.world, constraint);
        const element = elements[letter.index];
        if (element) element.style.cursor = "";
        if (reduced && letter.body) {
          // Reduced motion: no throw, the letter just drops from where you let it go
          Body.setVelocity(letter.body, { x: 0, y: 0 });
          Body.setAngularVelocity(letter.body, 0);
        }
        grab = null;
      };

      const onPointerDown = (event: PointerEvent) => {
        if (!event.isPrimary || event.button > 0) return;
        const target = (event.target as Element | null)?.closest<HTMLElement>("[data-letter]");
        const letter = all.find((item) => elements[item.index] === target);
        if (!letter?.body) return;
        event.preventDefault();
        if (letter.body.isStatic) Body.setStatic(letter.body, false);
        const point = pointFrom(event);
        const constraint = Constraint.create({
          pointA: point,
          bodyB: letter.body,
          pointB: Vector.sub(point, letter.body.position),
          length: 0,
          stiffness: reduced ? 0.4 : 0.18,
          damping: 0.12,
        });
        Composite.add(engine.world, constraint);
        grab = { constraint, letter, pointerId: event.pointerId };
        box.setPointerCapture(event.pointerId);
        if (target) target.style.cursor = "grabbing";
      };

      const onPointerMove = (event: PointerEvent) => {
        if (!grab || event.pointerId !== grab.pointerId) return;
        grab.constraint.pointA = pointFrom(event);
      };

      const onPointerUp = (event: PointerEvent) => {
        if (!grab || event.pointerId !== grab.pointerId) return;
        if (box.hasPointerCapture(event.pointerId)) box.releasePointerCapture(event.pointerId);
        release();
      };

      box.addEventListener("pointerdown", onPointerDown);
      box.addEventListener("pointermove", onPointerMove);
      box.addEventListener("pointerup", onPointerUp);
      box.addEventListener("pointercancel", onPointerUp);
      box.addEventListener("lostpointercapture", onPointerUp);

      let resizeTimer = 0;
      const resize = new ResizeObserver(() => {
        window.clearTimeout(resizeTimer);
        resizeTimer = window.setTimeout(() => {
          if (box.clientWidth !== width || box.clientHeight !== height) layout(width === 0);
        }, 120);
      });
      resize.observe(box);

      const visibility = new IntersectionObserver(([entry]) => {
        visible = entry?.isIntersecting ?? true;
        last = performance.now();
      });
      visibility.observe(box);

      resetRef.current = drop;
      layout(true);
      frame = requestAnimationFrame(loop);

      return () => {
        cancelAnimationFrame(frame);
        window.clearTimeout(resizeTimer);
        resize.disconnect();
        visibility.disconnect();
        box.removeEventListener("pointerdown", onPointerDown);
        box.removeEventListener("pointermove", onPointerMove);
        box.removeEventListener("pointerup", onPointerUp);
        box.removeEventListener("pointercancel", onPointerUp);
        box.removeEventListener("lostpointercapture", onPointerUp);
        Events.off(engine, "collisionStart", onCollision);
        resetRef.current = () => {};
        Composite.clear(engine.world, false);
        Engine.clear(engine);
        elements.forEach((element) => {
          element?.firstElementChild?.getAnimations().forEach((animation) => animation.cancel());
        });
      };
    };

    void start();
    return () => {
      cancelled = true;
      cancelAnimationFrame(frame);
      cleanupWorld();
    };
    // The world is rebuilt when the letters themselves change; everything else is read live
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [word, fontWeight, fontFamily, motionKey]);

  return (
    <div
      ref={boxRef}
      className={`relative h-full w-full overflow-hidden select-none ${className}`}
      style={{ touchAction: "pan-y", ...(fontFamily !== "inherit" ? { fontFamily } : null) }}
    >
      {/* What a screen reader reads: the word itself, once */}
      <span className="sr-only">{word}</span>

      <div aria-hidden="true">
        {letters.map(({ char, position }, index) => {
          const accent = accentEvery > 0 && (index + 1) % accentEvery === 0;
          return (
            <div
              key={`${position}-${char}`}
              ref={(element) => {
                letterRefs.current[index] = element;
              }}
              data-letter=""
              className="absolute top-0 left-0 cursor-grab"
              style={{ visibility: "hidden", touchAction: "none", willChange: "transform", transformOrigin: "50% 50%" }}
            >
              <svg className="block h-full w-full overflow-visible" preserveAspectRatio="none">
                <text fill={accent ? accentColour : letterColour} fontWeight={fontWeight} style={{ fontFamily: "inherit" }}>
                  {char}
                </text>
              </svg>
            </div>
          );
        })}
      </div>

      {resetLabel ? (
        <button
          type="button"
          onClick={() => resetRef.current()}
          className={`absolute top-4 right-4 z-20 inline-flex cursor-pointer items-center gap-2 text-[13px] font-medium opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 ${resetClassName}`}
        >
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M3 12a9 9 0 1 0 3-6.7" />
            <path d="M3 4v5h5" />
          </svg>
          {resetLabel}
        </button>
      ) : null}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { PhysicsLetters, type PhysicsLettersProps } from "@/components/dimaac/physics-letters";

// A kinetic poster: the word drops onto the floor of the stage, and you can throw it around.
// The letters follow the stage's text colour, so the same demo works on paper and on ink.

export default function Demo(props: Partial<PhysicsLettersProps>) {
  return (
    <div
      className="relative h-full w-full overflow-hidden bg-[#efeee9] text-[#0d0d0c] [--accent-letter:#b8d900] dark:bg-[#0c0c0b] dark:text-[#f1efe8] dark:[--accent-letter:#ddfc3e]"
      style={{ fontFamily: '"Inter Tight", ui-sans-serif, system-ui, sans-serif' }}
    >
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500..900&display=swap" precedence="default" />

      <p className="pointer-events-none absolute top-4 left-5 z-10 text-[13px] font-medium opacity-70 md:top-6 md:left-8">Grab a letter and throw it</p>

      <PhysicsLetters
        accentColour="var(--accent-letter)"
        {...props}
        resetClassName="md:top-6 md:right-8 right-5"
        className={`absolute inset-0 ${props.className ?? ""}`}
      />
    </div>
  );
}
```

## Agent prompt

```text
Add the Physics Letters component (<PhysicsLetters />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/physics-letters
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/physics-letters.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/physics-letters.json

Then use it like this:
import { PhysicsLetters } from "@/components/dimaac/physics-letters";
<PhysicsLetters />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/physics-letters.tsx and install matter-js.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
