# pixilui > Designed product UI for AI apps: approval cards, run timelines, token meters and the controls around them. Source you own, installed with the shadcn CLI. Install any item with the shadcn CLI after adding the registry to components.json: `"registries": { "@pixilui": "https://pixilui.com/r/{name}.json" }`, then `npx shadcn@latest add @pixilui/`. Items land in `components/pixilui/` and depend on `@pixilui/foundation` (tokens, motion presets, cn). # Foundation Tokens, motion presets and cn: the colour roles, radii, type scale, shadows and springs every pixilui item reads. Tier: free. Type: foundation. Category: layout. Tags: motion. ## Install ```bash npx shadcn@latest add @pixilui/foundation ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/foundation.css`, `components/pixilui/motion-tokens.ts`, `components/pixilui/lib/use-measure.ts`, `components/pixilui/lib/use-morph-size.ts`, `components/pixilui/lib/utils.ts`. npm: `clsx`, `tailwind-merge`, `motion`. ## Preview Live preview: https://pixilui.com/components/foundation Signature: Radii nest 18, 26, 34: a control inside a panel inside a surface always looks concentric. ## Code `components/pixilui/foundation.css` ```css /* pixilui foundation: every token the items read, and the Tailwind v4 mapping for them. How it is organised 1. Raw tokens are CSS custom properties named --pixilui-*. Items never read them directly; they read the Tailwind utilities mapped in section 6. Theme by overriding --pixilui-*. 2. Light is the default on :root. Dark is tuned separately (not inverted) and applies under [data-theme="dark"] or .dark, so it works with the docs site and with next-themes/shadcn apps. 3. Accents: data-accent="neutral|violet|blue|green|amber|orange|coral|rose" on any element. Without it the house accent is neutral. 4. Utilities keep the role names (bg-surface, text-secondary, border-border, rounded-control, h-control-md). Two roles are renamed in Tailwind because shadcn already uses the names for something else: the accent fill is bg-accent-solid (shadcn's bg-accent is a hover grey) and text on it is text-accent-contrast (shadcn's text-accent-foreground). Text colours use the --text-color-* namespace, so text-secondary and text-muted never repaint shadcn's bg-secondary or bg-muted. See docs/DECISIONS.md. Install: import once, after "tailwindcss", in the app's global stylesheet: @import "tailwindcss"; @import "../components/pixilui/foundation.css"; */ /* ------------------------------------------------------------------ 1. light (default) */ :root { color-scheme: light; /* surfaces */ --pixilui-background: oklch(0.985 0 0); --pixilui-surface: oklch(1 0 0); --pixilui-surface-raised: oklch(1 0 0); --pixilui-surface-muted: oklch(0.97 0 0); --pixilui-surface-inverted: oklch(0.2 0 0); /* the bench a component sits on in previews and demos: page < field < surface */ --pixilui-field: oklch(0.96 0 0); /* text: three even greys. foreground for objects, secondary for body, muted for meta only */ --pixilui-foreground: oklch(0.18 0 0); --pixilui-text-secondary: oklch(0.475 0 0); --pixilui-text-muted: oklch(0.55 0 0); --pixilui-text-inverted: oklch(0.985 0 0); /* borders: translucent, so they sit right on any surface */ --pixilui-border: oklch(0 0 0 / 0.08); --pixilui-border-subtle: oklch(0 0 0 / 0.05); --pixilui-border-strong: oklch(0 0 0 / 0.14); /* status: the base colour reads as text on a surface and as a fill under -foreground */ --pixilui-success: oklch(0.53 0.13 150); --pixilui-success-subtle: oklch(0.962 0.032 150); --pixilui-success-foreground: oklch(0.99 0.01 150); --pixilui-warning: oklch(0.56 0.14 58); --pixilui-warning-subtle: oklch(0.966 0.04 80); --pixilui-warning-foreground: oklch(0.99 0.01 80); --pixilui-danger: oklch(0.55 0.2 25); --pixilui-danger-subtle: oklch(0.962 0.025 25); --pixilui-danger-foreground: oklch(0.99 0.01 25); --pixilui-info: oklch(0.55 0.16 250); --pixilui-info-subtle: oklch(0.962 0.025 250); --pixilui-info-foreground: oklch(0.99 0.01 250); /* controls (switch, slider, checkbox) */ --pixilui-control-track: oklch(0.91 0 0); --pixilui-control-thumb: oklch(1 0 0); /* focus */ --pixilui-focus-outline-width: 2px; --pixilui-focus-outline-offset: 2px; /* shadows: smooth layered stacks in three tiers */ --pixilui-shadow-resting: 0 1px 1px oklch(0 0 0 / 3%), 0 1px 2px oklch(0 0 0 / 3%); --pixilui-shadow-raised: 0 1px 2px oklch(0 0 0 / 4%), 0 4px 8px -2px oklch(0 0 0 / 4%), 0 12px 20px -8px oklch(0 0 0 / 6%); --pixilui-shadow-floating: 0 1px 2px oklch(0 0 0 / 4%), 0 6px 12px -4px oklch(0 0 0 / 5%), 0 18px 32px -10px oklch(0 0 0 / 8%), 0 40px 64px -24px oklch(0 0 0 / 10%); /* the tactile pill: a micro two-layer shadow under the control, a light step inside its top edge */ --pixilui-shadow-control: 0 1px 1px oklch(0 0 0 / 6%), 0 2px 4px -1px oklch(0 0 0 / 6%); --pixilui-highlight: inset 0 1px 0 oklch(1 0 0 / 22%); /* shape: radii nest control < panel < surface */ --pixilui-radius-control: 18px; --pixilui-radius-panel: 26px; --pixilui-radius-surface: 34px; --pixilui-radius-pill: 9999px; --pixilui-control-height-sm: 36px; --pixilui-control-height-md: 44px; --pixilui-control-height-lg: 50px; /* type: Geist for display (30px and up), Inter for body, Geist Mono for numbers and code only */ --pixilui-font-display: var(--font-geist-sans, "Geist"), "Inter", ui-sans-serif, system-ui, sans-serif; --pixilui-font-body: var(--font-inter, "Inter"), ui-sans-serif, system-ui, sans-serif; --pixilui-font-mono: var(--font-geist-mono, "Geist Mono"), ui-monospace, "SF Mono", Menlo, monospace; --pixilui-text-12: 0.75rem; --pixilui-text-14: 0.875rem; --pixilui-text-16: 1rem; --pixilui-text-18: 1.125rem; --pixilui-text-22: 1.375rem; --pixilui-text-28: 1.75rem; --pixilui-text-36: 2.25rem; --pixilui-text-52: 3.25rem; --pixilui-text-72: 4.5rem; --pixilui-leading-body: 1.4; --pixilui-leading-display: 1.1; --pixilui-tracking-body: -0.01em; --pixilui-tracking-display: -0.03em; --pixilui-weight-regular: 400; --pixilui-weight-medium: 500; /* space: 4px steps, identical to Tailwind's default spacing (p-4 is space-4) */ --pixilui-space-1: 4px; --pixilui-space-2: 8px; --pixilui-space-3: 12px; --pixilui-space-4: 16px; --pixilui-space-5: 20px; --pixilui-space-6: 24px; --pixilui-space-8: 32px; --pixilui-space-10: 40px; --pixilui-space-12: 48px; --pixilui-space-16: 64px; --pixilui-space-20: 80px; --pixilui-space-24: 96px; /* motion: durations (GROUND-RULES section 4) */ --pixilui-duration-instant: 120ms; --pixilui-duration-quick: 150ms; --pixilui-duration-fast: 250ms; --pixilui-duration-standard: 240ms; --pixilui-duration-medium: 350ms; --pixilui-duration-slow: 400ms; --pixilui-duration-considered: 480ms; --pixilui-duration-very-slow: 500ms; --pixilui-duration-spring: 580ms; --pixilui-stagger-char: 16ms; --pixilui-stagger-item: 35ms; --pixilui-stagger-word: 40ms; --pixilui-stagger-line: 80ms; /* motion: easings */ --pixilui-ease-enter: cubic-bezier(0.16, 1, 0.3, 1); --pixilui-ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1); --pixilui-ease-exit: cubic-bezier(0.7, 0, 0.84, 0); --pixilui-ease-move: cubic-bezier(0.77, 0, 0.175, 1); --pixilui-ease-swap: cubic-bezier(0.2, 0, 0, 1); --pixilui-ease-linear: linear; --pixilui-ease-bounce: cubic-bezier(0.34, 1.36, 0.64, 1); /* CSS spring (bounce .15) for transform transitions; pair with --pixilui-duration-spring */ --pixilui-ease-spring: linear(0, 0.0258, 0.09, 0.1763, 0.2732, 0.3724, 0.4683, 0.5573, 0.6376, 0.7082, 0.7689, 0.8202, 0.8628, 0.8976, 0.9256, 0.9476, 0.9648, 0.9778, 0.9875, 0.9945, 0.9994, 1.0026, 1.0047, 1.0058, 1.0062, 1.0062, 1.0059, 1.0055, 1.0049, 1.0043, 1.0036, 1.0031, 1.0025, 1.002, 1.0016, 1.0013, 1); /* motion: blur */ --pixilui-blur-subtle: 2px; --pixilui-blur-soft: 4px; --pixilui-blur-text: 8px; } /* ------------------------------------------------------------------ 2. dark (tuned) */ [data-theme="dark"], .dark { color-scheme: dark; --pixilui-background: oklch(0.165 0 0); --pixilui-surface: oklch(0.22 0 0); --pixilui-surface-raised: oklch(0.25 0 0); --pixilui-surface-muted: oklch(0.255 0 0); --pixilui-surface-inverted: oklch(0.96 0 0); --pixilui-field: oklch(0.195 0 0); --pixilui-foreground: oklch(0.96 0 0); --pixilui-text-secondary: oklch(0.72 0 0); --pixilui-text-muted: oklch(0.62 0 0); --pixilui-text-inverted: oklch(0.2 0 0); --pixilui-border: oklch(1 0 0 / 0.08); --pixilui-border-subtle: oklch(1 0 0 / 0.05); --pixilui-border-strong: oklch(1 0 0 / 0.14); --pixilui-success: oklch(0.74 0.15 152); --pixilui-success-subtle: oklch(0.29 0.05 152); --pixilui-success-foreground: oklch(0.2 0.03 152); --pixilui-warning: oklch(0.8 0.14 78); --pixilui-warning-subtle: oklch(0.3 0.05 78); --pixilui-warning-foreground: oklch(0.22 0.04 70); --pixilui-danger: oklch(0.7 0.18 24); --pixilui-danger-subtle: oklch(0.29 0.06 24); --pixilui-danger-foreground: oklch(0.19 0.03 24); --pixilui-info: oklch(0.74 0.13 248); --pixilui-info-subtle: oklch(0.29 0.05 248); --pixilui-info-foreground: oklch(0.2 0.03 248); --pixilui-control-track: oklch(0.33 0 0); --pixilui-control-thumb: oklch(0.98 0 0); --pixilui-shadow-resting: 0 1px 2px oklch(0 0 0 / 30%); --pixilui-shadow-raised: 0 1px 2px oklch(0 0 0 / 30%), 0 8px 20px -6px oklch(0 0 0 / 45%); --pixilui-shadow-floating: 0 1px 2px oklch(0 0 0 / 30%), 0 12px 24px -8px oklch(0 0 0 / 45%), 0 40px 64px -24px oklch(0 0 0 / 60%); --pixilui-shadow-control: 0 1px 1px oklch(0 0 0 / 30%), 0 2px 4px -1px oklch(0 0 0 / 30%); --pixilui-highlight: inset 0 1px 0 oklch(1 0 0 / 7%); } /* ------------------------------------------------------------------ 3. accents Each accent carries its light and dark values; the theme in force picks one where it is used (section 6), so an accent or theme set on any element, at any depth, resolves right. accent the fill (primary buttons, selection, switch on) accent-strong accent-coloured text and icons on surfaces accent-subtle tinted backgrounds accent-foreground text and icons on the fill Light fills keep L <= 0.56 where text on them is light, so white text clears 4.5:1. To brand an app, override --pixilui-accent (and friends) or the -light/-dark pairs. */ :root, [data-theme="light"], .light { --pixilui-dark: 0; } [data-theme="dark"], .dark { --pixilui-dark: 1; } :root, [data-accent="neutral"] { --pixilui-accent-light: oklch(0.2 0 0); --pixilui-accent-strong-light: oklch(0.2 0 0); --pixilui-accent-subtle-light: oklch(0.955 0 0); --pixilui-accent-foreground-light: oklch(0.985 0 0); --pixilui-accent-dark: oklch(0.94 0 0); --pixilui-accent-strong-dark: oklch(0.96 0 0); --pixilui-accent-subtle-dark: oklch(0.28 0 0); --pixilui-accent-foreground-dark: oklch(0.2 0 0); } [data-accent="violet"] { --pixilui-accent-light: oklch(0.54 0.21 290); --pixilui-accent-strong-light: oklch(0.5 0.2 290); --pixilui-accent-subtle-light: oklch(0.955 0.03 290); --pixilui-accent-foreground-light: oklch(0.99 0.01 290); --pixilui-accent-dark: oklch(0.56 0.2 290); --pixilui-accent-strong-dark: oklch(0.78 0.13 290); --pixilui-accent-subtle-dark: oklch(0.29 0.07 290); --pixilui-accent-foreground-dark: oklch(0.99 0.01 290); } [data-accent="blue"] { --pixilui-accent-light: oklch(0.56 0.225 263); --pixilui-accent-strong-light: oklch(0.5 0.21 263); --pixilui-accent-subtle-light: oklch(0.96 0.025 263); --pixilui-accent-foreground-light: oklch(0.99 0.01 263); --pixilui-accent-dark: oklch(0.56 0.2 263); --pixilui-accent-strong-dark: oklch(0.78 0.12 263); --pixilui-accent-subtle-dark: oklch(0.29 0.07 263); --pixilui-accent-foreground-dark: oklch(0.99 0.01 263); } [data-accent="green"] { --pixilui-accent-light: oklch(0.53 0.13 155); --pixilui-accent-strong-light: oklch(0.48 0.12 155); --pixilui-accent-subtle-light: oklch(0.958 0.035 155); --pixilui-accent-foreground-light: oklch(0.99 0.01 155); --pixilui-accent-dark: oklch(0.72 0.15 155); --pixilui-accent-strong-dark: oklch(0.78 0.14 155); --pixilui-accent-subtle-dark: oklch(0.29 0.05 155); --pixilui-accent-foreground-dark: oklch(0.2 0.03 155); } [data-accent="amber"] { --pixilui-accent-light: oklch(0.8 0.16 78); --pixilui-accent-strong-light: oklch(0.55 0.12 65); --pixilui-accent-subtle-light: oklch(0.965 0.045 85); --pixilui-accent-foreground-light: oklch(0.25 0.05 65); --pixilui-accent-dark: oklch(0.82 0.15 80); --pixilui-accent-strong-dark: oklch(0.84 0.13 82); --pixilui-accent-subtle-dark: oklch(0.3 0.05 78); --pixilui-accent-foreground-dark: oklch(0.24 0.05 65); } [data-accent="orange"] { --pixilui-accent-light: oklch(0.56 0.17 42); --pixilui-accent-strong-light: oklch(0.52 0.16 42); --pixilui-accent-subtle-light: oklch(0.96 0.03 50); --pixilui-accent-foreground-light: oklch(0.99 0.01 50); --pixilui-accent-dark: oklch(0.72 0.16 48); --pixilui-accent-strong-dark: oklch(0.78 0.13 50); --pixilui-accent-subtle-dark: oklch(0.3 0.06 45); --pixilui-accent-foreground-dark: oklch(0.2 0.03 45); } [data-accent="coral"] { --pixilui-accent-light: oklch(0.56 0.17 28); --pixilui-accent-strong-light: oklch(0.52 0.16 28); --pixilui-accent-subtle-light: oklch(0.96 0.025 28); --pixilui-accent-foreground-light: oklch(0.99 0.01 28); --pixilui-accent-dark: oklch(0.72 0.15 30); --pixilui-accent-strong-dark: oklch(0.78 0.12 30); --pixilui-accent-subtle-dark: oklch(0.3 0.06 28); --pixilui-accent-foreground-dark: oklch(0.2 0.03 28); } [data-accent="rose"] { --pixilui-accent-light: oklch(0.55 0.2 8); --pixilui-accent-strong-light: oklch(0.51 0.19 8); --pixilui-accent-subtle-light: oklch(0.96 0.025 8); --pixilui-accent-foreground-light: oklch(0.99 0.01 8); --pixilui-accent-dark: oklch(0.56 0.2 8); --pixilui-accent-strong-dark: oklch(0.78 0.12 8); --pixilui-accent-subtle-dark: oklch(0.29 0.07 8); --pixilui-accent-foreground-dark: oklch(0.99 0.01 8); } /* ------------------------------------------------------------------ 5. variants and utilities */ /* Hover only where there is a fine pointer (GROUND-RULES section 2). Use hover-fine:, not hover:. */ @custom-variant hover-fine { @media (hover: hover) and (pointer: fine) { &:hover { @slot; } } } @custom-variant group-hover-fine { @media (hover: hover) and (pointer: fine) { &:is(:where(.group):hover *) { @slot; } } } /* The focus ring every interactive item uses: focus-visible:focus-ring */ @utility focus-ring { outline: var(--pixilui-focus-outline-width) solid var(--pixilui-focus-outline, var(--pixilui-accent, color-mix(in oklch, var(--pixilui-accent-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-light)))); outline-offset: var(--pixilui-focus-outline-offset); } /* A 1px border drawn as an overlay, so it never changes a measurement. */ @utility hairline { position: relative; &::after { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 1px solid var(--pixilui-hairline, var(--pixilui-border)); pointer-events: none; } } @utility hairline-subtle { --pixilui-hairline: var(--pixilui-border-subtle); } @utility hairline-strong { --pixilui-hairline: var(--pixilui-border-strong); } @utility hairline-accent { --pixilui-hairline: var(--pixilui-accent, color-mix(in oklch, var(--pixilui-accent-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-light))); } @utility hairline-danger { --pixilui-hairline: var(--pixilui-danger); } /* Tabular numbers in the mono face: timestamps, IDs, counts. */ @utility numeric { font-family: var(--pixilui-font-mono); font-variant-numeric: tabular-nums; letter-spacing: 0; } /* ------------------------------------------------------------------ 6. Tailwind mapping */ @theme inline { /* colour: shared names (same meaning as shadcn's, so an app's own theme may set them) */ --color-background: var(--pixilui-background); --color-foreground: var(--pixilui-foreground); --color-border: var(--pixilui-border); /* colour: pixilui roles */ --color-surface: var(--pixilui-surface); --color-surface-raised: var(--pixilui-surface-raised); --color-surface-muted: var(--pixilui-surface-muted); --color-surface-inverted: var(--pixilui-surface-inverted); --color-field: var(--pixilui-field); --color-border-subtle: var(--pixilui-border-subtle); --color-border-strong: var(--pixilui-border-strong); /* accents resolve where they are used: the nearest data-accent palette, the nearest theme */ --color-accent-solid: var(--pixilui-accent, color-mix(in oklch, var(--pixilui-accent-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-light))); --color-accent-strong: var(--pixilui-accent-strong, color-mix(in oklch, var(--pixilui-accent-strong-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-strong-light))); --color-accent-subtle: var(--pixilui-accent-subtle, color-mix(in oklch, var(--pixilui-accent-subtle-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-subtle-light))); --color-accent-contrast: var(--pixilui-accent-foreground, color-mix(in oklch, var(--pixilui-accent-foreground-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-foreground-light))); --color-success: var(--pixilui-success); --color-success-subtle: var(--pixilui-success-subtle); --color-success-foreground: var(--pixilui-success-foreground); --color-warning: var(--pixilui-warning); --color-warning-subtle: var(--pixilui-warning-subtle); --color-warning-foreground: var(--pixilui-warning-foreground); --color-danger: var(--pixilui-danger); --color-danger-subtle: var(--pixilui-danger-subtle); --color-danger-foreground: var(--pixilui-danger-foreground); --color-info: var(--pixilui-info); --color-info-subtle: var(--pixilui-info-subtle); --color-info-foreground: var(--pixilui-info-foreground); --color-control-on: var(--pixilui-control-on, var(--pixilui-accent, color-mix(in oklch, var(--pixilui-accent-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-light)))); --color-control-track: var(--pixilui-control-track); --color-control-thumb: var(--pixilui-control-thumb); /* colour: text-only roles (text-secondary, text-muted, text-inverted) */ --text-color-secondary: var(--pixilui-text-secondary); --text-color-muted: var(--pixilui-text-muted); --text-color-inverted: var(--pixilui-text-inverted); /* focus */ --outline-color-focus: var(--pixilui-focus-outline, var(--pixilui-accent, color-mix(in oklch, var(--pixilui-accent-dark) calc(var(--pixilui-dark) * 100%), var(--pixilui-accent-light)))); /* shape */ --radius-control: var(--pixilui-radius-control); --radius-panel: var(--pixilui-radius-panel); --radius-surface: var(--pixilui-radius-surface); --radius-pill: var(--pixilui-radius-pill); --spacing-control-sm: var(--pixilui-control-height-sm); --spacing-control-md: var(--pixilui-control-height-md); --spacing-control-lg: var(--pixilui-control-height-lg); /* type */ --font-display: var(--pixilui-font-display); --font-body: var(--pixilui-font-body); --font-mono: var(--pixilui-font-mono); --text-12: var(--pixilui-text-12); --text-12--line-height: var(--pixilui-leading-body); --text-12--letter-spacing: var(--pixilui-tracking-body); --text-14: var(--pixilui-text-14); --text-14--line-height: var(--pixilui-leading-body); --text-14--letter-spacing: var(--pixilui-tracking-body); --text-16: var(--pixilui-text-16); --text-16--line-height: var(--pixilui-leading-body); --text-16--letter-spacing: var(--pixilui-tracking-body); --text-18: var(--pixilui-text-18); --text-18--line-height: var(--pixilui-leading-body); --text-18--letter-spacing: var(--pixilui-tracking-body); --text-22: var(--pixilui-text-22); --text-22--line-height: var(--pixilui-leading-body); --text-22--letter-spacing: var(--pixilui-tracking-body); --text-28: var(--pixilui-text-28); --text-28--line-height: var(--pixilui-leading-body); --text-28--letter-spacing: var(--pixilui-tracking-body); --text-36: var(--pixilui-text-36); --text-36--line-height: var(--pixilui-leading-display); --text-36--letter-spacing: var(--pixilui-tracking-display); --text-52: var(--pixilui-text-52); --text-52--line-height: var(--pixilui-leading-display); --text-52--letter-spacing: var(--pixilui-tracking-display); --text-72: var(--pixilui-text-72); --text-72--line-height: var(--pixilui-leading-display); --text-72--letter-spacing: var(--pixilui-tracking-display); /* elevation */ --shadow-resting: var(--pixilui-shadow-resting); --shadow-raised: var(--pixilui-shadow-raised); --shadow-floating: var(--pixilui-shadow-floating); --shadow-control: var(--pixilui-shadow-control); --inset-shadow-highlight: var(--pixilui-highlight); /* motion: duration-fast, ease-enter, blur-subtle ... */ --transition-duration-instant: var(--pixilui-duration-instant); --transition-duration-quick: var(--pixilui-duration-quick); --transition-duration-fast: var(--pixilui-duration-fast); --transition-duration-standard: var(--pixilui-duration-standard); --transition-duration-medium: var(--pixilui-duration-medium); --transition-duration-slow: var(--pixilui-duration-slow); --transition-duration-considered: var(--pixilui-duration-considered); --transition-duration-very-slow: var(--pixilui-duration-very-slow); --transition-duration-spring: var(--pixilui-duration-spring); --ease-enter: var(--pixilui-ease-enter); --ease-smooth-out: var(--pixilui-ease-smooth-out); --ease-exit: var(--pixilui-ease-exit); --ease-move: var(--pixilui-ease-move); --ease-swap: var(--pixilui-ease-swap); --ease-bounce: var(--pixilui-ease-bounce); --ease-spring: var(--pixilui-ease-spring); --blur-subtle: var(--pixilui-blur-subtle); --blur-soft: var(--pixilui-blur-soft); --blur-text: var(--pixilui-blur-text); } ``` `components/pixilui/motion-tokens.ts` ```tsx /** * pixilui motion presets. Every animated item imports these and nothing else: no literal * durations, easings or spring numbers in components (check-registry fails on them). * The values mirror the CSS variables in foundation.css (--pixilui-duration-*, --pixilui-ease-*). * * Springs (the triad) * snappy presses, toggles, thumbs, indicators visualDuration .26, bounce .12 * smooth panels, heights, anything shrinking visualDuration .40, bounce 0 (never overshoots) * morph shapes growing, widths following content visualDuration .42, bounce .16 * * Rule: growing uses morph, shrinking uses smooth (see `resizeSpring`). A narrowing width * must never dip below its target, which is why shrinking never bounces. * * The springs are restated as stiffness and damping (mass 1), so a retarget mid-flight * keeps its velocity instead of restarting a fixed-duration curve. Conversion, from the * house spring.js: k = (2π / (visualDuration × 1.2))², c = 2 × (1 − bounce) × √k. * * Reduced motion: every animation has a branch. Use `reducedTransition` (a short fade) * or `instantTransition` when `useReducedMotion()` is true. Spinners stay still. */ type CubicBezier = readonly [number, number, number, number]; const spring = (stiffness: number, damping: number) => ({ type: "spring", stiffness, damping, mass: 1 }) as const; export const motionTokens = { /** Seconds, as motion expects. */ duration: { /** 120ms: hover tints, the old face fading out of a morph. */ instant: 0.12, /** 150ms: press, close, text swap, tooltip appear. */ quick: 0.15, /** 250ms: open, icon swap, tabs, page slide, row collapse. */ fast: 0.25, /** 240ms: the default UI transition. */ standard: 0.24, /** 350ms: panel close, toast close. */ medium: 0.35, /** 400ms: panel open, content reveal. */ slow: 0.4, /** 480ms: considered transitions that carry meaning. */ considered: 0.48, /** 500ms: emphasis, success check, entrance. */ verySlow: 0.5, /** 580ms: pairs with the CSS spring easing. */ spring: 0.58, }, ease: { /** Things arriving. */ enter: [0.16, 1, 0.3, 1] as CubicBezier, /** Surfaces opening, closing, sliding, resizing. */ smoothOut: [0.22, 1, 0.36, 1] as CubicBezier, /** Things leaving. */ exit: [0.7, 0, 0.84, 0] as CubicBezier, /** On-screen travel: playheads, glides. */ move: [0.77, 0, 0.175, 1] as CubicBezier, /** Icon and label swaps. Never bounces. */ swap: [0.2, 0, 0, 1] as CubicBezier, /** Progress, shimmer, spinners. */ linear: "linear", /** Entrances only: a badge pop. */ bounce: [0.34, 1.36, 0.64, 1] as CubicBezier, }, spring: { snappy: spring(405.6, 35.44), smooth: spring(171.3, 26.18), morph: spring(155.4, 20.94), }, /** The triad as authored, for reference and for CSS/JS ports. */ springSource: { snappy: { visualDuration: 0.26, bounce: 0.12 }, smooth: { visualDuration: 0.4, bounce: 0 }, morph: { visualDuration: 0.42, bounce: 0.16 }, }, /** Seconds between siblings. Keep a whole sequence under about 0.4s. */ stagger: { char: 0.016, item: 0.035, word: 0.04, line: 0.08, }, /** Pixels. */ blur: { subtle: 2, soft: 4, text: 8, }, /** Pixels of travel. */ distance: { micro: 4, small: 6, base: 8, medium: 12, large: 30, }, /** Press and settle scales. */ scale: { press: 0.97, pressSmall: 0.98, enterFrom: 0.96, }, } as const; export type MotionTokens = typeof motionTokens; export type SpringName = keyof typeof motionTokens.spring; /** Growing uses morph, shrinking uses smooth. */ export function resizeSpring(previous: number, next: number) { return next >= previous ? motionTokens.spring.morph : motionTokens.spring.smooth; } /** The reduced-motion stand-in for a movement: a short fade, no travel. */ export const reducedTransition = { duration: motionTokens.duration.quick, ease: motionTokens.ease.linear, } as const; /** For values that should simply change when motion is reduced. */ export const instantTransition = { duration: 0 } as const; ``` `components/pixilui/lib/use-measure.ts` ```tsx "use client"; import { useCallback, useLayoutEffect, useState } from "react"; export type Size = { width: number; height: number }; /** * Track an element's border-box size. Returns null until the first measurement, so the * first render matches the server and nothing animates on mount. */ export function useMeasure(): [(node: T | null) => void, Size | null] { const [size, setSize] = useState(null); const [node, setNode] = useState(null); const ref = useCallback((next: T | null) => setNode(next), []); useLayoutEffect(() => { if (!node) return; const read = () => { const rect = node.getBoundingClientRect(); setSize((prev) => (prev && prev.width === rect.width && prev.height === rect.height ? prev : { width: rect.width, height: rect.height })); }; read(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(read); observer.observe(node); return () => observer.disconnect(); }, [node]); return [ref, size]; } ``` `components/pixilui/lib/use-morph-size.ts` ```tsx "use client"; import { useLayoutEffect, useRef } from "react"; import { animate, useReducedMotion, type AnimationPlaybackControls } from "motion/react"; import { resizeSpring } from "../motion-tokens"; import { useMeasure } from "./use-measure"; /** * One material that follows its content. The container's width or height is locked in * pixels and springs to the content's new size: morph when growing, smooth when shrinking. * * - `measureRef` goes on an element that sizes to its content (w-max for width). * - `containerRef` goes on the clipping element (overflow-hidden) whose size animates. * - `changeKey` says when a change is a real content change. A size change with the same key * (a font loading, the container resizing) snaps instead of animating. * * Reduced motion: sizes change instantly. */ export function useMorphSize(axis: "width" | "height", changeKey: unknown) { const reduced = useReducedMotion(); const [measureRef, size] = useMeasure(); const containerRef = useRef(null); const applied = useRef<{ key: unknown; value: number; cross: number } | null>(null); const latestKey = useRef(changeKey); const controls = useRef(null); useLayoutEffect(() => { latestKey.current = changeKey; }, [changeKey]); useLayoutEffect(() => { const el = containerRef.current; if (!el || !size) return; const value = axis === "width" ? size.width : size.height; const cross = axis === "width" ? size.height : size.width; if (value === 0) return; // not laid out (hidden parent, test DOM) const prev = applied.current; applied.current = { key: latestKey.current, value, cross }; const animateIt = prev !== null && !reduced && prev.key !== latestKey.current && prev.cross === cross; if (!animateIt) { controls.current?.stop(); el.style[axis] = `${value}px`; return; } // No explicit start value: a retarget mid-flight continues from where it is, with its velocity. controls.current = animate(el, { [axis]: value }, resizeSpring(prev.value, value)); }, [axis, reduced, size]); useLayoutEffect(() => () => controls.current?.stop(), []); return { containerRef, measureRef }; } ``` `components/pixilui/lib/utils.ts` ```tsx import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge needs to know the pixilui scales, or it treats text-14 (a size) and text-secondary (a colour) as the same group and drops one of them. */ const merge = extendTailwindMerge<"hairline" | "hairline-color">({ extend: { theme: { text: ["12", "14", "16", "18", "22", "28", "36", "52", "72"], radius: ["control", "panel", "surface", "pill"], shadow: ["resting", "raised", "floating"], spacing: ["control-sm", "control-md", "control-lg"], ease: ["enter", "smooth-out", "exit", "move", "swap", "bounce", "spring"], blur: ["subtle", "soft", "text"], }, classGroups: { duration: [ { duration: ["instant", "quick", "fast", "standard", "medium", "slow", "considered", "very-slow", "spring"] }, ], hairline: ["hairline"], "hairline-color": [{ hairline: ["subtle", "strong", "accent", "danger"] }], }, }, }); /** Join class names; later classes win over earlier ones (className is always merged last). */ export function cn(...inputs: ClassValue[]) { return merge(clsx(inputs)); } ``` ## Usage When to use: - Installed once per app, automatically, by the first pixilui item you add. When not to use: - Do not copy values out of it into components; read the utilities (bg-surface, rounded-control, duration-fast). ## Examples None. ## API See the tokens in foundation.css and motion-tokens.ts. ## Keyboard None. ## Accessibility - Text roles clear 4.5:1 on their surfaces in light and dark. - Accent fills keep light text at 4.5:1 or switch to dark text (amber). - focus-ring draws a 2px outline in the accent with a 2px offset. ## Motion - Springs: snappy for presses and toggles, smooth for panels and anything shrinking, morph for anything growing. - Every item reads motionTokens and has a reduced-motion branch: a short fade or an instant change. ## Responsive - Tokens are size-independent; items size with their container. ## Performance - One stylesheet of custom properties; utilities compile through @theme inline, so no runtime theme provider. ## Notes for AI - Use semantic utilities only: bg-surface, bg-surface-muted, text-foreground, text-secondary, text-muted, border-border, hairline, rounded-control, rounded-panel, h-control-md, text-14, shadow-raised. - The accent fill is bg-accent-solid with text-accent-contrast. bg-accent stays shadcn's hover grey. - Hover styles use hover-fine:, focus uses focus-visible:focus-ring. - Theme with data-theme="dark" (or .dark) and data-accent="violet|blue|green|amber|orange|coral|rose|neutral". ## Related - [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place. - [Input](https://pixilui.com/components/input/markdown): A text field with its label, help, error, prefix and suffix slots and a clear button, wired for screen readers. - [Approval card](https://pixilui.com/components/approval-card/markdown): An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note. # Approval card An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note. Tier: free. Type: pattern. Category: ai. Tags: agent, approval, keyboard, motion. ## Install ```bash npx shadcn@latest add @pixilui/approval-card ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/approval-card.tsx`. npm: `motion`. ## Preview Live preview: https://pixilui.com/components/approval-card Knobs: - `risk`: `medium`, `low`, `high` - `status`: `pending`, `approved`, `rejected` Signature: Approve, and the whole card folds into a one-line receipt on a spring while focus lands on it. ## Code `components/pixilui/approval-card.tsx` ```tsx "use client"; import { forwardRef, useCallback, useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type FormEvent, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { useMorphSize } from "./lib/use-morph-size"; import { motionTokens, reducedTransition } from "./motion-tokens"; import { Button } from "./button"; import { Input } from "./input"; export type ApprovalCardStatus = "pending" | "approved" | "rejected"; export type ApprovalCardRisk = "low" | "medium" | "high"; export type ApprovalCardItem = { /** What the row is, in a word or two: "Audience", "Cost". */ label: string; /** The value the person checks before deciding. */ value: ReactNode; /** Set tabular mono figures for amounts, counts and IDs. */ numeric?: boolean; }; export type ApprovalCardLabels = { approve: string; confirm: string; approving: string; reject: string; rejectWithNote: string; rejecting: string; cancel: string; noteLabel: string; notePlaceholder: string; approved: string; rejected: string; error: string; confirmHint: string; low: string; medium: string; high: string; }; const defaultLabels: ApprovalCardLabels = { approve: "Approve", confirm: "Yes, approve", approving: "Approving", reject: "Reject", rejectWithNote: "Reject with note", rejecting: "Rejecting", cancel: "Cancel", noteLabel: "Note for the agent", notePlaceholder: "What should it change?", approved: "Approved", rejected: "Rejected", error: "That did not go through. Nothing changed, so you can try again.", confirmHint: "High risk. Press approve again to confirm.", low: "Low risk", medium: "Medium risk", high: "High risk", }; export type ApprovalCardProps = Omit, "title"> & { /** What the agent wants to do, in one sentence. */ title: string; /** Rows the person should see before deciding. */ items?: ApprovalCardItem[]; /** Blast radius. High risk asks for a second press before it approves. */ risk?: ApprovalCardRisk; /** Controlled decision. Leave it out and the card keeps its own once the callbacks settle. */ status?: ApprovalCardStatus; /** Starting decision when uncontrolled. */ defaultStatus?: ApprovalCardStatus; /** Who is asking: the agent's name, a run ID, when. Sits beside the actions. */ source?: ReactNode; /** The agent or tool mark, shown in a small tile beside the title. */ icon?: ReactNode; /** Runs on approve. Return a promise and the card shows its own pending state, then folds into a receipt. */ onApprove?: () => void | Promise; /** Runs on reject with the note the person wrote (possibly empty). Return a promise for a pending state. */ onReject?: (note: string) => void | Promise; /** The receipt line once decided. Defaults to the title, or the note when rejected with one. */ receipt?: ReactNode; /** Mark in the receipt tile. Defaults to a drawn check (approved) or cross (rejected). */ receiptIcon?: ReactNode; /** Copy overrides, for verbs that fit the action ("Approve and send") and for translation. */ labels?: Partial; }; type Phase = "idle" | "armed" | "approving" | "noting" | "rejecting"; const riskStyles: Record = { low: "bg-success-subtle text-success", medium: "bg-warning-subtle text-warning", high: "bg-danger-subtle text-danger", }; function ApprovalCardMark({ status, reduced }: { status: Exclude; reduced: boolean }) { const draw = reduced ? { initial: { pathLength: 1 }, transition: reducedTransition } : { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter } }; return ( ); } export const ApprovalCard = forwardRef(function ApprovalCard( { title, items = [], risk = "low", status: statusProp, defaultStatus = "pending", source, icon, onApprove, onReject, receipt, receiptIcon, labels: labelsProp, className, onKeyDown, ...rest }, forwardedRef, ) { const labels = { ...defaultLabels, ...labelsProp }; const reduced = useReducedMotion() ?? false; const titleId = useId(); const [innerStatus, setInnerStatus] = useState(defaultStatus); const [phase, setPhase] = useState("idle"); const [note, setNote] = useState(""); const [submittedNote, setSubmittedNote] = useState(""); const [error, setError] = useState(false); const status = statusProp ?? innerStatus; const decided = status !== "pending"; const sectionRef = useRef(null); const rejectRef = useRef(null); const noteRef = useRef(null); const receiptRef = useRef(null); const focusAfter = useRef<"note" | "reject" | "receipt" | null>(null); const { containerRef, measureRef } = useMorphSize("height", `${status}:${phase === "noting" || phase === "rejecting"}:${error}`); const setRef = useCallback( (node: HTMLElement | null) => { sectionRef.current = node; containerRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef, containerRef], ); // Focus follows the morph: into the note, back to Reject, onto the receipt. useEffect(() => { const target = focusAfter.current; if (!target) return; const el = target === "note" ? noteRef.current : target === "reject" ? rejectRef.current : receiptRef.current; if (el) { el.focus(); focusAfter.current = null; } }); const focusInside = () => Boolean(sectionRef.current?.contains(sectionRef.current.ownerDocument.activeElement)); const approve = async () => { if (decided || phase === "approving" || phase === "rejecting" || phase === "noting") return; if (risk === "high" && phase !== "armed") { setPhase("armed"); return; } setError(false); setPhase("approving"); try { await onApprove?.(); if (focusInside()) focusAfter.current = "receipt"; setInnerStatus("approved"); setPhase("idle"); } catch { setError(true); setPhase("idle"); } }; const openNote = () => { if (decided || phase === "approving" || phase === "rejecting") return; setError(false); setPhase("noting"); focusAfter.current = "note"; }; const closeNote = () => { setPhase("idle"); focusAfter.current = "reject"; }; const reject = async (event: FormEvent) => { event.preventDefault(); if (phase === "rejecting") return; setError(false); setPhase("rejecting"); const text = note.trim(); try { await onReject?.(text); if (focusInside()) focusAfter.current = "receipt"; setSubmittedNote(text); setInnerStatus("rejected"); setPhase("idle"); } catch { setError(true); setPhase("noting"); } }; const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented || decided) return; if (event.key === "Enter" && event.target === event.currentTarget) { event.preventDefault(); void approve(); } else if (event.key === "Escape") { if (phase === "noting") { event.preventDefault(); event.stopPropagation(); closeNote(); } else if (phase === "armed" || document.activeElement !== rejectRef.current) { event.preventDefault(); event.stopPropagation(); if (phase === "armed") setPhase("idle"); rejectRef.current?.focus(); } } }; const announcement = phase === "approving" ? labels.approving : phase === "rejecting" ? labels.rejecting : phase === "armed" ? labels.confirmHint : error ? labels.error : status === "approved" ? labels.approved : status === "rejected" ? labels.rejected : ""; const leave = reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }; const arrive = (index: number) => reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: reducedTransition } : { initial: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, filter: "blur(0px)" }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * (index + 1) }, }; const noteOpen = phase === "noting" || phase === "rejecting"; const receiptText = receipt ?? (status === "rejected" && submittedNote ? submittedNote : title); return (
{decided ? ( {receiptIcon ?? } {status === "approved" ? labels.approved : labels.rejected} {receiptText} ) : (
{icon ? ( {icon} ) : null}

{title}

{items.length > 0 ? (
{items.map((item, index) => (
0 && "border-t border-border-subtle")} >
{item.label}
{item.value}
))}
) : null} {error ?

{labels.error}

: null} {noteOpen ? (
setNote(event.target.value)} className="min-w-0 flex-1 basis-48" autoComplete="off" />
) : (
{source ?
{source}
: null}
)}
)}

{announcement}

); }); ``` ## Usage When to use: - An agent is about to do something with a cost or a blast radius: send, pay, delete, deploy, email customers. - A human has to sign off inside a run timeline or an inbox of agent requests. When not to use: - A yes or no with no consequence: use a button. - A plan with many steps to review: use agent-plan. - Confirming your own destructive action: use hold-to-confirm. ```tsx import { useState } from "react"; import { Mail, RotateCcw } from "lucide-react"; import { Button } from "@/components/pixilui/button"; import { ApprovalCard, type ApprovalCardItem } from "@/components/pixilui/approval-card"; export const knobs = { risk: ["medium", "low", "high"], status: ["pending", "approved", "rejected"], } as const; const items: ApprovalCardItem[] = [ { label: "Template", value: "Q3 renewal, second notice" }, { label: "Audience", value: "3,412 customers on annual plans" }, { label: "Sends from", value: "billing@northwind.co" }, { label: "Estimated cost", value: "$41.30", numeric: true }, ]; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export default function ApprovalCardDemo({ risk = "medium", status = "pending" }: KnobValues) { const [round, setRound] = useState(0); const [decided, setDecided] = useState(false); // "Ask again" restarts from pending, until the status knob moves. const [resetFrom, setResetFrom] = useState(null); const start = resetFrom === status ? "pending" : status; return (
} source="Billing agent, run 4821" items={items} risk={risk} defaultStatus={start} labels={{ approve: "Approve and send" }} onApprove={async () => { await wait(1100); setDecided(true); }} onReject={async () => { await wait(700); setDecided(true); }} /> {decided || start !== "pending" ? ( ) : null}
); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` (required) | `string` | | What the agent wants to do, in one sentence. | | `items` | `ApprovalCardItem[]` | `[]` | Rows the person should see before deciding. | | `risk` | `"low" \| "medium" \| "high"` | `"low"` | Blast radius. High risk asks for a second press before it approves. | | `status` | `"pending" \| "approved" \| "rejected"` | | Controlled decision. Leave it out and the card keeps its own once the callbacks settle. | | `defaultStatus` | `"pending" \| "approved" \| "rejected"` | `"pending"` | Starting decision when uncontrolled. | | `source` | `ReactNode` | | Who is asking: the agent's name, a run ID, when. Sits beside the actions. | | `icon` | `ReactNode` | | The agent or tool mark, shown in a small tile beside the title. | | `onApprove` | `() => void \| Promise` | | Runs on approve. Return a promise and the card shows its own pending state, then folds into a receipt. | | `onReject` | `(note: string) => void \| Promise` | | Runs on reject with the note the person wrote (possibly empty). Return a promise for a pending state. | | `receipt` | `ReactNode` | | The receipt line once decided. Defaults to the title, or the note when rejected with one. | | `receiptIcon` | `ReactNode` | | Mark in the receipt tile. Defaults to a drawn check (approved) or cross (rejected). | | `labels` | `Partial` | | Copy overrides, for verbs that fit the action ("Approve and send") and for translation. | Also accepts every native `
` attribute except `title`, and a ref to it. ## Keyboard - `Tab`: Focuses the card, then Reject, then Approve. - `Enter`: Approves when the card itself is focused. High risk asks for a second press. - `Esc`: Focuses Reject. In the note, closes it and returns to Reject. On an armed high-risk approval, disarms it. - `Enter`: In the note field, sends the rejection with the note. ## Accessibility - The card is a section labelled by its title, so it reads as a named region. - Approve and Reject are real buttons; pending work uses aria-busy, never disabled, so focus stays put. - Status changes (approving, approved, rejected, errors, the high-risk confirm) are announced in a polite live region. - When the card folds, focus moves to the receipt so keyboard users are never dropped to the page. ## Motion - Approving folds the card into a one-line receipt: its height shrinks on the smooth spring while the radius and surface stay one material. - The old face fades out in 120ms through a 2px blur; the receipt's parts arrive in reading order 35ms apart. - The receipt check draws itself and scales in on the snappy spring. No glow, no confetti. - Opening the note grows the card on the morph spring; the Approve label morphs to Approving and to Yes, approve on high risk. - Reduced motion: the fold is instant and the receipt fades in. ## Responsive - Fills its container. When the source and the actions no longer fit on one line, the actions wrap to their own line, right-aligned; the note field does the same. - Uses flex-wrap, not container queries, so it keeps its width inside shrink-to-fit parents. - Long titles wrap; long values break inside their row. ## Performance - One ResizeObserver per card drives the fold; nothing animates on mount. ## Notes for AI - Return a promise from onApprove and onReject: the card shows pending, recovers on a thrown error, and folds when it resolves. - Name the action in labels.approve ("Approve and send"); keep the title one sentence in the agent's words. - Put amounts, counts and IDs in items with numeric: true so they set in tabular figures. - Pass status to control it from your store; leave it out and the card tracks its own decision. - Pass the agent's mark as icon; the component never imports an icon library. ## Related - `hold-to-confirm` (planned) - `agent-plan` (planned) - [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line. - [Run timeline](https://pixilui.com/components/run-timeline/markdown): The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval. - [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place. # Button A button whose label can change without a jump: the width springs to the new words while they swap in place. Tier: free. Type: component. Category: actions. Tags: motion, keyboard. ## Install ```bash npx shadcn@latest add @pixilui/button ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/button.tsx`. npm: `motion`. ## Preview Live preview: https://pixilui.com/components/button Knobs: - `variant`: `primary`, `secondary`, `ghost`, `danger` - `size`: `md`, `sm`, `lg` - `loading`: `false`, `true` Signature: The label changes and the button grows into the new words on a spring, never a jump. ## Code `components/pixilui/button.tsx` ```tsx "use client"; import { forwardRef, useCallback, useRef, type ComponentPropsWithoutRef, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, } from "react"; import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { useMorphSize } from "./lib/use-morph-size"; import { motionTokens, reducedTransition } from "./motion-tokens"; type ButtonOwnProps = { /** Visual weight. Keep one primary per surface. */ variant?: "primary" | "secondary" | "ghost" | "danger"; /** Control height: sm is 36px, md 44px, lg 50px. */ size?: "sm" | "md" | "lg"; /** Shows a spinner, sets aria-busy and aria-disabled, and ignores presses. Focus stays on the button. */ loading?: boolean; /** Leading icon. The spinner takes its place while loading. */ icon?: ReactNode; /** Trailing icon, after the label. */ iconEnd?: ReactNode; }; type ButtonLabelProps = | { /** The label. When it changes, the button morphs to the new width and the words swap. */ children: ReactNode; } | { children?: undefined; /** Icon-only buttons need an accessible name. */ "aria-label": string; icon: ReactNode; }; export type ButtonProps = Omit, "children"> & ButtonOwnProps & ButtonLabelProps; const variants = { primary: "bg-accent-solid text-accent-contrast shadow-control inset-shadow-highlight hover-fine:bg-accent-solid/90", secondary: "bg-surface text-foreground shadow-control hairline hairline-strong hover-fine:bg-surface-muted", ghost: "bg-transparent text-foreground hover-fine:bg-surface-muted", danger: "bg-danger text-danger-foreground shadow-resting hover-fine:bg-danger/90", } as const; const sizes = { sm: "h-control-sm px-3.5 text-14", md: "h-control-md px-4 text-14", lg: "h-control-lg px-5 text-16", } as const; const gaps = { sm: "gap-1.5", md: "gap-2", lg: "gap-2" } as const; const iconOnlySizes = { sm: "w-control-sm px-0", md: "w-control-md px-0", lg: "w-control-lg px-0", } as const; function labelKeyOf(children: ReactNode) { return typeof children === "string" || typeof children === "number" ? String(children) : "node"; } /** A ring that turns while work is in flight. Still under reduced motion. */ function ButtonSpinner() { return ( ); } export const Button = forwardRef(function Button( { variant = "primary", size = "md", loading = false, icon, iconEnd, children, type = "button", disabled, className, onClick, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onKeyDown, onKeyUp, "aria-disabled": ariaDisabledProp, ...rest }, forwardedRef, ) { const reduced = useReducedMotion(); const buttonRef = useRef(null); const labelKey = labelKeyOf(children); const { containerRef, measureRef } = useMorphSize("width", labelKey); const ariaDisabled = ariaDisabledProp === true || ariaDisabledProp === "true"; const inert = loading || ariaDisabled || Boolean(disabled); const iconOnly = children === undefined || children === null; const setRef = useCallback( (node: HTMLButtonElement | null) => { buttonRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const press = (down: boolean) => { const el = buttonRef.current; if (!el || reduced || (down && inert)) return; animate(el, { scale: down ? motionTokens.scale.press : 1 }, motionTokens.spring.snappy); }; const swap = reduced ? reducedTransition : { duration: motionTokens.duration.quick, ease: motionTokens.ease.swap }; const blur = `blur(${motionTokens.blur.subtle}px)`; return ( ); }); ``` ## Usage When to use: - Any action someone triggers: submit, send, deploy, approve. - Actions whose label follows the work (Send, Sending, Sent) so the button tells the story without a toast. When not to use: - Navigation to another page: use a link styled as you need. - A destructive action on its own: pair the danger variant with a confirm step or hold-to-confirm. ```tsx import { useState } from "react"; import { Check, Ellipsis, Eye, Send } from "lucide-react"; import { Button } from "@/components/pixilui/button"; export const knobs = { variant: ["primary", "secondary", "ghost", "danger"], size: ["md", "sm", "lg"], loading: [false, true], } as const; type Phase = "idle" | "sending" | "sent"; const labels: Record = { idle: "Send invoice", sending: "Sending to Northwind Freight", sent: "Sent", }; export default function ButtonDemo({ variant = "primary", size = "md", loading = false }: KnobValues) { const [phase, setPhase] = useState("idle"); const send = () => { if (phase !== "idle") { setPhase("idle"); return; } setPhase("sending"); setTimeout(() => setPhase("sent"), 1600); }; return (
); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `aria-label` | `string` | | Icon-only buttons need an accessible name. | | `variant` | `"primary" \| "secondary" \| "ghost" \| "danger"` | `"primary"` | Visual weight. Keep one primary per surface. | | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control height: sm is 36px, md 44px, lg 50px. | | `loading` | `boolean` | `false` | Shows a spinner, sets aria-busy and aria-disabled, and ignores presses. Focus stays on the button. | | `icon` | `ReactNode` | | Leading icon. The spinner takes its place while loading. | | `iconEnd` | `ReactNode` | | Trailing icon, after the label. | | `children` | `ReactNode \| undefined` | | The label. When it changes, the button morphs to the new width and the words swap. | Also accepts every native ` ) : null} ); }); ``` ## Usage When to use: - A decision has to be made before the person goes on: connect a tool, delete an agent, discard a run. - A short task that needs focus and fits on one screen: rename, invite, set a budget. When not to use: - Details the person reads while working on the page: use a drawer. - A few options anchored to a control: use a popover. - An agent asking a human to sign off inside a run: use approval-card, which stays in the timeline. ```tsx import { useState } from "react"; import { Plug, Trash2 } from "lucide-react"; import { Button } from "@/components/pixilui/button"; import { Dialog, DialogActions, DialogDescription, DialogTitle } from "@/components/pixilui/dialog"; // The first option of each knob is the demo's default; the docs preview starts there. export const knobs = { variant: ["default", "danger"], size: ["md", "sm", "lg"], state: ["open", "closed"], } as const; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const scopes = [ { label: "Reads", value: "Issues and comments in Support, Platform" }, { label: "Writes", value: "Draft replies, labels" }, { label: "Never", value: "Closes or deletes an issue" }, ]; export default function DialogDemo({ variant = "default", size = "md", state = "open" }: KnobValues) { // The dialog portals into this stage, so it keeps the preview's theme and accent and stays inside it. // It waits while the stage is null, so pass the element itself (a ref object would resolve too late). const [stage, setStage] = useState(null); const danger = variant === "danger"; return (
: } trigger={danger ? : } > {danger ? ( <> Delete the Billing agent? Its 214 runs, 3 schedules and the Stripe connection go with it. Reminders it already sent stay in your customers' inboxes. wait(900)} /> ) : ( <> Connect Linear to the triage agent The agent drafts a reply for every new issue and waits for a person to send it.
{scopes.map((scope, index) => (
0 ? "flex gap-4 border-t border-border-subtle py-2.5" : "flex gap-4 py-2.5"}>
{scope.label}
{scope.value}
))}
wait(900)} /> )}
); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `children` | `ReactNode` | | The dialog's parts: DialogTitle, DialogDescription, your content, DialogActions. | | `open` | `boolean` | | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. | | `onOpenChange` | `(open: boolean) => void` | | Runs whenever the dialog opens or closes, from the trigger, Esc, an outside press or an action. | | `trigger` | `ReactElement` | | The element that opens the dialog, usually a Button. It is rendered through Base UI's render prop, so any element works and keeps its own props. | | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Panel width: sm is 384px, md 448px, lg 576px. It never exceeds the viewport. | | `variant` | `"default" \| "danger"` | `"default"` | danger tints the icon tile, styles the confirm action with the danger tokens, places it second and focuses Cancel first. | | `dismissible` | `boolean` | `true` | Close on Esc and on a press outside the panel. Turn off for a decision that must be made. | | `icon` | `ReactNode` | | A mark shown in a tile above the title. Components never bake in an icon. | | `container` | `DialogContainer` | | Where the dialog portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. | | `modal` | `boolean \| "trap-focus"` | | true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. | Also accepts every native `
` attribute except `role`, and a ref to it. ## Keyboard - `Enter`: On the trigger, opens the dialog and moves focus inside it. - `Tab`: Moves between the controls in the dialog; focus never leaves it while it is open. - `Esc`: Closes the dialog and returns focus to the trigger, unless dismissible is false or an action is pending. ## Accessibility - Base UI renders role dialog with aria-modal and wires DialogTitle and DialogDescription to aria-labelledby and aria-describedby. - The danger variant renders role alertdialog and puts focus on Cancel first, so a stray Enter never deletes anything. - Focus is trapped while open and returns to the trigger on close; page scroll is locked for a full-page dialog. - The confirm action shows pending with aria-busy and aria-disabled, never disabled, so focus stays on it; errors are announced in a polite live region. ## Motion - The panel opens on the morph spring from scale 0.96 while a 2px blur clears, and closes faster on the exit ease in 150ms. - The backdrop fades in on duration-fast. - Reduced motion: both fade only, with no scale or blur. ## Responsive - The panel fills the width up to its size (384, 448 or 576px) with 16px of margin, and never exceeds the viewport height. - Long content scrolls inside the panel while the actions stay pinned to the bottom. - Below 640px the actions stack full width with the main action on top. ## Performance - Nothing is mounted while closed; the panel and backdrop mount on open and unmount after the exit animation. ## Notes for AI - Pass the opener as trigger (any element; a Button is usual). For a dialog opened from code, leave trigger out and control open and onOpenChange. - Compose the parts: DialogTitle, DialogDescription, your content, then DialogActions with confirm and onConfirm. - Return a promise from onConfirm: the button shows pending, Esc and outside presses wait, and the dialog closes when it resolves or shows an error when it throws. - Name the action in confirm ("Delete agent", "Connect Linear"), never "OK" or "Continue". - Use variant danger for anything destructive; it styles the action with the danger tokens, puts it second and focuses Cancel. - Pass container to keep the dialog inside a region of the page; it then traps focus without locking page scroll. ## Related - [Drawer](https://pixilui.com/components/drawer/markdown): A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel. - [Popover](https://pixilui.com/components/popover/markdown): A small panel anchored to a control for a closer look or a quick change: a run's budget, a model's settings, who can see a thread. - [Approval card](https://pixilui.com/components/approval-card/markdown): An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note. - [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place. # Drawer A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel. Tier: free. Type: component. Category: overlays. Tags: keyboard, motion, run. ## Install ```bash npx shadcn@latest add @pixilui/drawer ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/drawer.tsx`. npm: `@base-ui/react`, `motion`. ## Preview Live preview: https://pixilui.com/components/drawer Knobs: - `side`: `right`, `bottom`, `left` - `size`: `md`, `sm`, `lg` - `state`: `open`, `closed` Signature: On a phone, flick the handle and the sheet leaves at the speed you threw it, on a spring that never bounces back. ## Code `components/pixilui/drawer.tsx` ```tsx "use client"; import { forwardRef, isValidElement, useCallback, useId, useMemo, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactElement, type ReactNode, } from "react"; import { Dialog as BaseDialog } from "@base-ui/react/dialog"; import { AnimatePresence, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react"; import { cn } from "./lib/utils"; import { motionTokens, reducedTransition } from "./motion-tokens"; import { DialogActions, DialogClose, DialogDescription, DialogProvider, DialogTitle, dialogTriggerIsNativeButton, type DialogContainer, } from "./dialog"; export type DrawerSide = "right" | "left" | "bottom"; export type DrawerSize = "sm" | "md" | "lg"; export type DrawerProps = Omit, "children" | "role"> & { /** The drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions. */ children?: ReactNode; /** Controlled open state. Pair with onOpenChange. */ open?: boolean; /** Starting open state when uncontrolled. */ defaultOpen?: boolean; /** Runs whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action. */ onOpenChange?: (open: boolean) => void; /** The element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works. */ trigger?: ReactElement; /** The edge the sheet slides from. */ side?: DrawerSide; /** Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen). */ size?: DrawerSize; /** Close on Esc, an outside press and, for bottom drawers on touch screens, a swipe down. */ dismissible?: boolean; /** Where the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */ container?: DialogContainer; /** true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. */ modal?: boolean | "trap-focus"; }; const panelSides: Record = { right: "inset-y-2 right-2 w-[calc(100%-1rem)]", left: "inset-y-2 left-2 w-[calc(100%-1rem)]", bottom: "inset-x-2 bottom-2 mx-auto max-w-2xl", }; const panelSizes: Record> = { right: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" }, left: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" }, bottom: { sm: "max-h-[40%]", md: "max-h-[65%]", lg: "max-h-[90%]" }, }; /** How far off screen the sheet waits: past its own size plus the 8px inset. */ const offscreen: Record = { right: { x: "110%" }, left: { x: "-110%" }, bottom: { y: "110%" }, }; /** A swipe closes the sheet past this share of its height, or faster than this many px/s. */ const SWIPE_DISTANCE = 0.3; const SWIPE_VELOCITY = 600; /** Upward pull resistance: the sheet gives about 10px before it stops. */ const PULL_ELASTIC = 0.04; const coarseQuery = "(pointer: coarse)"; const subscribeCoarse = (onChange: () => void) => { const list = window.matchMedia(coarseQuery); list.addEventListener("change", onChange); return () => list.removeEventListener("change", onChange); }; const readCoarse = () => window.matchMedia(coarseQuery).matches; const readCoarseOnServer = () => false; function usePointerCoarse() { return useSyncExternalStore(subscribeCoarse, readCoarse, readCoarseOnServer); } export const Drawer = forwardRef(function Drawer( { children, open: openProp, defaultOpen = false, onOpenChange, trigger, side = "right", size = "md", dismissible = true, container, modal, className, ...rest }, forwardedRef, ) { const reduced = useReducedMotion() ?? false; const coarse = usePointerCoarse(); const dragControls = useDragControls(); const [innerOpen, setInnerOpen] = useState(defaultOpen); const open = openProp ?? innerOpen; const generatedTriggerId = useId(); const triggerId = (isValidElement<{ id?: string }>(trigger) && trigger.props.id) || generatedTriggerId; const cancelRef = useRef(null); const pendingRef = useRef(false); const panelRef = useRef(null); const swipeable = side === "bottom" && coarse && dismissible; const setOpen = useCallback( (next: boolean) => { setInnerOpen(next); onOpenChange?.(next); }, [onOpenChange], ); const context = useMemo( () => ({ variant: "default" as const, close: () => setOpen(false), cancelRef, setPending: (pending: boolean) => { pendingRef.current = pending; }, }), [setOpen], ); const setPanelRef = useCallback( (node: HTMLDivElement | null) => { panelRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const onDragEnd = (event: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => { const height = (event.target as HTMLElement | null)?.closest('[data-slot="drawer"]')?.getBoundingClientRect().height ?? 0; if (info.offset.y > height * SWIPE_DISTANCE || info.velocity.y > SWIPE_VELOCITY) setOpen(false); }; const backdrop = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition } : { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: motionTokens.duration.medium, ease: motionTokens.ease.smoothOut } }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.smoothOut }, }; const panel = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition } : { initial: { opacity: 0, ...offscreen[side] }, animate: { opacity: 1, x: 0, y: 0 }, // The exit is a spring too, so a flick carries its velocity off screen. exit: { opacity: 0, ...offscreen[side], transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.medium, ease: motionTokens.ease.exit } }, }, transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter } }, }; const state = open ? "open" : "closed"; return ( { if (!next && (pendingRef.current || (!dismissible && details.reason === "escape-key"))) { details.cancel(); return; } setOpen(next); }} > {trigger ? : null} {open ? ( } /> } {...rest} > {side === "bottom" && coarse ? ( ) : null}
{children}
) : null}
); }); /** The drawer's name, wired to aria-labelledby. Same part as DialogTitle. */ export const DrawerTitle = DialogTitle; /** Supporting sentence, wired to aria-describedby. Same part as DialogDescription. */ export const DrawerDescription = DialogDescription; /** The button row, pinned to the bottom while content scrolls. Same part as DialogActions. */ export const DrawerActions = DialogActions; /** A Button that closes the drawer. Same part as DialogClose. */ export const DrawerClose = DialogClose; ``` ## Usage When to use: - Details of one row the person opens and closes while scanning a list: a run, a tool call, a customer. - A form too long for a dialog that still belongs to the current page: agent settings, a schedule. - On phones, actions and filters from the bottom edge where a thumb reaches them. When not to use: - A single decision: use a dialog. - A few options anchored to a control: use a popover. - Navigation that stays open: use a sidebar in the layout, not an overlay. ```tsx import { useState } from "react"; import { Button } from "@/components/pixilui/button"; import { Drawer, DrawerActions, DrawerDescription, DrawerTitle } from "@/components/pixilui/drawer"; // The first option of each knob is the demo's default; the docs preview starts there. export const knobs = { side: ["right", "bottom", "left"], size: ["md", "sm", "lg"], state: ["open", "closed"], } as const; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const steps = [ { time: "09:41:02", text: "Read 48 open invoices from Stripe" }, { time: "09:41:09", text: "Matched 46 to customers in HubSpot" }, { time: "09:41:15", text: "Drafted reminders in the Q3 renewal template" }, { time: "09:42:30", text: "Sent 46 reminders from billing@northwind.co" }, { time: "09:42:31", text: "Skipped Arden Labs: no billing contact" }, { time: "09:42:31", text: "Skipped Kova Health: invoice disputed" }, { time: "09:42:40", text: "Logged the run to #billing-ops in Slack" }, ]; export default function DrawerDemo({ side = "right", size = "md", state = "open" }: KnobValues) { // The drawer portals into this stage, so it keeps the preview's theme and accent and stays inside it. const [stage, setStage] = useState(null); return (
Open run 4821} > Run 4821, Billing agent Finished 2 minutes ago. 46 of 48 reminders sent.
    {steps.map((step, index) => (
  1. {step.time} {step.text}
  2. ))}
wait(900)} labels={{ cancel: "Close" }} />
); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `children` | `ReactNode` | | The drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions. | | `open` | `boolean` | | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. | | `onOpenChange` | `(open: boolean) => void` | | Runs whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action. | | `trigger` | `ReactElement` | | The element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works. | | `side` | `"right" \| "left" \| "bottom"` | `"right"` | The edge the sheet slides from. | | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen). | | `dismissible` | `boolean` | `true` | Close on Esc, an outside press and, for bottom drawers on touch screens, a swipe down. | | `container` | `DialogContainer` | | Where the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. | | `modal` | `boolean \| "trap-focus"` | | true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. | Also accepts every native `
` attribute except `role`, and a ref to it. ## Keyboard - `Enter`: On the trigger, opens the drawer and focuses the sheet, so reading starts at its title. - `Tab`: Moves between the controls in the drawer; focus stays inside while it is open. - `Esc`: Closes the drawer and returns focus to the trigger, unless dismissible is false or an action is pending. ## Accessibility - Built on Base UI Dialog: role dialog, aria-modal, and the title and description wired to aria-labelledby and aria-describedby. - The sheet itself takes focus on open, so a screen reader starts at the title instead of the footer button; Tab then moves through its controls. - Focus is trapped while open and returns to the trigger on close; page scroll is locked for a full-page drawer. - The drag handle is decorative (aria-hidden); keyboard and screen reader users close with Esc or the Close action. - Swipe to dismiss only exists on coarse pointers, so a mouse never drags the sheet by accident. ## Motion - The sheet slides in on the smooth spring, which never overshoots, and fades in on duration-fast. - Closing uses the smooth spring too, so a flick down on the handle carries its velocity off screen. - Pulling a bottom sheet up rubber-bands about 10px; letting go short of a third of its height springs it back. - Reduced motion: the sheet and backdrop fade only, and dragging is off. ## Responsive - Side sheets take the full width minus 16px on phones and cap at 320, 384 or 512px. - Bottom sheets center at up to 672px wide and cap their height at 40, 65 or 90% of the screen. - Content scrolls inside the sheet; DrawerActions stay pinned to its bottom. - Bottom sheets show a drag handle on touch screens only. ## Performance - Nothing is mounted while closed. Dragging runs on a motion value, outside React renders. ## Notes for AI - Same API as dialog: trigger, open, defaultOpen, onOpenChange, dismissible, container. - Compose DrawerTitle, DrawerDescription, your content and DrawerActions; they are the dialog parts under drawer names. - Use side bottom for mobile action sheets and side right for detail panels on desktop. - Return a promise from DrawerActions onConfirm to get pending and error handling for free. ## Related - [Dialog](https://pixilui.com/components/dialog/markdown): A modal panel for one decision or one short task: a title, what happens, and the buttons that do it, with pending and error handled. - [Popover](https://pixilui.com/components/popover/markdown): A small panel anchored to a control for a closer look or a quick change: a run's budget, a model's settings, who can see a thread. - [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place. # Input A text field with its label, help, error, prefix and suffix slots and a clear button, wired for screen readers. Tier: free. Type: component. Category: inputs. Tags: form, keyboard. ## Install ```bash npx shadcn@latest add @pixilui/input ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/input.tsx`. npm: `motion`. ## Preview Live preview: https://pixilui.com/components/input Knobs: - `size`: `md`, `sm`, `lg` - `state`: `default`, `error`, `disabled` Signature: Esc clears the field and hands focus straight back, and an error rises into place instead of popping. ## Code `components/pixilui/input.tsx` ```tsx "use client"; import { forwardRef, useCallback, useId, useRef, useState, type ChangeEvent, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { motionTokens, reducedTransition } from "./motion-tokens"; export type InputProps = Omit, "size" | "prefix"> & { /** Visible label. Use aria-label instead only when the surrounding UI already names the field. */ label?: ReactNode; /** Help under the field, linked with aria-describedby. */ description?: ReactNode; /** Error message. Marks the field invalid, replaces nothing, and is announced politely. */ error?: ReactNode; /** Content before the value: an icon, a currency sign, a URL scheme. */ prefix?: ReactNode; /** Content after the value: a unit, a shortcut hint, a status. */ suffix?: ReactNode; /** Control height: sm is 36px, md 44px, lg 50px. */ size?: "sm" | "md" | "lg"; /** Shows a clear button while there is a value. Esc clears too. */ clearable?: boolean; /** Runs after the clear button or Esc empties the field. onChange also fires with an empty value. */ onClear?: () => void; /** Accessible name of the clear button. */ clearLabel?: string; /** Icon for the clear button. Defaults to a drawn cross. */ clearIcon?: ReactNode; /** Classes for the outer wrapper (label, field and messages). Native attributes and the ref go to the input. */ className?: string; }; const fieldSizes = { sm: "h-control-sm gap-2 px-3 text-14", md: "h-control-md gap-2 px-3.5 text-16 sm:text-14", lg: "h-control-lg gap-2.5 px-4 text-16", } as const; function InputClearMark() { return (
); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `children` | `ReactNode` | | What the panel holds: PopoverTitle, PopoverDescription, controls, PopoverClose. | | `trigger` (required) | `ReactElement` | | The element the panel anchors to and opens from, usually a Button. Rendered through Base UI's render prop. | | `open` | `boolean` | | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. | | `onOpenChange` | `(open: boolean) => void` | | Runs whenever the popover opens or closes. | | `side` | `"top" \| "bottom" \| "left" \| "right"` | `"bottom"` | Preferred side of the trigger. It flips when there is no room. | | `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along that side. | | `offset` | `number` | `8` | Gap between the trigger and the panel, in px. | | `arrow` | `boolean` | `false` | Draw a small arrow pointing at the trigger. | | `hoverOpen` | `boolean` | `false` | Also open when the trigger is hovered (fine pointers only). Clicks still toggle it. | | `container` | `HTMLElement \| null` | | Where the popover portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. | Also accepts every native `
` attribute, and a ref to it. ## Keyboard - `Enter`: On the trigger, toggles the popover; Space does the same. - `Tab`: Moves through the controls inside; tabbing past the last one closes it and moves on. - `Esc`: Closes the popover and returns focus to the trigger. ## Accessibility - Base UI renders the panel as a dialog labelled by PopoverTitle and described by PopoverDescription, and sets aria-expanded and aria-controls on the trigger. - Focus moves into the panel on open and back to the trigger on close. - It is not modal: the page stays scrollable and usable, and an outside press closes it. - hoverOpen is off by default because hover alone never reaches touch or keyboard users; clicking still works when it is on. ## Motion - The panel scales up from 0.96 on the snappy spring, growing out of the trigger: transform-origin follows the side and alignment Base UI resolved. - It closes in 150ms on the exit ease. - Reduced motion: fade only. ## Responsive - The panel is as wide as its content up to 352px and never wider than the space beside the trigger. - It flips to the other side when there is no room and shifts to stay on screen. ## Performance - Nothing is mounted while closed; positioning updates only while open. ## Notes for AI - Pass the anchor as trigger; the popover opens from it and returns focus to it. - Start content with PopoverTitle so the panel has an accessible name. - Use PopoverClose for the action that finishes the job ("Raise to $20"); it closes and restores focus. - Pass side and align for placement; it still flips when it would leave the screen. ## Related - [Tooltip](https://pixilui.com/components/tooltip/markdown): A short text label for an icon button, with its keyboard shortcut, that waits 400ms and then opens neighbours instantly while you scan a toolbar. - [Dialog](https://pixilui.com/components/dialog/markdown): A modal panel for one decision or one short task: a title, what happens, and the buttons that do it, with pending and error handled. - [Drawer](https://pixilui.com/components/drawer/markdown): A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel. - [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place. # Run timeline The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval. Tier: free. Type: pattern. Category: ai. Tags: agent, run, trace, approval, motion. ## Install ```bash npx shadcn@latest add @pixilui/run-timeline ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/run-timeline.tsx`. npm: `motion`. ## Preview Live preview: https://pixilui.com/components/run-timeline Knobs: - `state`: `play`, `waiting`, `error`, `done` - `mode`: `expanded`, `compact` - `approval`: `auto`, `manual` Signature: The connector fills down to the next step as each check draws itself, until the run stops on an approval card and the card folds into a receipt. ## Code `components/pixilui/run-timeline.tsx` ```tsx "use client"; import { forwardRef, useEffect, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { instantTransition, motionTokens, reducedTransition } from "./motion-tokens"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } export type RunTimelineStatus = "pending" | "active" | "done" | "error" | "waiting"; export type RunTimelineStep = { /** Stable key for the step; onStepSelect receives it. */ id: string; /** What the step does, in a few words: "Pull stuck refunds from Stripe". */ title: ReactNode; /** Evidence once it ran ("9 refunds pending for more than 72 hours"), or why it failed. Hidden in compact mode. */ detail?: ReactNode; /** Pending, active (live timer), done (drawn check), error, or waiting on a person. */ status: RunTimelineStatus; /** When the step started (epoch ms). Drives the live timer and the duration. */ startedAt?: number; /** When the step finished (epoch ms). With startedAt, shows how long it took. */ endedAt?: number; /** Content under the step: an approval card on a waiting step, a tool call, a diff. Waiting steps show it in compact mode too. */ children?: ReactNode; }; export type RunTimelineLabels = Record; const defaultLabels: RunTimelineLabels = { pending: "Up next", active: "In progress", done: "Done", error: "Failed", waiting: "Needs approval", }; export type RunTimelineProps = Omit, "children"> & { /** The run's steps, in order. Update a step's status and the timeline animates the change. */ steps: RunTimelineStep[]; /** Titles only, tighter rhythm. Waiting steps still show their children. */ compact?: boolean; /** Makes each step a button. Called with the step's id. */ onStepSelect?: (id: string) => void; /** The step shown elsewhere (a detail pane), marked with data-selected. */ selectedId?: string; /** Status words, read by screen readers and shown for waiting and failed steps. */ labels?: Partial; }; const TICK_MS = 250; /** "820ms", "1.4s", "1m 04s". */ function formatDuration(ms: number) { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } function formatLive(ms: number) { const s = Math.floor(ms / 1000); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } /** A live timer whose digits are keyed by place and roll up. Only this re-renders each tick. */ function LiveTimer({ startedAt, reduced }: { startedAt: number; reduced: boolean }) { const [now, setNow] = useState(null); useEffect(() => { const tick = () => setNow(Date.now()); tick(); const id = setInterval(tick, TICK_MS); return () => clearInterval(id); }, [startedAt]); if (now === null) return null; const value = formatLive(Math.max(0, now - startedAt)); const chars = value.split(""); return ( {chars.map((char, index) => { const place = chars.length - index; if (reduced || !/\d/.test(char)) { return ( ); } return ( ); })} ); } /** Glyph rings sit between the 1px hairline and a 2px border. */ const ring = { borderWidth: 1.5 }; function Mark({ status, reduced }: { status: RunTimelineStatus; reduced: boolean }) { const draw = reduced ? { initial: { pathLength: 1 }, transition: reducedTransition } : { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 } }; switch (status) { case "pending": return ; case "active": return ( ); case "done": return ( ); case "error": return ( ); case "waiting": return ( ); } } /** * Detail and children open their own height, so the steps below are pushed along in the flow * (no overlap while the content fades in). Growing uses morph, closing uses smooth. The clip is * lifted once it has opened, so an approval card's shadow and focus ring show in full. */ function Reveal({ reduced, className, children, ...rest }: { reduced: boolean; className?: string; children: ReactNode; "data-slot"?: string }) { const [clip, setClip] = useState(true); const motionProps = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, height: 0, transition: instantTransition }, transition: reducedTransition, } : { initial: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, height: "auto", filter: "blur(0px)", transition: { height: motionTokens.spring.morph, default: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 }, }, }, exit: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { height: motionTokens.spring.smooth, default: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }, }, }; return ( setClip(true)} onAnimationComplete={() => setClip(false)} > {children} ); } export const RunTimeline = forwardRef(function RunTimeline( { steps, compact = false, onStepSelect, selectedId, labels: labelsProp, className, onKeyDown, ...rest }, ref, ) { const labels = { ...defaultLabels, ...labelsProp }; const reduced = useReducedMotionAfterHydration(); // Up and Down walk the step buttons, Home and End jump to the ends. const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented || !onStepSelect || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const buttons = Array.from(event.currentTarget.querySelectorAll('[data-slot="run-timeline-trigger"]')); const current = buttons.indexOf(event.target as HTMLElement); if (current === -1) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.min(buttons.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1))); buttons[next]?.focus(); }; const swapIn = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: instantTransition }, transition: instantTransition } : { initial: { opacity: 0, scale: motionTokens.scale.enterFrom, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.swap }, }; return (
    {steps.map((step, index) => { const last = index === steps.length - 1; const duration = step.startedAt !== undefined && step.endedAt !== undefined ? step.endedAt - step.startedAt : null; const showDetail = !compact && step.detail; const showChildren = step.children && (!compact || step.status === "waiting"); const flagged = step.status === "waiting" || step.status === "error"; const titleRow = ( <> {step.title} {flagged ? {labels[step.status]} : null} {step.status === "active" && step.startedAt !== undefined ? : null} {step.status === "done" && duration !== null ? {formatDuration(duration)} : null} ); return (
  1. {/* Rail: the glyph, then the connector to the next step, which fills when this one is done. */}
    {onStepSelect ? ( ) : (
    {titleRow}
    )} {labels[step.status]} {showDetail ? (
    {step.detail}
    ) : null}
    {showChildren ? (
    {step.children}
    ) : null}
  2. ); })}
); }); ``` ## Usage When to use: - Showing an agent run in progress, step by step, with the evidence of what each step did. - A run that pauses for a person: the waiting step hosts an approval card in place. - A run history view where each step can open details elsewhere (onStepSelect). When not to use: - A flat list of tool calls inside one message: use tool-call-row with ToolCallRowGroup. - A quiet status line while a model thinks: use thinking-state. - A form wizard or onboarding progress: use a stepper. ```tsx import { useEffect, useState, useSyncExternalStore } from "react"; import { useReducedMotion } from "motion/react"; import { Mail, RotateCcw } from "lucide-react"; import { Button } from "@/components/pixilui/button"; import { ApprovalCard, type ApprovalCardItem, type ApprovalCardStatus } from "@/components/pixilui/approval-card"; import { RunTimeline, type RunTimelineStatus, type RunTimelineStep } from "@/components/pixilui/run-timeline"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } // The first option of each knob is the demo's default; the docs preview starts there. export const knobs = { state: ["play", "waiting", "error", "done"], mode: ["expanded", "compact"], approval: ["auto", "manual"], } as const; type StepCopy = { id: string; title: string; detail: string }; const STEPS: StepCopy[] = [ { id: "pull", title: "Pull stuck refunds from Stripe", detail: "9 refunds pending for more than 72 hours" }, { id: "match", title: "Match refunds to customer accounts", detail: "7 accounts, with 2 duplicate refunds merged" }, { id: "draft", title: "Draft apology emails with a $15 credit", detail: "7 drafts in the support voice, 94 to 118 words each" }, { id: "approve", title: "Get approval to send", detail: "Approved by Dana Whitfield" }, { id: "post", title: "Post the summary to #support-ops", detail: "Posted with links to ENG-4127 and the 7 tickets" }, ]; const ITEMS: ApprovalCardItem[] = [ { label: "Recipients", value: "7 customers" }, { label: "Credits", value: "$105.00", numeric: true }, { label: "Sends from", value: "support@northwind.co" }, ]; type StepState = { status: RunTimelineStatus; startedAt?: number; endedAt?: number }; type Frame = { steps: StepState[]; approval: ApprovalCardStatus }; // Fixed clock for the still states, so durations read the same on every render. const T0 = 1_761_400_000_000; const DURATIONS = [1140, 870, 2260, 4120, 930]; const ended = (index: number): StepState => { const start = T0 + DURATIONS.slice(0, index).reduce((a, b) => a + b, 0); return { status: "done", startedAt: start, endedAt: start + DURATIONS[index]! }; }; const STILL: Record<"waiting" | "error" | "done", Frame> = { waiting: { steps: [ended(0), ended(1), ended(2), { status: "waiting" }, { status: "pending" }], approval: "pending" }, error: { steps: [ended(0), ended(1), { status: "error" }, { status: "pending" }, { status: "pending" }], approval: "pending" }, done: { steps: STEPS.map((_, index) => ended(index)), approval: "approved" }, }; const START: Frame = { steps: STEPS.map(() => ({ status: "pending" })), approval: "pending" }; const STEP_MS = 900; export default function RunTimelineDemo({ state = "play", mode = "expanded", approval = "auto" }: KnobValues) { const reduced = useReducedMotionAfterHydration(); const [run, setRun] = useState(0); const [frame, setFrame] = useState(START); const [finished, setFinished] = useState(false); const playing = state === "play" && !reduced; const combo = `${state}-${approval}-${reduced}`; const [seen, setSeen] = useState(null); if (seen !== combo) { setSeen(combo); setFinished(false); setFrame(playing ? START : STILL[state === "play" ? "done" : state]); } const setStep = (index: number, next: StepState) => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? next : s)) })); // Steps 1 to 3 run at reading speed, then the run waits on a person. useEffect(() => { if (!playing) return; const begin = (index: number) => () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { status: "active", startedAt: Date.now() } : s)) })); const finish = (index: number) => () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { ...s, status: "done", endedAt: Date.now() } : s)) })); const plan: [number, () => void][] = [ [300, begin(0)], [300 + STEP_MS, finish(0)], [300 + STEP_MS, begin(1)], [300 + STEP_MS * 2, finish(1)], [300 + STEP_MS * 2, begin(2)], [300 + STEP_MS * 3, finish(2)], [300 + STEP_MS * 3, () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { status: "waiting", startedAt: Date.now() } : s)) }))], ]; if (approval === "auto") plan.push([300 + STEP_MS * 3 + 2000, () => setFrame((f) => ({ ...f, approval: "approved" }))]); const timers = plan.map(([at, fn]) => setTimeout(fn, at)); return () => timers.forEach(clearTimeout); }, [playing, approval, run]); // Once approved: close the waiting step, then run the last one. const approved = frame.approval === "approved"; const waitingOnApproval = frame.steps[3]!.status === "waiting"; const lastStatus = frame.steps[4]!.status; useEffect(() => { if (!approved || !waitingOnApproval) return; const id = setTimeout( () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { ...s, status: "done", endedAt: Date.now() } : i === 4 ? { status: "active", startedAt: Date.now() } : s)) })), 500, ); return () => clearTimeout(id); }, [approved, waitingOnApproval]); useEffect(() => { if (lastStatus === "active") { const id = setTimeout(() => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 4 ? { ...s, status: "done", endedAt: Date.now() } : s)) })), STEP_MS + 100); return () => clearTimeout(id); } if (lastStatus === "done") { const id = setTimeout(() => setFinished(true), STEP_MS); return () => clearTimeout(id); } }, [lastStatus]); const steps: RunTimelineStep[] = STEPS.map((copy, index) => { const s = frame.steps[index]!; const detail = s.status === "done" ? copy.detail : s.status === "error" ? "Template refund-apology-v2 was archived on Oct 21. Pick another and retry." : undefined; const card = copy.id === "approve" && (s.status === "waiting" || s.status === "done") ? ( } source="Support agent, run 7f2e1c" items={ITEMS} risk="medium" status={frame.approval} receipt="7 emails queued, $105.00 in credits applied" labels={{ approve: "Approve and send" }} onApprove={async () => { await new Promise((resolve) => setTimeout(resolve, 700)); setFrame((f) => ({ ...f, approval: "approved" })); }} onReject={async () => { await new Promise((resolve) => setTimeout(resolve, 500)); setStep(3, { status: "error" }); }} /> ) : undefined; return { id: copy.id, title: copy.title, detail: copy.id === "approve" ? undefined : detail, status: s.status, startedAt: s.startedAt, endedAt: s.endedAt, children: card }; }); return (
{state === "play" && !reduced && finished ? ( ) : null}
); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `steps` (required) | `RunTimelineStep[]` | | The run's steps, in order. Update a step's status and the timeline animates the change. | | `compact` | `boolean` | `false` | Titles only, tighter rhythm. Waiting steps still show their children. | | `onStepSelect` | `(id: string) => void` | | Makes each step a button. Called with the step's id. | | `selectedId` | `string` | | The step shown elsewhere (a detail pane), marked with data-selected. | | `labels` | `Partial` | | Status words, read by screen readers and shown for waiting and failed steps. | Also accepts every native `
    ` attribute, and a ref to it. ## Keyboard - `Tab`: Moves through step buttons when onStepSelect is set, and into a waiting step's approval. - `Enter`: Selects the focused step. - `ArrowDown` + `ArrowUp`: Moves between step buttons when onStepSelect is set. - `Home` + `End`: Jumps to the first or last step button. ## Accessibility - An ordered list, so screen readers say how many steps there are and where each sits. - The active step has aria-current="step"; every step carries its status in text (Done, In progress, Up next, Failed, Needs approval). - Waiting and failed steps also show their status as visible words, not only as a colour or a glyph. - The live timer has role timer, so it is not announced every second. - With onStepSelect, steps are real buttons; selectedId sets aria-pressed. ## Motion - Status changes crossfade the glyph through a 2px blur in 250ms on the swap easing; the done check draws itself. - The connector under a finished step fills top to bottom in 400ms on linear easing, so progress reads segment by segment. - Detail and children open their own height on the morph spring and fade up, pushing the steps below instead of overlapping them. - The active step's timer digits roll up on the snappy spring. - Reduced motion: glyphs and connectors swap instantly, content fades in. ## Responsive - Fills its container; titles and details wrap, timings stay on the title line. - Compact mode drops details and tightens the rhythm for side panels; waiting steps keep their approval. ## Performance - Only the active step's timer re-renders each tick; the list re-renders when steps change. - Connectors animate transform only. ## Notes for AI - Keep one source of truth: map your run events to steps with id, title, status, startedAt and endedAt, and let the component animate the changes. - Write detail as evidence in past tense once a step is done ("9 refunds pending for more than 72 hours"); on error, say what failed and what to do. - Put an approval-card in the children of the waiting step; it stays visible in compact mode. Keep it there after approval so its receipt shows what was approved. - Use onStepSelect with selectedId to open step details in a side pane. - Statuses are pending, active, done, error and waiting; keep at most one active. ## Related - [Approval card](https://pixilui.com/components/approval-card/markdown): An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note. - [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line. - [Thinking state](https://pixilui.com/components/thinking-state/markdown): A quiet line that says what the agent is doing while it works, with a live timer, steps that unfold, and a fold into "Thought for 12s" when it is done. - [Streaming text](https://pixilui.com/components/streaming-text/markdown): Renders a model's answer as it streams: only new words rise into place at reading speed, a caret holds the end, and half-arrived words wait until they are whole. # Streaming text Renders a model's answer as it streams: only new words rise into place at reading speed, a caret holds the end, and half-arrived words wait until they are whole. Tier: free. Type: pattern. Category: ai. Tags: chat, streaming, motion. ## Install ```bash npx shadcn@latest add @pixilui/streaming-text ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/streaming-text.tsx`. npm: `motion`. ## Preview Live preview: https://pixilui.com/components/streaming-text Knobs: - `state`: `play`, `done` - `chunks`: `tokens`, `one big chunk` - `speed`: `50`, `30`, `80` Signature: Uneven chunks arrive but the words rise one by one at reading pace, half-words wait their turn, and the caret leaves when the last word lands. ## Code `components/pixilui/streaming-text.tsx` ```tsx "use client"; import { Fragment, forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type PointerEvent, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { motionTokens, reducedTransition } from "./motion-tokens"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } export type StreamingTextProps = Omit, "children"> & { /** Everything received so far. Pass the growing string on every chunk; only the new words animate. */ text: string; /** More text is on its way: shows the caret and holds back a word that has not finished arriving. */ streaming?: boolean; /** Show a caret at the end while streaming. */ caret?: boolean; /** Milliseconds between words as they reveal. A large chunk speeds up until the reveal is back within about 1.5s of the stream. */ speed?: number; /** Pause the reveal while a mouse rests on the text, so people can read; it catches up on leave. */ pauseOnHover?: boolean; /** Render text between backticks as inline code. */ inlineCode?: boolean; /** Called once every word has revealed after streaming ends. */ onRevealComplete?: () => void; }; type Token = { /** The word, without the whitespace after it. */ word: string; /** Whitespace after the word (may hold newlines). Rendered as plain text, never animated. */ space: string; code: boolean; }; type Reveal = { tokens: Token[]; /** Tokens on screen. */ released: number; /** Tokens that have finished animating and render as plain text. */ settled: number; /** The tokens released in the last tick, spread across it. */ batchFrom: number; batchSize: number; /** Words per tick while catching up. Held steady until the backlog is back inside the window. */ rate: number; }; /** A large chunk speeds the reveal up until it is back within this many milliseconds of words. */ const MAX_LAG_MS = 1500; function tokenize(text: string, inlineCode: boolean): Token[] { const tokens: Token[] = []; const parts = inlineCode ? text.split("`") : [text]; parts.forEach((part, index) => { if (!part) return; const code = inlineCode && index % 2 === 1; const lead = /^\s+/.exec(part); if (lead) tokens.push({ word: "", space: lead[0], code }); for (const match of part.slice(lead ? lead[0].length : 0).matchAll(/(\S+)(\s*)/g)) { tokens.push({ word: match[1]!, space: match[2]!, code }); } }); return tokens; } /** How many leading tokens two token lists share (words and code only; trailing space may grow). */ function sharedPrefix(a: Token[], b: Token[]) { const max = Math.min(a.length, b.length); let i = 0; while (i < max && a[i]!.word === b[i]!.word && a[i]!.code === b[i]!.code) i++; return i; } /** Settled tokens as a few plain runs: one text node per run, however long the text gets. */ function runs(tokens: Token[]) { const out: { text: string; code: boolean }[] = []; for (const token of tokens) { const last = out[out.length - 1]; if (last && last.code === token.code) last.text += token.word + token.space; else out.push({ text: token.word + token.space, code: token.code }); } return out; } const codeClass = "rounded-control bg-surface-muted px-1 py-px font-mono"; const codeStyle = { fontSize: "0.9em" }; export const StreamingText = forwardRef(function StreamingText( { text, streaming = false, caret = true, speed = 50, pauseOnHover = false, inlineCode = true, onRevealComplete, className, onPointerEnter, onPointerLeave, ...rest }, ref, ) { const reduced = useReducedMotionAfterHydration(); const tokens = useMemo(() => tokenize(text, inlineCode), [text, inlineCode]); const last = tokens[tokens.length - 1]; // A word without whitespace after it may still be arriving ("Searc" then "hed"): hold it back. const available = streaming && last && last.space === "" ? tokens.length - 1 : tokens.length; const [state, setState] = useState(() => ({ tokens, released: 0, settled: 0, batchFrom: 0, batchSize: 0, rate: 1 })); const [paused, setPaused] = useState(false); // Append-only diff, during render: keep what still matches, rewind past anything that changed. let reveal = state; if (state.tokens !== tokens) { const keep = sharedPrefix(state.tokens, tokens); reveal = { tokens, released: Math.min(state.released, keep), settled: Math.min(state.settled, keep), batchFrom: state.batchFrom, batchSize: state.batchSize, rate: state.rate, }; setState(reveal); } const released = reduced ? available : Math.min(reveal.released, tokens.length); const settled = reduced ? available : Math.min(reveal.settled, released); const hasBacklog = !reduced && reveal.released < available; const availableRef = useRef(available); const generation = useRef(0); useLayoutEffect(() => { availableRef.current = available; }, [available]); useLayoutEffect(() => { generation.current += 1; }, [tokens]); // The clock: one tick per `speed` ms while there is a backlog. New chunks never restart it. useEffect(() => { if (!hasBacklog || paused) return; const id = setTimeout(() => { setState((prev) => { const backlog = availableRef.current - prev.released; if (backlog <= 0) return prev; // Inside the window: one word per tick. Beyond it: a steady rate that drains the excess in about one window. const inWindow = Math.max(1, Math.floor(MAX_LAG_MS / speed)); const rate = backlog <= inWindow ? 1 : Math.max(prev.rate, Math.ceil(backlog / inWindow)); const size = Math.min(backlog, rate); return { ...prev, released: prev.released + size, batchFrom: prev.released, batchSize: size, rate }; }); }, speed); return () => clearTimeout(id); }, [hasBacklog, paused, reveal.released, speed]); // Words settle into plain text once their rise has finished, so the DOM stays a few nodes deep. const settleTimers = useRef(new Set>()); useEffect(() => { if (reduced || reveal.released <= reveal.settled) return; const target = reveal.released; const gen = generation.current; const timers = settleTimers.current; const id = setTimeout(() => { timers.delete(id); if (gen !== generation.current) return; setState((prev) => ({ ...prev, settled: Math.max(prev.settled, Math.min(target, prev.released)) })); }, motionTokens.duration.fast * 1000 + speed); timers.add(id); }, [reduced, reveal.released, reveal.settled, speed]); useEffect(() => { const timers = settleTimers.current; return () => timers.forEach(clearTimeout); }, []); const complete = !streaming && released >= tokens.length; const completeRef = useRef(complete); useEffect(() => { if (complete && !completeRef.current) onRevealComplete?.(); completeRef.current = complete; }, [complete, onRevealComplete]); const plain = useMemo(() => runs(tokens.slice(0, settled)), [tokens, settled]); const tail = tokens.slice(settled, released); const handleEnter = (event: PointerEvent) => { onPointerEnter?.(event); if (pauseOnHover && event.pointerType === "mouse") setPaused(true); }; const handleLeave = (event: PointerEvent) => { onPointerLeave?.(event); setPaused(false); }; const spread = reveal.batchSize > 1 ? speed / reveal.batchSize / 1000 : 0; return (
    {plain.map((run, index) => run.code ? ( {run.text} ) : ( {run.text} ), )} {tail.map((token, offset) => { const index = settled + offset; const inBatch = index - reveal.batchFrom; const delay = inBatch > 0 && inBatch < reveal.batchSize ? inBatch * spread : 0; return ( {token.word ? ( {token.word} ) : null} {token.space} ); })} {caret && !complete ? (
    ); }); ``` ## Usage When to use: - Showing a model's reply token by token in a chat, a summary panel or an inline suggestion. - Any text that arrives in chunks of uneven size and should read as one calm stream. When not to use: - Markdown with headings, lists and code blocks: render it with your markdown renderer and stream into that. - Static copy that is already complete: render it as text. - Status while the model has not started answering: use thinking-state. ```tsx import { useEffect, useState, useSyncExternalStore } from "react"; import { useReducedMotion } from "motion/react"; import { RotateCcw } from "lucide-react"; import { Button } from "@/components/pixilui/button"; import { StreamingText } from "@/components/pixilui/streaming-text"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } // The first option of each knob is the demo's default; the docs preview starts there. export const knobs = { state: ["play", "done"], chunks: ["tokens", "one big chunk"], speed: [50, 30, 80], } as const; const ANSWER = "Checkout errors started at 09:42 UTC, four minutes after deploy `4f3a9c1` shipped new retry logic in `payments-client`. " + "Every failed call to Stripe retried three times with no backoff, so request volume doubled and 1,318 checkouts timed out " + "before Priya Nair rolled the deploy back at 09:58.\n\n" + "About $41,300 in carts were abandoned, mostly from 212 merchants on the EU cluster. I opened ENG-4127 to add jittered " + "backoff and a test that fails on more than one retry per second."; // Uneven chunk sizes (in characters) and gaps, the way a model streams: words often split mid-way. const SIZES = [7, 3, 12, 5, 18, 2, 9, 4, 14, 6, 11, 8]; const GAPS = [70, 45, 110, 60, 90, 40, 130]; function chunkPlan(bigChunk: boolean) { if (bigChunk) { const cut = ANSWER.indexOf("\n\n") + 2; return [ { at: 400, end: cut }, { at: 2600, end: ANSWER.length }, ]; } const plan: { at: number; end: number }[] = []; let end = 0; let at = 400; for (let i = 0; end < ANSWER.length; i++) { end = Math.min(ANSWER.length, end + SIZES[i % SIZES.length]!); at += GAPS[i % GAPS.length]!; plan.push({ at, end }); } return plan; } export default function StreamingTextDemo({ state = "play", chunks = "tokens", speed = 50 }: KnobValues) { const reduced = useReducedMotionAfterHydration(); const [run, setRun] = useState(0); const [received, setReceived] = useState(0); const [finished, setFinished] = useState(false); const [revealed, setRevealed] = useState(false); const playing = state === "play" && !reduced; // A knob change starts the stream again. const combo = `${state}-${chunks}-${speed}`; const [seen, setSeen] = useState(combo); if (seen !== combo) { setSeen(combo); setReceived(0); setFinished(false); setRevealed(false); } useEffect(() => { if (!playing) return; const plan = chunkPlan(chunks === "one big chunk"); const timers = plan.map(({ at, end }, index) => setTimeout(() => { setReceived(end); if (index === plan.length - 1) setFinished(true); }, at), ); return () => timers.forEach(clearTimeout); }, [playing, chunks, run]); const text = playing ? ANSWER.slice(0, received) : ANSWER; const streaming = playing && !finished; return (

    Why did checkout errors spike this morning?

    setRevealed(true)} className="min-h-48" />
    {playing && revealed ? ( ) : null}
    ); } ``` ## Examples None. ## API | Prop | Type | Default | Description | | --- | --- | --- | --- | | `text` (required) | `string` | | Everything received so far. Pass the growing string on every chunk; only the new words animate. | | `streaming` | `boolean` | `false` | More text is on its way: shows the caret and holds back a word that has not finished arriving. | | `caret` | `boolean` | `true` | Show a caret at the end while streaming. | | `speed` | `number` | `50` | Milliseconds between words as they reveal. A large chunk speeds up until the reveal is back within about 1.5s of the stream. | | `pauseOnHover` | `boolean` | `false` | Pause the reveal while a mouse rests on the text, so people can read; it catches up on leave. | | `inlineCode` | `boolean` | `true` | Render text between backticks as inline code. | | `onRevealComplete` | `() => void` | | Called once every word has revealed after streaming ends. | Also accepts every native `
    ` attribute, and a ref to it. ## Keyboard - `Tab`: Nothing to focus; the text is read as it is, and links or code inside it keep their own behaviour. ## Accessibility - The root sets aria-busy while text is still arriving, so assistive tech can wait for the whole answer. - It is not a live region on its own: put it inside your message list's role="log" so new messages are announced once, not word by word. - Words are kept whole in the DOM and the caret is aria-hidden, so the text reads normally at any moment. ## Motion - Only new words animate: each rises 0.3em out of a 4px blur in 250ms on the enter easing. - Words reveal about 50ms apart (speed). A large chunk speeds up until the reveal is back within about 1.5s of the stream. - A word whose end has not arrived yet is held back, so a word never animates in two halves. - The caret fades out in 150ms when the stream ends and every word is shown. - Reduced motion: chunks appear instantly, no rise, no blur. ## Responsive - Wraps like normal text and keeps newlines (white-space: pre-wrap); long words and URLs break inside the line. - Inherits the container's width; default size is text-16, override with className. ## Performance - Text is diffed per chunk (append-only); words that finished animating collapse back into a few text nodes, so a long answer stays a handful of DOM nodes. - Animation runs on transform, opacity and filter only, so no layout is read per word. - One timer drives the reveal; new chunks never restart it. ## Notes for AI - Pass the whole text received so far on every chunk, not the delta. If the text changes in the middle (a regenerate), the component rewinds to the shared prefix. - Set streaming to true while chunks arrive and false when the stream closes; the caret drops once the last word is shown. - Text between backticks renders as inline code; turn it off with inlineCode={false}. - Use pauseOnHover on long answers so a reader can stop the text under their mouse. - onRevealComplete fires once the last word is on screen, useful for showing follow-up actions after the answer, not before. ## Related - [Thinking state](https://pixilui.com/components/thinking-state/markdown): A quiet line that says what the agent is doing while it works, with a live timer, steps that unfold, and a fold into "Thought for 12s" when it is done. - [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line. - [Run timeline](https://pixilui.com/components/run-timeline/markdown): The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval. # Thinking state A quiet line that says what the agent is doing while it works, with a live timer, steps that unfold, and a fold into "Thought for 12s" when it is done. Tier: free. Type: pattern. Category: ai. Tags: agent, run, streaming, motion. ## Install ```bash npx shadcn@latest add @pixilui/thinking-state ``` One-time setup: add the registry to `components.json`: ```json { "registries": { "@pixilui": "https://pixilui.com/r/{name}.json" } } ``` Files: `components/pixilui/thinking-state.tsx`. npm: `motion`. ## Preview Live preview: https://pixilui.com/components/thinking-state Knobs: - `state`: `play`, `working`, `done` - `steps`: `with steps`, `line only` Signature: The shimmer runs across the line while the timer digits roll, then the steps fold away and the line becomes "Thought for 4s". ## Code `components/pixilui/thinking-state.tsx` ```tsx "use client"; import { forwardRef, useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; import { cn } from "./lib/utils"; import { useMorphSize } from "./lib/use-morph-size"; import { motionTokens, reducedTransition } from "./motion-tokens"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } export type ThinkingStateStepStatus = "pending" | "active" | "done"; export type ThinkingStateStep = { /** Stable key for the step. */ id: string; /** What the agent is doing or did, in plain words: "Reading the incident timeline". */ text: string; /** Pending steps are muted, the active one reads in full colour, done steps get a drawn check. */ status: ThinkingStateStepStatus; }; export type ThinkingStateProps = Omit, "children"> & { /** What the agent is doing right now. Changing it crossfades the line. */ label?: string; /** The steps behind the line, shown when expanded. Leave it out for a single quiet line. */ steps?: ThinkingStateStep[]; /** When the work started (epoch ms). Drives a live timer that stops when done. */ startedAt?: number; /** Elapsed time you track yourself (ms). Wins over startedAt; pass it for a finished run you render later. */ elapsedMs?: number; /** Controlled: whether the steps are unfolded. */ expanded?: boolean; /** Starting fold when uncontrolled. */ defaultExpanded?: boolean; /** Called when the person toggles the steps, and with false when the work finishes. */ onExpandedChange?: (expanded: boolean) => void; /** The work is finished: the shimmer stops, the timer freezes and the line folds into "Thought for 12s". */ done?: boolean; /** The finished line. A function receives the elapsed time in ms (null when there is no timer). */ doneLabel?: string | ((elapsedMs: number | null) => string); /** Show the live timer beside the label. */ showTimer?: boolean; /** The agent's mark, shown small before the label. */ icon?: ReactNode; }; /** One pass of the shimmer across the label: 1.92s, linear. */ const SWEEP = motionTokens.duration.considered * 4; const TICK_MS = 250; /** "12s", "1m 04s". */ function formatThinkingTime(ms: number) { const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } const defaultDoneLabel = (ms: number | null) => (ms === null ? "Thought it through" : `Thought for ${formatThinkingTime(Math.max(ms, 1000))}`); function useElapsed(startedAt: number | undefined, running: boolean) { const [now, setNow] = useState(null); useEffect(() => { if (startedAt === undefined || !running) return; const tick = () => setNow(Date.now()); tick(); const id = setInterval(tick, TICK_MS); return () => clearInterval(id); }, [startedAt, running]); return startedAt === undefined || now === null ? null : Math.max(0, now - startedAt); } /** Digits keyed by place that roll up as the time grows. */ function ThinkingStateTicker({ value, reduced }: { value: string; reduced: boolean }) { const chars = value.split(""); return ( {chars.map((char, index) => { const place = chars.length - index; if (reduced || !/\d/.test(char)) { return ( ); } return ( ); })} ); } function ThinkingStateGlyph({ status, reduced }: { status: ThinkingStateStepStatus; reduced: boolean }) { if (status === "done") { return ( ); } return (