Components

Getting started

Blocks21

Components155

More

What a list says before there is anything in it: an isometric line drawing in your theme that draws itself, changes with the state and acts out the action.

Used forFirst runSearch and filtersIntegrations and feedsInboxes and queues

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/empty-state.json

Templates

Need the whole page?

44 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 44 templates

Where it’s used

  • First run

    A new workspace with no projects, and Create project.

  • Search and filters

    No results for a query, with Clear search and suggestions.

  • Integrations and feeds

    Can't reach the API, then connected but no events yet.

  • Inboxes and queues

    Everything handled: you're all caught up.

What it handles for you

Most empty states are a dead end: a picture that never changes and a button that gives no sign it did anything. This one changes with the state and shows the action working.

Instead of wiring upan empty-state illustrationan SVG icon seta loading buttonan error messagea no-results message
  1. Art in your theme

    Faces and lines are mixed from your shadcn tokens, so the drawing follows any brand in light and dark; pass illustration to use your own.

  2. Five states, one tile

    inbox, search, offline, chart and done share the tile; changing scene un-draws the old object and draws the new one.

  3. Drawn when seen

    Lines draw back to front and faces fill once the drawing is on screen, never while it is out of sight.

  4. The action, acted out

    onAction runs as Status button (Working → Done); the drawing moves into its pending and done poses with it.

  5. Failures said in place

    Throw from onAction: the piece springs back with a shake, the button says Try again and your message folds in.

  6. Fits a card or a page

    A container query shrinks the drawing and padding in narrow cards and table bodies.

Overview

Empty states are usually a grey icon, a sentence and a button, or a stock illustration in someone else's colours that never changes. This one is a line drawing built in code on one isometric tile, drawn with your theme's tokens in light and dark. It draws itself the first time it scrolls into view. When the state changes (nothing yet, no results, can't connect, no data, all done) the old object un-draws and the new one rises from the same tile, while the title morphs letter by letter. The main action is a Status button and the drawing acts it out: the first sheet lands in the tray, the plug slides into its socket, the lens sweeps the empty cards. If the action throws, the piece springs back with a shake and your message folds in under the buttons.

States

first view
The tile draws itself, then the scene: each line traces in 640ms, 34ms after the one behind it, and its face fills over the last third.
scene change
The old object retracts front to back in 340ms while the new one draws in 160ms later; the tile stays.
title and description
The title morphs through TextMorph; the description lifts out through a 4px blur as the new one rises in, and the height eases over 380ms.
action running
The button shows its spinner and pending label; the hero piece eases to its pending pose (the sheet lowers, the plug moves toward the socket).
action done
The check draws in the button and the piece lands in its done pose with a slight settle (620ms).
action failed
The piece returns and shakes ±4px, the button says Try again, and the thrown message folds in under it.

Usage

usage.tsx
import { EmptyState } from "@/components/empty-state";<EmptyState  scene="inbox"  title="No projects yet"  description="Start one and invite your team."  action={{ label: "Create project", pendingLabel: "Creating", successLabel: "Created", onAction: createProject }}/>

Works with

In a searchable list

One empty state for every way a list can be empty: the scene follows why it is empty, so a cleared search or a failed load changes the drawing in place.

example.tsx
"use client";import { EmptyState } from "@/components/empty-state";export function ProjectList({ projects, query, error, onCreate, onRetry, onClear }: Props) {  if (error) {    return (      <EmptyState        scene="offline"        title="Couldn't load projects"        description={error}        action={{ label: "Retry", pendingLabel: "Loading", successLabel: "Loaded", onAction: onRetry }}      />    );  }  if (projects.length === 0) {    return query ? (      <EmptyState        scene="search"        title={`No results for “${query}”`}        description="Check the spelling, or search by owner or tag."        secondaryAction={{ label: "Clear search", onAction: onClear }}      />    ) : (      <EmptyState        scene="inbox"        title="No projects yet"        description="Start one and invite your team."        // Throw to keep the empty state and show why: throw new Error("You've reached 3 projects on Free.")        action={{ label: "Create project", pendingLabel: "Creating", successLabel: "Created", onAction: onCreate }}      />    );  }  return <ul>{/* your rows */}</ul>;}

Props

PropTypeDescription
scene"inbox" | "search" | "done" | "offline" | "chart"default "inbox"The drawing: nothing yet, no results, all done, can't connect or no data. Changing it redraws the object on the same tile.
illustrationReactNodeYour own art in place of the drawing.
titlestringThe headline; changes morph letter by letter.
descriptionReactNodeA sentence or two under it; changes cross-fade.
action{ label, onAction?, pendingLabel?, successLabel?, errorLabel?, icon? } | ReactNodeThe main action as a Status button whose pending, done and failed states the drawing acts out; onAction may return a promise and throws to fail. Or pass your own button.
secondaryAction{ label, onAction?, href? }A quieter button or link beside it.
childrenReactNodeAnything under the text, such as suggested searches.
classNamestringClasses for the root.

Accessibility and motion

  • The drawing is decorative (aria-hidden); the title is a heading and the failure message is announced with role="alert".
  • Status button announces Working, Done and Try again outside the button.
  • Reduced motion: no drawing, rolling or shaking; scenes cross-fade and text changes in place.
  • Below 22rem of container width the drawing shrinks from 168 to 132px wide and the padding tightens.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Status button, Text morph.

Source

components/empty-state.tsx
"use client";import * as React from "react";import { StatusButton, type ActionStatus } from "./status-button";import { TextMorph } from "./text-morph";import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";import { SCENES, TILE, VIEW, poseOffset, type EmptyStateScene, type SceneFill, type SceneShape, type SceneStroke } from "./empty-state-scenes";export type { EmptyStateScene } from "./empty-state-scenes";/* ───────────────────────────────────────────────────────── * EMPTY STATE: what a list says before there's anything in it * *   drawing   an isometric line drawing built in code on one tile, *             in the theme's own colours: it draws itself the first *             time it's seen, and when the state changes (nothing *             yet → no results → all done) the old object un-draws *             and the new one rises from the same tile *   action    the main action is a Status button, and the drawing *             acts it out: the first sheet lands in the tray, the *             plug goes into its socket, the lens sweeps the cards; *             if it throws, the piece springs back with a shake and *             the reason folds in under the buttons *   text      the title morphs letter by letter, the description *             cross-fades, and the height eases between them * ───────────────────────────────────────────────────────── */export interface EmptyStateAction {  label: string;  /** Runs the action. Return a promise to show it running, then done; throw to say why it failed. */  onAction?: () => unknown;  /** Labels while it runs, once it worked and after it failed (Working, Done, Try again). */  pendingLabel?: string;  successLabel?: string;  errorLabel?: string;  /** Shown before the label while idle. */  icon?: React.ReactNode;}export interface EmptyStateSecondaryAction {  label: string;  onAction?: () => void;  /** A link instead of a button. */  href?: string;}export interface EmptyStateProps {  /** Which drawing: nothing yet (inbox), no results (search), all done, can't connect (offline) or no data (chart). */  scene?: EmptyStateScene;  /** Your own art in place of the drawing. */  illustration?: React.ReactNode;  title: string;  description?: React.ReactNode;  /** The main action, run as a Status button; or your own button. */  action?: EmptyStateAction | React.ReactNode;  /** A quieter second action, such as Clear filters or Learn more. */  secondaryAction?: EmptyStateSecondaryAction;  /** Anything under the text, such as suggested searches. */  children?: React.ReactNode;  className?: string;}const FILL: Record<SceneFill, string> = {  top: "color-mix(in oklab, var(--foreground) 3%, var(--background))",  left: "color-mix(in oklab, var(--foreground) 7%, var(--background))",  right: "color-mix(in oklab, var(--foreground) 12%, var(--background))",  hole: "color-mix(in oklab, var(--foreground) 16%, var(--background))",  glass: "color-mix(in oklab, var(--primary) 7%, var(--background))",  accent: "color-mix(in oklab, var(--primary) 16%, var(--background))",  "accent-side": "color-mix(in oklab, var(--primary) 32%, var(--background))",  none: "none",};const STROKE: Record<SceneStroke, string> = {  line: "color-mix(in oklab, var(--foreground) 46%, var(--background))",  faint: "color-mix(in oklab, var(--foreground) 20%, var(--background))",  accent: "var(--primary)",};// Slightly longer than the path, so a drawn line never shows a hairline gap where it closes.const DASH = 1.02;const DRAW_EASE = "cubic-bezier(0.65, 0, 0.35, 1)";const isAction = (action: unknown): action is EmptyStateAction =>  Boolean(action) && typeof action === "object" && !React.isValidElement(action) && "label" in (action as object);/** Lines draw themselves back to front, then their faces fill; ghosts fade in. */function drawIn(root: Element, delay = 0) {  root.querySelectorAll<SVGPathElement>("[data-shape]").forEach((el, i) => {    const at = delay + Math.min(i * 34, 560);    if (el.dataset.dashed) {      el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay: at, easing: EASE_OUT, fill: "backwards" });      return;    }    el.animate(      [        { strokeDashoffset: DASH, fillOpacity: 0 },        { strokeDashoffset: 0, fillOpacity: 0, offset: 0.62 },        { strokeDashoffset: 0, fillOpacity: 1 },      ],      { duration: 640, delay: at, easing: DRAW_EASE, fill: "backwards" },    );  });}/** The reverse, front to back and quicker: lines retract and faces empty. */function drawOut(root: Element) {  const shapes = [...root.querySelectorAll<SVGPathElement>("[data-shape]")].reverse();  shapes.forEach((el, i) => {    const delay = Math.min(i * 14, 200);    if (el.dataset.dashed) {      el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, delay, easing: EASE_OUT, fill: "forwards" });      return;    }    el.animate(      [        { strokeDashoffset: 0, fillOpacity: 1 },        { strokeDashoffset: 0, fillOpacity: 0, offset: 0.35 },        { strokeDashoffset: -DASH, fillOpacity: 0 },      ],      { duration: 340, delay, easing: DRAW_EASE, fill: "forwards" },    );  });}function Shape({ shape, clipId }: { shape: SceneShape; clipId?: string }) {  const el = (    <path      data-shape=""      data-dashed={shape.dashed ? "" : undefined}      d={shape.d}      fillRule={shape.ring ? "evenodd" : undefined}      pathLength={shape.dashed ? undefined : 1}      strokeDasharray={shape.dashed ? "2 2.5" : `${DASH} ${DASH}`}      strokeLinejoin="round"      strokeLinecap="round"      strokeWidth={shape.stroke === "accent" && shape.fill === "none" ? 1.5 : 1}      style={{ fill: FILL[shape.fill], stroke: STROKE[shape.stroke] }}    />  );  return clipId ? <g clipPath={`url(#${clipId})`}>{el}</g> : el;}function Shapes({ shapes, id }: { shapes: SceneShape[]; id: string }) {  const clips = [...new Set(shapes.flatMap((s) => (s.clip ? [s.clip] : [])))];  return (    <>      {clips.length > 0 && (        <defs>          {clips.map((d, i) => (            <clipPath key={d} id={`${id}-${i}`}>              <path d={d} />            </clipPath>          ))}        </defs>      )}      {shapes.map((shape, i) => (        <Shape key={i} shape={shape} clipId={shape.clip ? `${id}-${clips.indexOf(shape.clip)}` : undefined} />      ))}    </>  );}/** Plays its drawing in once `seen` (the first time on screen, or at once for a scene that replaces another). */function useDrawIn(seen: boolean, reduced: boolean, delay: number) {  const ref = React.useRef<SVGGElement>(null);  const drawn = React.useRef(false);  React.useLayoutEffect(() => {    if (!seen || drawn.current) return;    drawn.current = true;    if (!reduced && ref.current) drawIn(ref.current, delay);  }, [seen, reduced, delay]);  return ref;}const Tile = React.memo(function Tile({ seen, reduced, id }: { seen: boolean; reduced: boolean; id: string }) {  const ref = useDrawIn(seen, reduced, 0);  return (    <g ref={ref}>      <Shapes shapes={TILE} id={id} />    </g>  );});interface LayerProps {  scene: EmptyStateScene;  leaving: boolean;  seen: boolean;  reduced: boolean;  status: ActionStatus;  /** The first scene waits for the tile; later ones wait for the old one to start leaving. */  delay: number;  id: string;  heroRef?: React.Ref<SVGGElement>;}const Layer = React.memo(function Layer({ scene, leaving, seen, reduced, status, delay, id, heroRef }: LayerProps) {  const def = React.useMemo(() => SCENES[scene](), [scene]);  const ref = useDrawIn(seen, reduced, delay);  React.useLayoutEffect(() => {    if (leaving && !reduced && ref.current) drawOut(ref.current);  }, [leaving, reduced, ref]);  const pose = (status === "pending" && def.poses?.pending) || (status === "success" && def.poses?.success) || ([0, 0, 0] as [number, number, number]);  const [dx, dy] = poseOffset(pose);  const landing = status === "success";  const base = def.shapes.filter((s) => !s.hero);  const hero = def.shapes.filter((s) => s.hero);  return (    <g      ref={ref}      style={{        opacity: leaving && reduced ? 0 : 1,        transition: reduced ? "opacity 200ms linear" : undefined,      }}    >      <Shapes shapes={base} id={`${id}-b`} />      <g        ref={heroRef}        style={{          transform: `translate(${dx}px, ${dy}px)`,          transition: reduced ? "none" : `transform ${landing ? 620 : 520}ms ${landing ? EASE_SETTLE : EASE_OUT}`,        }}      >        <Shapes shapes={hero} id={`${id}-h`} />      </g>    </g>  );});/** Keeps the old content a moment so it can cross-fade out while the new one comes in, with the height eased between. */function Swap({ swapKey, reduced, children }: { swapKey: string; reduced: boolean; children: React.ReactNode }) {  const [shown, setShown] = React.useState({ key: swapKey, node: children });  const [prev, setPrev] = React.useState<{ key: string; node: React.ReactNode } | null>(null);  if (swapKey !== shown.key) {    setPrev(shown);    setShown({ key: swapKey, node: children });  }  const inner = React.useRef<HTMLDivElement>(null);  const [height, setHeight] = React.useState<number | null>(null);  React.useEffect(() => {    if (!prev) return;    const timer = window.setTimeout(() => setPrev(null), 260);    return () => window.clearTimeout(timer);  }, [prev]);  // The inner box is a new element for each description, so it's observed again after every swap.  React.useLayoutEffect(() => {    const el = inner.current;    if (!el) return;    const observer = new ResizeObserver(() => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight)));    observer.observe(el);    return () => observer.disconnect();  }, [shown.key]);  return (    <div      className="relative w-full"      style={{ height: height ?? undefined, transition: reduced || height === null ? "none" : `height 380ms ${EASE_OUT}` }}    >      {prev && !reduced && (        <div          key={prev.key}          aria-hidden="true"          className="pointer-events-none absolute inset-x-0 top-0 animate-[ui-empty-out_200ms_cubic-bezier(0.16,1,0.3,1)_forwards]"        >          {prev.node}        </div>      )}      <div ref={inner} key={shown.key} className={prev && !reduced ? "animate-[ui-empty-in_360ms_cubic-bezier(0.16,1,0.3,1)_80ms_backwards]" : undefined}>        {children}      </div>    </div>  );}export function EmptyState({ scene = "inbox", illustration, title, description, action, secondaryAction, children, className = "" }: EmptyStateProps) {  const reduced = useInteractionReducedMotion();  const id = React.useId().replace(/:/g, "");  const rootRef = React.useRef<HTMLDivElement>(null);  const heroRef = React.useRef<SVGGElement>(null);  const [seen, setSeen] = React.useState(false);  const [status, setStatus] = React.useState<ActionStatus>("idle");  const [error, setError] = React.useState<string | null>(null);  const [layers, setLayers] = React.useState([{ key: 0, scene, leaving: false }]);  const [lastScene, setLastScene] = React.useState(scene);  const [generation, setGeneration] = React.useState(0);  const generationRef = React.useRef(generation);  // A new scene: the old drawing starts leaving, the new one comes in over it, and the action starts over.  if (scene !== lastScene) {    setLastScene(scene);    setLayers((prev) => [...prev.filter((l) => !l.leaving).map((l) => ({ ...l, leaving: true })), { key: Math.max(...prev.map((l) => l.key)) + 1, scene, leaving: false }]);    setStatus("idle");    setError(null);    setGeneration((g) => g + 1);  }  React.useEffect(() => {    generationRef.current = generation;  }, [generation]);  React.useEffect(() => {    if (!layers.some((l) => l.leaving)) return;    const timer = window.setTimeout(() => setLayers((prev) => prev.filter((l) => !l.leaving)), reduced ? 220 : 560);    return () => window.clearTimeout(timer);  }, [layers, reduced]);  // The drawing waits until it's on screen, so it's never played out of sight.  React.useEffect(() => {    const el = rootRef.current;    if (!el) return;    const observer = new IntersectionObserver(      ([entry]) => {        if (!entry.isIntersecting) return;        setSeen(true);        observer.disconnect();      },      { threshold: 0.25 },    );    observer.observe(el);    return () => observer.disconnect();  }, []);  // The piece that acts the action out springs back with a shake when it fails.  React.useEffect(() => {    if (status !== "error" || reduced) return;    heroRef.current?.animate(      [{ transform: "translateX(0)" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "translateX(0)" }],      { duration: 380, easing: "ease-out", composite: "add" },    );  }, [status, reduced]);  const act = isAction(action) ? action : null;  const run = async () => {    if (!act?.onAction || status === "pending") return;    const mine = generationRef.current;    setStatus("pending");    setError(null);    try {      await act.onAction();      if (generationRef.current === mine) setStatus("success");    } catch (reason) {      if (generationRef.current !== mine) return;      setStatus("error");      setError(reason instanceof Error ? reason.message : typeof reason === "string" ? reason : null);    }  };  const descriptionKey = typeof description === "string" ? description : `${scene}:${title}`;  const hasActions = Boolean(action || secondaryAction);  const secondaryClass = `inline-flex h-9 cursor-pointer items-center rounded-full px-4 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;  return (    <div ref={rootRef} className={`@container w-full ${className}`}>      <div className="flex flex-col items-center px-6 py-10 text-center @max-[22rem]:px-4 @max-[22rem]:py-7">        <div className="mb-5 flex h-[140px] w-[168px] items-center justify-center @max-[22rem]:mb-4 @max-[22rem]:h-[110px] @max-[22rem]:w-[132px]" aria-hidden="true">          {illustration ?? (            <svg              viewBox={`${VIEW.x} ${VIEW.y} ${VIEW.width} ${VIEW.height}`}              className="size-full overflow-visible"              style={{ opacity: seen || reduced ? 1 : 0 }}            >              <Tile seen={seen || reduced} reduced={reduced} id={`${id}-t`} />              {layers.map((layer) => (                <Layer                  key={layer.key}                  scene={layer.scene}                  leaving={layer.leaving}                  seen={seen || reduced}                  reduced={reduced}                  status={layer.leaving ? "idle" : status}                  delay={layer.key === 0 ? 260 : 160}                  id={`${id}-${layer.key}`}                  heroRef={layer.leaving ? undefined : heroRef}                />              ))}            </svg>          )}        </div>        <h3 className="max-w-full text-[15px] font-medium leading-6 tracking-[-0.01em] text-foreground">          <TextMorph>{title}</TextMorph>        </h3>        {description !== undefined && (          <div className="mt-1 w-full max-w-[36ch] text-[13.5px] leading-[1.55] text-muted-foreground">            <Swap swapKey={descriptionKey} reduced={reduced}>              {description}            </Swap>          </div>        )}        {children && <div className="mt-4 flex w-full flex-wrap items-center justify-center gap-2">{children}</div>}        <div          className="grid w-full"          style={{ gridTemplateRows: hasActions ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 380ms ${EASE_OUT}` }}        >          <div className="min-h-0 overflow-clip">            <div className="flex flex-wrap items-center justify-center gap-2 pt-5">              {act ? (                <StatusButton                  status={status}                  icon={act.icon}                  labels={{ idle: act.label, pending: act.pendingLabel ?? "Working", success: act.successLabel ?? "Done", error: act.errorLabel ?? "Try again" }}                  onClick={run}                />              ) : (                (action as React.ReactNode)              )}              {secondaryAction &&                (secondaryAction.href ? (                  <a href={secondaryAction.href} onClick={secondaryAction.onAction} className={secondaryClass}>                    {secondaryAction.label}                  </a>                ) : (                  <button type="button" onClick={secondaryAction.onAction} className={secondaryClass}>                    {secondaryAction.label}                  </button>                ))}            </div>          </div>        </div>        <div          className="grid w-full"          style={{ gridTemplateRows: error ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}        >          <div className="min-h-0 overflow-clip">            <p role="alert" className="pt-3 text-[12.5px] text-red-600 dark:text-red-400">              {error}            </p>          </div>        </div>      </div>    </div>  );}export default EmptyState;
components/empty-state-scenes.ts
/* ───────────────────────────────────────────────────────── * EMPTY STATE SCENES: the isometric line drawings, as data * * Every scene is built in three dimensions on the same 6 × 6 tile * and projected with the standard isometric angle (30°). A shape is * one closed face or one line, listed back to front, so faces drawn * later cover what's behind them and the drawing keeps only the * lines you'd see. Shapes in the "hero" part move together for the * action's poses (a sheet that lands, a plug that goes in). * ───────────────────────────────────────────────────────── */export type EmptyStateScene = "inbox" | "search" | "done" | "offline" | "chart";/** What a face is filled with; the component maps these to theme tokens. */export type SceneFill = "top" | "left" | "right" | "hole" | "glass" | "accent" | "accent-side" | "none";export type SceneStroke = "line" | "faint" | "accent";export interface SceneShape {  d: string;  fill: SceneFill;  stroke: SceneStroke;  /** Dashed outlines are ghosts (nothing there yet); they fade in instead of drawing. */  dashed?: boolean;  /** Even-odd fill, for faces with a hole in them (the tray's rim, the lens). */  ring?: boolean;  /** Clipped to this outline (the inside of the tray). */  clip?: string;  /** Moves with the action's poses. */  hero?: boolean;}type Vec3 = [number, number, number];export interface SceneDef {  shapes: SceneShape[];  /** Where the hero part stands while the action runs and once it has worked, in tile units. */  poses?: { pending?: Vec3; success?: Vec3 };}/** One tile unit in px. */export const UNIT = 14;const C = Math.cos(Math.PI / 6);/** The drawing's box: the tile and everything that stands on it, same for every scene so the tile never moves. */export const VIEW = { x: -78, y: -36, width: 156, height: 130 };export const project = ([x, y, z]: Vec3): [number, number] => [(x - y) * C * UNIT, (x + y) * 0.5 * UNIT - z * UNIT];const n = (v: number) => (Math.round(v * 100) / 100).toString();const path = (points: Vec3[], close = true) =>  points.map((p, i) => `${i ? "L" : "M"}${n(project(p)[0])} ${n(project(p)[1])}`).join(" ") + (close ? " Z" : "");type Style = { fills?: [SceneFill, SceneFill, SceneFill]; stroke?: SceneStroke; dashed?: boolean; hero?: boolean };/** The three faces you can see of an upright box: the left-front, the right-front and the top. */function box(x: number, y: number, z: number, w: number, d: number, h: number, style: Style = {}): SceneShape[] {  const [X, Y, Z] = [x + w, y + d, z + h];  const [left, right, top] = style.fills ?? ["left", "right", "top"];  const base = { stroke: style.stroke ?? "line", dashed: style.dashed, hero: style.hero };  return [    { ...base, fill: left, d: path([[x, Y, z], [X, Y, z], [X, Y, Z], [x, Y, Z]]) },    { ...base, fill: right, d: path([[X, y, z], [X, Y, z], [X, Y, Z], [X, y, Z]]) },    { ...base, fill: top, d: path([[x, y, Z], [X, y, Z], [X, Y, Z], [x, Y, Z]]) },  ];}const line = (points: Vec3[], stroke: SceneStroke = "line", hero = false): SceneShape => ({ d: path(points, false), fill: "none", stroke, hero });/** A circle standing upright and turned to face you (the plane x + y = constant), so it reads round. */const facingCircle = (cx: number, cy: number, cz: number, r: number): Vec3[] =>  Array.from({ length: 48 }, (_, i) => {    const a = (i / 48) * Math.PI * 2;    const u = (r * Math.cos(a)) / Math.SQRT2;    return [cx + u, cy - u, cz + r * Math.sin(a)];  });/** The tile everything stands on, with a faint drafting grid. Drawn once; scenes change on top of it. */export const TILE: SceneShape[] = [  ...box(0, 0, -0.45, 6, 6, 0.45),  ...[2, 4].flatMap((v) => [line([[v, 0, 0], [v, 6, 0]], "faint"), line([[0, v, 0], [6, v, 0]], "faint")]),];/** Nothing here yet: an open tray, and the first sheet waiting above it. */function inbox(): SceneDef {  const [x0, x1, top, floor, inset] = [1.3, 4.7, 0.8, 0.25, 0.3];  const [i0, i1] = [x0 + inset, x1 - inset];  const hole = path([[i0, i0, top], [i1, i0, top], [i1, i1, top], [i0, i1, top]]);  const outer = path([[x0, x0, top], [x1, x0, top], [x1, x1, top], [x0, x1, top]]);  const sheet: [number, number, number] = [2.0, 1.95, 2.05];  const textTop = sheet[2] + 0.06;  return {    shapes: [      ...box(x0, x0, 0, x1 - x0, x1 - x0, top).slice(0, 2),      { d: `${outer} ${hole}`, fill: "top", stroke: "line", ring: true },      { d: path([[i0, i0, floor], [i1, i0, floor], [i1, i1, floor], [i0, i1, floor]]), fill: "hole", stroke: "faint", clip: hole },      { d: path([[i0, i0, top], [i0, i1, top], [i0, i1, floor], [i0, i0, floor]]), fill: "right", stroke: "line", clip: hole },      { d: path([[i0, i0, top], [i1, i0, top], [i1, i0, floor], [i0, i0, floor]]), fill: "left", stroke: "line", clip: hole },      ...box(sheet[0], sheet[1], sheet[2], 2.0, 2.5, 0.06, { hero: true }),      ...[2.45, 2.95, 3.45].map((y, i) => line([[2.35, y, textTop], [i === 2 ? 3.1 : 3.65, y, textTop]], "accent", true)),    ],    // It hovers a little lower while the action runs, then lands on the rim.    poses: { pending: [0, 0, -0.55], success: [0, 0, top - sheet[2]] },  };}/** No results: a lens held over three empty result rows. */function search(): SceneDef {  const [cx, cy, cz, r, t] = [3.15, 2.85, 1.85, 1.3, 0.26];  const outer = facingCircle(cx, cy, cz, r);  const inner = facingCircle(cx, cy, cz, r - t);  // The handle leaves the rim down and to the right, in the lens's own plane.  const a = -0.95;  const along = (d: number): Vec3 => {    const u = (d * Math.cos(a)) / Math.SQRT2;    return [cx + u, cy - u, cz + d * Math.sin(a)];  };  const side = (p: Vec3, w: number): Vec3 => {    const u = (w * Math.cos(a + Math.PI / 2)) / Math.SQRT2;    return [p[0] + u, p[1] - u, p[2] + w * Math.sin(a + Math.PI / 2)];  };  const [h0, h1] = [along(r - 0.05), along(r + 1.15)];  const rows = [0.9, 2.35, 3.8].flatMap((y, i) => box(0.9, y, 0, i === 2 ? 3.3 : 4.2, 1.05, 0.1, { fills: ["none", "none", "none"], stroke: "line", dashed: true }));  return {    shapes: [      ...rows,      { d: path([side(h0, 0.16), side(h1, 0.16), side(h1, -0.16), side(h0, -0.16)]), fill: "right", stroke: "line", hero: true },      { d: path(inner), fill: "glass", stroke: "faint", hero: true },      { d: `${path(outer)} ${path(inner)}`, fill: "left", stroke: "accent", ring: true, hero: true },    ],    // The lens sweeps across the empty rows while a search runs again.    poses: { pending: [-0.7, 0.7, 0] },  };}/** All caught up: a thick check standing on a low platform, turned to face you. */function done(): SceneDef {  const [A, B, Cp] = [[-1.35, 1.95], [-0.45, 1.0], [1.7, 3.15]] as [number, number][];  const unit = (v: [number, number]): [number, number] => {    const l = Math.hypot(v[0], v[1]);    return [v[0] / l, v[1] / l];  };  const h = 0.26;  const d1 = unit([B[0] - A[0], B[1] - A[1]]);  const d2 = unit([Cp[0] - B[0], Cp[1] - B[1]]);  const n1: [number, number] = [-d1[1], d1[0]];  const n2: [number, number] = [-d2[1], d2[0]];  const m0 = unit([n1[0] + n2[0], n1[1] + n2[1]]);  const mLen = h / (m0[0] * n1[0] + m0[1] * n1[1]);  const m: [number, number] = [m0[0] * mLen, m0[1] * mLen];  // The outline in the check's own plane (u across, z up), with each edge's outward normal.  const outline: [number, number][] = [    [A[0] + n1[0] * h, A[1] + n1[1] * h],    [B[0] + m[0], B[1] + m[1]],    [Cp[0] + n2[0] * h, Cp[1] + n2[1] * h],    [Cp[0] - n2[0] * h, Cp[1] - n2[1] * h],    [B[0] - m[0], B[1] - m[1]],    [A[0] - n1[0] * h, A[1] - n1[1] * h],  ];  const normals: [number, number][] = [n1, n2, d2, [-n2[0], -n2[1]], [-n1[0], -n1[1]], [-d1[0], -d1[1]]];  // Its plane is x + y = 6 through the platform's middle; the thickness runs toward you, so the faces that look up show.  const at = (depth: number) => outline.map(([u, z]) => [3 + u / Math.SQRT2 + depth, 3 - u / Math.SQRT2 + depth, z] as Vec3);  const [front, back] = [0.16, -0.16];  const edge = (p: [number, number], q: [number, number]): Vec3[] => {    const [pb, qb] = [at(back), at(back)];    const [pf, qf] = [at(front), at(front)];    const i = outline.indexOf(p);    const j = outline.indexOf(q);    return [pb[i], qb[j], qf[j], pf[i]];  };  const sides = outline    .map((p, i) => ({ p, q: outline[(i + 1) % outline.length], nrm: normals[i] }))    .filter(({ nrm }) => nrm[1] > 0.02)    .map(({ p, q }) => ({ d: path(edge(p, q)), fill: "accent-side" as const, stroke: "accent" as const, hero: true }));  return {    shapes: [      ...box(1.3, 1.3, 0, 3.4, 3.4, 0.55),      { d: path(at(back)), fill: "accent-side", stroke: "accent", hero: true },      ...sides,      { d: path(at(front)), fill: "accent", stroke: "accent", hero: true },    ],  };}/** Can't connect: a plug lying short of its socket, its cable loose on the tile. */function offline(): SceneDef {  const face = 2.2;  const slot = (x: number) => ({ d: path([[x, face, 0.8], [x + 0.2, face, 0.8], [x + 0.2, face, 1.2], [x, face, 1.2]]), fill: "hole" as const, stroke: "line" as const });  const prong = (x: number) => line([[x, 3.9, 1.0], [x, 2.9, 1.0]], "accent", true);  return {    shapes: [      ...box(3.1, 0.7, 0, 2.3, face - 0.7, 2.0),      slot(3.75),      slot(4.55),      prong(3.85),      prong(4.65),      ...box(3.5, 3.9, 0, 1.5, 1.2, 1.6, { hero: true }),      line([[4.25, 5.1, 0.55], [4.25, 5.5, 0.03], [3.1, 5.6, 0.03], [2.0, 5.15, 0.03], [0.8, 5.4, 0.03]], "line", true),    ],    // It slides toward the socket while you retry, and goes in when it works.    poses: { pending: [0, -0.4, 0], success: [0, -0.72, 0] },  };}/** No data yet: a chart's back wall with ghost bars waiting to be filled. */function chart(): SceneDef {  const wall = 0.8;  return {    shapes: [      ...box(0.6, wall - 0.2, 0, 4.8, 0.2, 2.2),      ...[0.75, 1.5].map((z) => line([[0.8, wall, z], [5.2, wall, z]], "faint")),      ...[        [0.9, 0.9],        [2.05, 1.6],        [3.2, 1.2],        [4.35, 2.1],      ].flatMap(([x, h]) => box(x, 2.2, 0, 0.85, 0.85, h, { stroke: "line", dashed: true, hero: true })),    ],    // Ghost bars rise a touch while the first data is fetched.    poses: { pending: [0, 0, 0.3] },  };}export const SCENES: Record<EmptyStateScene, () => SceneDef> = { inbox, search, done, offline, chart };/** A pose in tile units, as a screen offset in px. */export const poseOffset = ([x, y, z]: Vec3): [number, number] => [(x - y) * C * UNIT, (x + y) * 0.5 * UNIT - z * UNIT];
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */export const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';/** * The press every button primitive shares: a 3% give that eases in and settles back. * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list * or the press snaps. */export const INTERACTION_PRESS =  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';export const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */export function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {  const d = dimension || 320;  const abs = Math.abs(overshoot);  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;}/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */export function projectInertia(distance: number, velocity: number, time = 0.12): number {  return distance + velocity * 1000 * time;}/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */export function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {  const maxAllowed = Math.abs(distanceToTarget) * factor;  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);}/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */export function springFrames(  from: number,  to: number,  velocity = 0,  duration = 480,  config: { omega?: number; damping?: number; steps?: number } = {},) {  const { omega = 16, damping = 0.74, steps = 36 } = config;  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: steps + 1 }, (_, i) => {    const t = (i / steps) * (duration / 1000);    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === steps ? to : value, offset: i / steps };  });}

Registry item: /r/empty-state.json