# WunderUI (full) > Everything an AI agent needs to build with WunderUI in one file: install, rules, tokens, > motion, copy, skills, every component with its props and usage, templates and starters. # WunderUI — DESIGN.md > Design context for AI coding agents. Paste this into your prompt, or let your > agent pull it from https://wunderui.com/DESIGN.md. > Generated from the WunderUI source on 2026-10-23 · @wunderui/react@0.3.0 WunderUI is a React component library and design system: 222 documented components in 6 groups, built on Base UI primitives and Tailwind CSS v4. It covers the parts most kits skip — data grid, kanban, charts, an application shell and a full AI chat set. ## Install Core and Pro licence holders: the full library from the private GitHub repository wunder-ui/wunderui-core (Polar invites your GitHub account after the purchase). The npm package of the same name is WunderUI Free (free components only). Accept the GitHub invitation, `git clone https://github.com/wunder-ui/wunderui-core`, run `npm install && npm run build` in it once, then in your project: `mkdir .wunderui`, `npm pack /packages/react --pack-destination .wunderui` and `npm install ./.wunderui/`. The wunderui-setup agent skill does all of it. `npm install @wunderui/react` installs WunderUI Free from npm: the free components only (no charts, data tables, AI or agent components). Core and Pro need the full library from the private wunderui-core repository — never install the npm package for them. Free plan: the 83 free components are MIT source at https://github.com/wunder-ui/wunderui — add them with `npx wunderui-cli add ` (files, dependencies and styles), install the Free package with `npm install @wunderui/react`, or copy components/, lib/ and styles/wunderui.css (each free component in design.json has a sourceRaw URL). Core and Pro components come with a plan (https://wunderui.com/#pricing). Then wire up the styles and import: ```css /* your Tailwind entry point */ @import "tailwindcss"; @import "@wunderui/react/styles.css"; @source "../node_modules/@wunderui/react/dist"; ``` ```tsx import { Button, Card } from "@wunderui/react" ``` ## Rules for agents 1. **Never hardcode colours.** Every value is a CSS variable. Use the Tailwind utility that maps to it (`bg-card`, `text-text-secondary`, `border-border`), not a hex. 2. **Theme by overriding tokens**, not by restyling components. Setting `--primary` on any wrapper re-themes everything inside it. 3. **Dark mode is a `.dark` class on ``.** `ThemeProvider` (next-themes) handles it. Never write your own dark colours. 4. **The package is client code.** It ships with a `"use client"` banner, so you can import it inside React Server Components, but its own components run on the client. 5. **Import from the package root**, never from `dist/` or `src/` paths. 6. `cn` is not re-exported — import it from the `cn` package directly. 7. **Prefer a composite over rebuilding one.** Before writing a table, a board, a stat row or a chat bubble by hand, check the task table and the inventory below — it probably exists. 8. **Charts take a `loading` prop**; do not build your own skeleton. 9. **A KPI chart ends on the trend line.** In a metric card, a sparkline or mini chart beside the value sits on the same horizontal line as the bottom of the trend chip — `MetricCard` does this by default for `MetricSparkline`. Never centre it vertically or let it float above the chip. 10. **Motion comes from tokens too.** Duration is `duration-instant|fast|base|slow|deliberate|ambient`, easing is `ease-entrance|exit|move|overshoot|spring`. Never write a raw `duration-300` and never write `ease-in-out` — both are the default every generator reaches for, and both read as mechanical. 11. **Agent UI has its own components.** A run's state, its steps, an approval gate, its spend and its failure each have one — `AgentStatus`, `RunTimeline`, `ApprovalCard`, `CostMeter`, `RunError`. Do not assemble these from badges and divs. ## Do and don't The mistakes that cost the most time, each as a pair. **Don't** ```tsx …
``` **Do** ```tsx ``` Sorting, search, selection and pagination already exist. A hand-written table has none of them. --- **Don't** ```tsx
``` **Do** ```tsx
``` A hex value survives no theme change and breaks in dark mode. --- **Don't** ```tsx
``` **Do** ```tsx
``` The tokens already carry their dark value. Writing `dark:` variants duplicates — and eventually contradicts — the system. --- **Don't** ```tsx import { Button } from "@wunderui/react/dist/index.js" ``` **Do** ```tsx import { Button } from "@wunderui/react" ``` Deep imports break on every release and skip the package's exports map. --- **Don't** ```tsx if (window.confirm("Delete this?")) remove() ``` **Do** ```tsx … ``` A native confirm cannot be styled, cannot be tested, and looks like 1998. --- **Don't** ```tsx
Save
``` **Do** ```tsx ``` A div is not focusable, has no role, and ignores the keyboard. --- **Don't** ```tsx {isLoading ? : …
} ``` **Do** ```tsx {isLoading ? : …
} ``` A skeleton in the content's height keeps the layout still while the data loads; a lone spinner collapses it. --- **Don't** ```tsx transition-all duration-300 ease-in-out ``` **Do** ```tsx transition-[opacity,transform] duration-base ease-entrance ``` 300ms with a symmetric curve is the generated default. 250ms on a decelerating curve is what a considered interface feels like — and `transition-all` animates properties you did not mean to animate. --- **Don't** ```tsx
``` **Do** ```tsx
…
``` `height: auto` is not animatable anywhere outside Chromium, and animating height forces layout on every frame. The 0fr → 1fr grid trick works in every browser and stays on the compositor. --- **Don't** ```tsx data-closed:duration-slow ``` **Do** ```tsx data-closed:duration-fast ``` An exit is faster than an entrance. The user already decided; making them watch the decision play out is the single most common motion mistake. --- **Don't** ```tsx {rows.length === 0 &&

No results

} ``` **Do** ```tsx New invoice} /> ``` An empty state should say why it is empty and what to do next. ## Task → component Look here first. Most requests map to something that already exists. | I need … | Use | | --- | --- | | Sortable, searchable table with row selection | `DataGrid` | | Plain table markup, no behaviour | `Table` and its parts | | Editable cells inside a table | `CellSelect`, `CellSwitch`, `CellSlider`, `CellColorPicker` | | Formatted table cells | `UserCell`, `BadgeCell`, `CurrencyCell`, `ProgressCell`, `TagCell`, `RatingCell` | | Bulk actions for a selection | `ActionBar` | | Drag-and-drop board of columns and cards | `Kanban` | | A single metric with a trend | `StatCard`, grouped in `KPIGroup` | | A percentage change as a small pill | `TrendChip` | | Values over time | `AreaChart` or `LineChart` | | Categories compared | `BarChart` | | Parts of a whole | `PieChart` (donut mode) or `RadialChart` | | Application shell with sidebar and top bar | `AppLayout` + `Sidebar` + `Navbar` | | Detail panel sliding in from the edge | `Sheet` | | Modal for a focused task | `Dialog` | | Confirmation with consequences | `AlertDialog` | | Menu on a button / on right-click | `DropdownMenu` / `ContextMenu` | | Text input that suggests while typing | `Combobox` or `Autocomplete` | | Pick a date | `DatePicker`, or `Calendar` for an inline month | | Form with labels and validation | `Form` + `Field` + `Fieldset` | | Settings row with a toggle | `SelectorItem` + `Switch`, grouped in `SelectorItemGroup` | | Multi-step flow | `Stepper` | | Switch between views, compactly | `Segment`; `Tabs` when the views are panels | | File upload | `DropZone` | | Chat with an assistant | `ChatConversation` + `ChatMessage` + `PromptInput` | | Show what a run is doing right now | `AgentStatus` | | Ask a human to approve an action before it runs | `ApprovalCard` | | The steps of a run, in order, with timings | `RunTimeline` | | Spend against a budget | `CostMeter` | | A run that failed, with retry | `RunError` | | Every agent you own, and its state | `AgentGrid` | | Show the model's reasoning, a tool call, a source | `ChainOfThought`, `ChatTool`, `ChatSource` | | List of past conversations | `ChatListView` | | Nothing to show yet | `EmptyState` | | Something is loading | `Skeleton`; charts take `loading` | | Transient notification | `useToast` with `Toaster`; `Alert` when it should stay | | Search across everything (⌘K) | `Command` | | Chronological events | `Timeline`; `Agenda` for scheduled ones | | Folder and file tree | `FileTree` | | Two panes with a draggable divider | `Resizable` | | Time an interaction | `duration-fast` hover/press and every exit · `duration-base` menus · `duration-slow` dialogs · `duration-deliberate` sheets · `duration-ambient` data bars | | Ease an interaction | `ease-entrance` arriving · `ease-exit` leaving · `ease-move` repositioning · `ease-overshoot` once per screen at most | | Animate something open or closed | `data-open` / `data-closed` variants; height via `grid-rows-[0fr]` → `[1fr]` | ## Tokens Light is `:root`, dark is `.dark`. Same names in both themes. | Token | Light | Dark | | --- | --- | --- | | `--background` | `#FFFFFF` | `#17181A` | | `--bg-page` | `#F9FBFC` | `#17181A` | | `--foreground` | `#000000` | `#FFFFFF` | | `--card` | `#FFFFFF` | `#1C1D20` | | `--card-foreground` | `#000000` | `#FFFFFF` | | `--popover` | `#FFFFFF` | `#1C1D20` | | `--popover-foreground` | `#000000` | `#FFFFFF` | | `--primary` | `var(--brand-600)` | `var(--brand-600)` | | `--primary-foreground` | `#FFFFFF` | `#FFFFFF` | | `--brand-primary` | `var(--brand-600)` | `var(--brand-600)` | | `--brand-primary-foreground` | `#FFFFFF` | `#FFFFFF` | | `--brand-secondary` | `#12AFF0` | `#12AFF0` | | `--brand-secondary-foreground` | `#25272A` | `#25272A` | | `--brand-tertiary` | `#F47690` | `#F47690` | | `--brand-tertiary-foreground` | `#25272A` | `#25272A` | | `--brand-quaternary` | `#FACA4A` | `#FACA4A` | | `--brand-quaternary-foreground` | `#25272A` | `#25272A` | | `--secondary` | `#FFFFFF` | `#1C1D20` | | `--secondary-foreground` | `#000000` | `#FFFFFF` | | `--muted` | `#F1F5F7` | `#323438` | | `--muted-foreground` | `#5A7383` | `#949EAD` | | `--accent` | `#F1F5F7` | `#323438` | | `--accent-foreground` | `#000000` | `#FFFFFF` | | `--destructive` | `#D42A2D` | `#D42A2D` | | `--destructive-foreground` | `#FFFFFF` | `#FFFFFF` | | `--border` | `#F1F5F7` | `#323438` | | `--input` | `var(--border-control)` | `#6E6E70` | | `--code-border` | `var(--light-300)` | `color-mix(in srgb, var(--foreground) 14%, var(--muted))` | | `--border-control` | `var(--light-300)` | `#323438` | | `--ring` | `var(--brand-600)` | `var(--brand-400)` | | `--text-secondary` | `#454648` | `#D1D1D1` | | `--text-tertiary` | `#6E6E70` | `#A2A3A3` | | `--success` | `#1AD598` | `#1AD598` | | `--success-foreground` | `#17181A` | `#17181A` | | `--chart-1` | `var(--brand-600)` | `var(--brand-600)` | | `--chart-2` | `#12AFF0` | `#3CC2F5` | | `--chart-3` | `#A584F3` | `#B79CF6` | | `--chart-4` | `#1AD598` | `#3DDBA6` | | `--chart-5` | `#A2A3A3` | `#6E7078` | | `--sidebar` | `#F9FBFC` | `#17181A` | | `--sidebar-foreground` | `#000000` | `#FFFFFF` | | `--sidebar-primary` | `var(--brand-600)` | `var(--brand-600)` | | `--sidebar-primary-foreground` | `#FFFFFF` | `#FFFFFF` | | `--sidebar-accent` | `#F1F5F7` | `#323438` | | `--sidebar-accent-foreground` | `#000000` | `#FFFFFF` | | `--sidebar-border` | `#F1F5F7` | `#323438` | | `--sidebar-ring` | `var(--brand-600)` | `var(--brand-400)` | | `--text-link` | `var(--brand-600)` | `var(--brand-400)` | | `--text-success` | `#0C7E5A` | `#76E6C1` | | `--text-error` | `#D42A2D` | `#F8ADBC` | | `--text-warning` | `#64511E` | `#FCDF92` | | `--inverse` | `#000000` | `#FFFFFF` | | `--text-error-inverse` | `#F8ADBC` | `#D42A2D` | | `--text-inverse` | `#FFFFFF` | `#000000` | | `--subtle` | `#F1F5F7` | `#25272A` | | `--tint-indigo` | `var(--brand-50)` | `var(--brand-900)` | | `--tint-blue` | `#E7F7FE` | `#074660` | | `--tint-purple` | `#F6F3FE` | `#423561` | | `--tint-pink` | `#FFF0F8` | `#662B4A` | | `--tint-red` | `#FEF1F4` | `#622F3A` | | `--tint-orange` | `#FEF0ED` | `#61281E` | | `--tint-yellow` | `#FFFAED` | `#64511E` | | `--tint-green` | `#E8FBF5` | `#0A553D` | | `--tint-alternative` | `#F1F0F3` | `#2C2C36` | | `--tint-text-indigo` | `var(--brand-800)` | `var(--brand-300)` | | `--tint-text-blue` | `#0B6990` | `#A0DFF9` | | `--tint-text-purple` | `#634F92` | `#DBCEFA` | | `--tint-text-pink` | `#984070` | `#FFC4E3` | | `--tint-text-red` | `#924756` | `#FBC8D3` | | `--tint-text-orange` | `#923D2C` | `#FAC1B7` | | `--tint-text-yellow` | `#64511E` | `#FDEAB7` | | `--tint-text-green` | `#10805B` | `#A3EED6` | | `--tint-text-alternative` | `#424150` | `#C5C5CF` | | `--brand-quinary` | `#A584F3` | `#A584F3` | | `--brand-senary` | `#FE6BBA` | `#FE6BBA` | | `--brand-septenary` | `#F3654A` | `#F3654A` | | `--brand-octonary` | `#6E6D86` | `#6E6D86` | | `--effect-shadow` | `rgb(0 0 0 / 0.08)` | `rgb(0 0 0 / 0.08)` | | `--effect-shadow-2` | `rgb(16 24 40 / 0.05)` | `rgb(16 24 40 / 0.05)` | | `--effect-shadow-3` | `rgb(0 0 0 / 0.05)` | `rgb(0 0 0 / 0.05)` | | `--gradient-start` | `#494F60` | `#494F60` | | `--gradient-end` | `#0B0C33` | `#0B0C33` | | `--gradient-start-2` | `#777FA1` | `#777FA1` | | `--gradient-end-2` | `#1F2059` | `#1F2059` | | `--gradient-start-3` | `#6F74F5` | `#6F74F5` | | `--brand-50` | `#F4F5FF` | `#F4F5FF` | | `--brand-100` | `var(--indigo-100)` | `var(--indigo-100)` | | `--brand-200` | `var(--indigo-200)` | `var(--indigo-200)` | | `--brand-300` | `var(--indigo-300)` | `var(--indigo-300)` | | `--brand-400` | `var(--indigo-400)` | `var(--indigo-400)` | | `--brand-500` | `var(--indigo-500)` | `var(--indigo-500)` | | `--brand-600` | `var(--indigo-600)` | `var(--indigo-600)` | | `--brand-700` | `var(--indigo-700)` | `var(--indigo-700)` | | `--brand-800` | `var(--indigo-800)` | `var(--indigo-800)` | | `--brand-900` | `var(--indigo-900)` | `var(--indigo-900)` | | `--brand-1000` | `var(--indigo-1000)` | `var(--indigo-1000)` | Figma names are also available as classes: `bg-bg-page`, `bg-bg-surface`, `bg-bg-subtle`, `bg-bg-inverse`, `text-text-primary`, `text-text-muted`, `bg-error`. ### Tints One mode-aware pair per hue (indigo, blue, purple, pink, red, orange, yellow, green, alternative): `bg-tint-` is the soft surface, `text-tint-text-` the AA text colour on it. Solid badges use `var(--tint-)` as text colour on the 600 fill. ### Palette (primitives) Mode-independent hue scales from the Figma Primitives collection, exposed as `bg--` / `text--` (they replace Tailwind's palette for these names). | Token | Value | | --- | --- | | `--dark-100` | `#E8E8E8` | | `--dark-200` | `#D1D1D1` | | `--dark-300` | `#A2A3A3` | | `--dark-400` | `#6E6E70` | | `--dark-500` | `#454648` | | `--dark-600` | `#17181A` | | `--dark-700` | `#121315` | | `--dark-800` | `#0E0E10` | | `--dark-900` | `#090A0A` | | `--dark-1000` | `#050505` | | `--light-100` | `#FFFFFF` | | `--light-200` | `#F1F5F7` | | `--light-300` | `#D9E1E7` | | `--light-400` | `#B3C5D4` | | `--light-500` | `#99B2C6` | | `--light-600` | `#809FB8` | | `--light-700` | `#5A7383` | | `--light-800` | `#4D5F6E` | | `--light-900` | `#33404A` | | `--light-1000` | `#1A2025` | | `--blue-100` | `#E7F7FE` | | `--blue-200` | `#D0EFFC` | | `--blue-300` | `#A0DFF9` | | `--blue-400` | `#71CFF6` | | `--blue-500` | `#41BFF3` | | `--blue-600` | `#12AFF0` | | `--blue-700` | `#0E8CC0` | | `--blue-800` | `#0B6990` | | `--blue-900` | `#074660` | | `--blue-1000` | `#042330` | | `--indigo-100` | `#EEEFFE` | | `--indigo-200` | `#DDDEFD` | | `--indigo-300` | `#BBBEFA` | | `--indigo-400` | `#999DF8` | | `--indigo-500` | `#777DF5` | | `--indigo-600` | `#555CF3` | | `--indigo-700` | `#444AC2` | | `--indigo-800` | `#333792` | | `--indigo-900` | `#222561` | | `--indigo-1000` | `#111231` | | `--purple-100` | `#F6F3FE` | | `--purple-200` | `#EDE6FD` | | `--purple-300` | `#DBCEFA` | | `--purple-400` | `#C9B5F8` | | `--purple-500` | `#B79DF5` | | `--purple-600` | `#A584F3` | | `--purple-700` | `#846AC2` | | `--purple-800` | `#634F92` | | `--purple-900` | `#423561` | | `--purple-1000` | `#211A31` | | `--pink-100` | `#FFF0F8` | | `--pink-200` | `#FFE1F1` | | `--pink-300` | `#FFC4E3` | | `--pink-400` | `#FEA6D6` | | `--pink-500` | `#FE89C8` | | `--pink-600` | `#FE6BBA` | | `--pink-700` | `#CB5695` | | `--pink-800` | `#984070` | | `--pink-900` | `#662B4A` | | `--pink-1000` | `#331525` | | `--red-100` | `#FEF1F4` | | `--red-200` | `#FDE4E9` | | `--red-300` | `#FBC8D3` | | `--red-400` | `#F8ADBC` | | `--red-500` | `#F691A6` | | `--red-600` | `#F47690` | | `--red-700` | `#C35E73` | | `--red-800` | `#924756` | | `--red-900` | `#622F3A` | | `--red-1000` | `#31181D` | | `--orange-100` | `#FEF0ED` | | `--orange-200` | `#FDE0DB` | | `--orange-300` | `#FAC1B7` | | `--orange-400` | `#F8A392` | | `--orange-500` | `#F5846E` | | `--orange-600` | `#F3654A` | | `--orange-700` | `#C2513B` | | `--orange-800` | `#923D2C` | | `--orange-900` | `#61281E` | | `--orange-1000` | `#31140F` | | `--yellow-100` | `#FFFAED` | | `--yellow-200` | `#FEF4DB` | | `--yellow-300` | `#FDEAB7` | | `--yellow-400` | `#FCDF92` | | `--yellow-500` | `#FBD56E` | | `--yellow-600` | `#FACA4A` | | `--yellow-700` | `#C8A23B` | | `--yellow-800` | `#96792C` | | `--yellow-900` | `#64511E` | | `--yellow-1000` | `#32280F` | | `--green-100` | `#E8FBF5` | | `--green-200` | `#D1F7EA` | | `--green-300` | `#A3EED6` | | `--green-400` | `#76E6C1` | | `--green-500` | `#48DDAD` | | `--green-600` | `#1AD598` | | `--green-700` | `#15AA7A` | | `--green-800` | `#10805B` | | `--green-900` | `#0A553D` | | `--green-1000` | `#052B1E` | | `--alternative-100` | `#F1F0F3` | | `--alternative-200` | `#E2E2E7` | | `--alternative-300` | `#C5C5CF` | | `--alternative-400` | `#A8A7B6` | | `--alternative-500` | `#8B8A9E` | | `--alternative-600` | `#6E6D86` | | `--alternative-700` | `#58576B` | | `--alternative-800` | `#424150` | | `--alternative-900` | `#2C2C36` | | `--alternative-1000` | `#16161B` | | `--neutral-black` | `#000000` | | `--neutral-white` | `#FFFFFF` | | `--neutral-dark-secondary` | `#1C1D20` | | `--neutral-dark-content` | `#25272A` | | `--neutral-dark-borders` | `#323438` | | `--neutral-dark-grey` | `#384455` | | `--neutral-light-background` | `#F9FBFC` | | `--neutral-alternative-secondary` | `#ACB3C7` | | `--neutral-error-base` | `#D42A2D` | | `--neutral-dark-muted` | `#949EAD` | ### Gradients Figma `Gradient/` paint styles as `bg-gradient-` (blue, yellow, red, indigo, purple, pink, orange, green, alternative), plus `bg-gradient-dark` (gradient/start → end), `bg-gradient-slate` (start-2 → end-2) and `bg-gradient-primary` (start-3 → brand-primary). ### Effects Figma Shadow styles on the Tailwind scale: `shadow-xs` Small (0 1 2 / 5%), `shadow-sm` Regular (0 2 4 / 8%), `shadow-md` Medium (0 4 8 / 10%), `shadow-lg` Large (0 8 16 / 12%), `shadow-xl` XLarge (0 16 32 -4 / 16%). Shadow colours: `--effect-shadow`, `--effect-shadow-2`, `--effect-shadow-3`. ### Radius | Token | Value | | --- | --- | | `--radius` | `0.75rem` | ### Typography Font family comes from the host app through `--font-sans`; WunderUI maps `--font-heading` to the same stack. The documentation site uses Inter. ### Density Compact is the default: controls are 32, 36, 40, 44 and 48 px (`sm` to `2xl`), one scale for buttons and fields, text stays 14 px. `data-density="comfortable"` on `` or any ancestor restores the roomier sizes (36 to 56 px); it nests in both directions. In code: ``, `DensityProvider` and `useDensity()`. Do not hard-code control heights (`h-9`, `h-11`) — use the component sizes so density keeps working. Portals (menus, dialogs) read the page's density. ## Motion Motion is tokenised like everything else. Duration and easing are never written as raw values — a hand-typed `300ms` with `ease-in-out` is the default every generator produces, and it is why generated interfaces feel generic. ### Duration Tailwind v4 has no `--duration-*` theme namespace, so WunderUI ships the scale as named utilities defined with `@utility`. Each one sets `--tw-duration` as well, which is what `tw-animate-css` reads — so `data-open:duration-base` works on an `animate-in` the same way it works on a `transition`. | Token | Value | Reach for it when | | --- | --- | --- | | `duration-instant` | `80ms` | Tooltip dismiss, press release. Barely reads as motion. | | `duration-fast` | `150ms` | The default exit. Hover, focus ring, checkbox, switch track. | | `duration-base` | `250ms` | Dropdown, popover, context menu, backdrop, collapse. | | `duration-slow` | `350ms` | Dialog, toast, switch thumb — a surface arriving. | | `duration-deliberate` | `450ms` | Sheet and full-width panel. Rare. | | `duration-ambient` | `700ms` | Progress and meter fills. Data moving, not chrome. | ### Easing `ease-*` is a Tailwind v4 namespace, so these are utilities: `ease-entrance`, `ease-exit`, `ease-move`, `ease-overshoot`. | Utility | Curve | Reach for it when | | --- | --- | --- | | `ease-entrance` | `cubic-bezier(0.22, 1, 0.36, 1)` | Anything arriving. Fast start, long settle. The house curve. | | `ease-exit` | `cubic-bezier(0.4, 0, 1, 1)` | Anything leaving. Accelerates away. | | `ease-move` | `cubic-bezier(0.65, 0, 0.35, 1)` | Anything repositioning while staying on screen. | | `ease-overshoot` | `cubic-bezier(0.34, 1.35, 0.64, 1)` | Dialog, popover, switch. At most one per screen. | | `ease-spring` | `linear(0, 0.35 9%, 0.78 19%, 1.04 27%, 1.1 32%, 1.03 41%, 0.98 50%, 1.01 62%, 1)` | A sampled spring: one soft overshoot, then it settles. Something physical landing — a thumb, an arrow, a released sheet. Never on a data value. | ### Travel | Token | Value | Reach for it when | | --- | --- | --- | | `--travel-xs` | `2px` | Tooltip — stays glued to its anchor. | | `--travel-sm` | `4px` | Popover, dropdown, context menu. | | `--travel-md` | `8px` | Dialog, toast, inline panel. | | `--travel-lg` | `16px` | Section reveal on scroll. | | `--scale-press` | `0.97` | Pressed button. | | `--scale-enter` | `0.96` | Dialog arriving. | ### Rules 1. **Duration and easing are class names, not numbers.** `duration-base`, `ease-entrance`. Tailwind has no `--duration-*` theme namespace, so WunderUI ships these as `@utility` rules that also set `--tw-duration` — which means they work on `animate-in` / `animate-out` too. 2. **Only `transform` and `opacity`.** Everything else goes through layout or paint. Height animates via `grid-template-rows: 0fr → 1fr`, never via `height`. 3. **Entrances decelerate, exits accelerate.** `ease-entrance` in, `ease-exit` out — and the exit is one step shorter on the duration scale, never the entrance duration reversed. 4. **Never fade in the LCP element.** An element at `opacity: 0` is not an LCP candidate, so a 600ms hero fade adds 600ms to your LCP. Render headline copy at full opacity; animate `transform` only. 5. **Restrain the travel.** Anything anchored to a trigger moves `--travel-xs` or `--travel-sm`. A menu that slides 20px looks like it fell in. 6. **One overshoot per screen.** `ease-overshoot` is punctuation. Two of them on one view and the interface reads as a toy. Never on a data value: a bar, gauge or bubble that swings past its number shows a wrong number for a moment. 7. **Reduced motion is opt-in, not a kill switch.** Wrap the animation in `@media (prefers-reduced-motion: no-preference)` so the still state is the default and you cannot forget a selector. Reduced means calmer, not nothing: drop transforms and travel, keep opacity and colour changes (rule 15). 8. **Never `transition-all`.** Name the properties — `transition-[opacity,transform]` — or use bare `transition`, which covers colour, shadow, transform and opacity but no layout property. `transition-all` animates things you did not mean to animate, including on first paint. 9. **Loading keeps its height.** A skeleton occupies the same box as the content it stands in for, and crossfades — it never collapses and reflows. 10. **A looping ambient animation is the one place `ease-in-out` belongs.** A shimmer that runs forever has no entrance and no exit, so symmetry is correct there and nowhere else. 11. **Animate by frequency.** Keyboard-driven and 100+/day actions (command palette, shortcuts, moving between tooltips) get no motion; tens a day get a short fade; rare moments may delight. Tooltips skip their entrance when another one was just open (`data-open:data-instant:animate-none`). 12. **Anchored surfaces grow from their trigger.** Popover, dropdown, tooltip, hover card and select use `origin-(--transform-origin)`. Never start from `scale(0)` — start at 0.92–0.97 with opacity. Centred dialogs are exempt. 13. **Toggles stay interruptible.** Animate switches, checkboxes, tabs and other repeated state changes with transitions — they retarget from wherever they are — never with keyframes bound to the state, which restart from zero. An overshoot transition (`transition-transform duration-base ease-overshoot`) still swings past the end. 14. **Hover only where hover exists.** Tailwind v4's `hover:` is already gated to `(hover: hover)`; hand-written `:hover` in CSS goes inside `@media (hover: hover) and (pointer: fine)`, so a tap on a phone does not leave a hover state stuck. 15. **Reduced motion keeps the fade.** WunderUI's global rule zeroes the scale, slide, spin and blur of every `animate-in` / `animate-out` and every transform transition, and keeps opacity and colour — so do not add `motion-reduce:transition-none` or `motion-reduce:animate-none` to a fade. ### Motion recipes #### Overlay enter and exit ```tsx /* Dialog, sheet, popover, dropdown, context menu — one pattern for all of them. Base UI exposes data-open / data-closed; tw-animate-css turns them into keyframes. */ ``` #### Collapse without a fixed height ```tsx
{children}
``` #### Press feedback ```tsx ``` ```ts function Button({ className, variant, shape, size, ...props }: Button.Props & VariantProps): React.JSX.Element; ``` | Prop | Type | Default | | --- | --- | --- | | `className` | `string \| ((state: State) => string \| undefined)` | — | | `focusableWhenDisabled` | `boolean` | `false` | | `nativeButton` | `boolean` | `true` | | `render` | `React.ReactElement \| ComponentRenderFn` | — | | `shape` | `"rounded" \| "pill"` | `"rounded"` | | `size` | `"sm" \| "lg" \| "xl" \| "md" \| "2xl"` | `"md"` | | `style` | `React.CSSProperties \| ((state: State) => React.CSSProperties \| undefined)` | — | | `variant` | `"link" \| "plain" \| "gradient" \| "outline" \| "primary" \| "destructive" \| "light" \| "ghost" \| "brand" \| "dark" \| "tint"` | `"primary"` | Also accepts the React.ButtonHTMLAttributes attributes (285). Anatomy: - `Button` (`data-slot="button"`): The clickable element: a native ` ``` ```ts function ButtonGroup({ shape, orientation, className, ...props }: React.ComponentProps<"div"> & { shape?: ButtonGroupShape; /** "horizontal" puts the buttons in a row, "vertical" stacks them with shared edges. */ orientation?: ButtonGroupOrientation; }): React.JSX.Element; type ButtonGroupShape = "rounded" | "pill"; type ButtonGroupOrientation = "horizontal" | "vertical"; ``` | Prop | Type | Default | | --- | --- | --- | | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | | `shape` | `"rounded" \| "pill"` | `"rounded"` | Also accepts the React.HTMLAttributes attributes (280). Anatomy: - `ButtonGroup` (`data-slot="button-group"`): A `role="group"` row or stack that joins its children: neighbours overlap by one border and only the two outer ends keep their corners. - `Button`: Each direct child, usually a plain Button or Icon Button. The focused one moves on top, so its focus ring is not covered. Best practices: - **When:** Use a Button Group for a few related actions that belong together, such as zoom in and zoom out or previous and next. For one choice that stays selected, use a Radio Button Group. - **Do:** Give the group an `aria-label` that names what the buttons do together, such as `Zoom`. It already has `role="group"`, but no name. - **Do:** Use the same `variant` and `size` on every button inside. The shared borders only line up when the buttons have the same height and border. - **Don't:** Don't wrap the buttons in extra elements. Corners and overlaps are set on direct children only, so a wrapper breaks the joined edges. - **Don't:** Don't put a primary button into the group. A view has one primary action, and the joined edges present the buttons as equals. Theming: reads `--radius-md`, `--radius-xl`. #### Card A bordered container for grouping related content. ```tsx Card title Supporting text. ``` ```ts function Card({ className, size, variant, ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant; }): React.JSX.Element; type CardVariant = keyof typeof CARD_VARIANT; ``` | Prop | Type | Default | | --- | --- | --- | | `size` | `"default" \| "sm"` | `"default"` | | `variant` | `"default" \| "elevated" \| "outlined"` | `"default"` | Also accepts the React.HTMLAttributes attributes (280). Anatomy: - `Card` (`data-slot="card"`): The surface in `default`, `elevated` or `outlined` style. It has 20 px padding on every side and 16 px between its parts (12 px in the small size), and it sets `--surface` for the parts inside. - `CardMedia` (`data-slot="card-media"`) — optional: An image area flush with the top edge. Put an `` inside to fill it; empty, it shows an image placeholder. - `CardHeader` (`data-slot="card-header"`) — optional: A grid for the title, the description and an optional action on the right. - `CardTitle` (`data-slot="card-title"`) — optional: The card's title. It renders a `
`, so put a heading element inside when the card opens a section. - `CardDescription` (`data-slot="card-description"`) — optional: Secondary text under the title. - `CardAction` (`data-slot="card-action"`) — optional: Sits top right in the header beside the title and description, for a menu or one small button. - `CardContent` (`data-slot="card-content"`): The body, with the card's side padding. - `CardFooter` (`data-slot="card-footer"`) — optional: A right-aligned row of buttons with a line above it. The card drops its own bottom padding when a footer is present. Best practices: - **When:** Use a Card to group content about one thing, such as a project, a plan or a settings section. For a row with icon, title and meta, use Item Card; when there is nothing to show yet, use Empty State. - **Do:** Leave the padding to the card: the default size has the same 20 px inset as the chart, stat and metric cards. Use `size="sm"` for dense grids instead of overriding `p-*` classes. - **Do:** Put a menu or one small button in `CardAction`. It stays top right however long the title runs. - **Do:** Use plain buttons in `CardFooter` when cards repeat in a grid. A view has one primary action, and a primary button on every card leaves none. - **Don't:** Don't set `bg-white` or a hex background on a card. `bg-card` follows the theme and dark mode, and the parts inside read it through `--surface`. Theming: reads `--background`, `--border`, `--card`, `--card-foreground`, `--foreground`, `--muted`, `--text-secondary`, `--text-tertiary`, `--radius-xl`, `--shadow-md`, `--shadow-xs`, `--font-sans`. #### Checkbox A single choice that is on or off, with an indeterminate state for “some selected”. ```tsx ``` ```ts function Checkbox({ className, inputRef, ...props }: Checkbox.Root.Props): React.JSX.Element; ``` | Prop | Type | Default | | --- | --- | --- | | `checked` | `boolean` | `undefined` | | `className` | `string \| ((state: State) => string \| undefined)` | — | | `defaultChecked` | `boolean` | `false` | | `disabled` | `boolean` | `false` | | `form` | `string` | — | | `id` | `string` | — | | `indeterminate` | `boolean` | `false` | | `inputRef` | `React.Ref` | — | | `name` | `string` | `undefined` | | `nativeButton` | `boolean` | `false` | | `onCheckedChange` | `((checked: boolean, eventDetails: CheckboxRootChangeEventDetails) => void)` | — | | `parent` | `boolean` | `false` | | `readOnly` | `boolean` | `false` | | `render` | `React.ReactElement \| ComponentRenderFn` | — | | `required` | `boolean` | `false` | | `style` | `React.CSSProperties \| ((state: State) => React.CSSProperties \| undefined)` | — | | `uncheckedValue` | `string` | — | | `value` | `string` | — | Also accepts the React.HTMLAttributes attributes (273). Anatomy: - `Checkbox` (`data-slot="checkbox"`): The box, a `role="checkbox"` with `aria-checked`. Its hit area reaches beyond the visible box, and `aria-invalid` turns the border to the destructive colour. - `Check indicator` (`data-slot="checkbox-indicator"`) — optional: The check, or a dash when `indeterminate`. The check draws in on checking. Checkbox renders it. - `Label`: The text that names the checkbox, from `Label` or `Field`. Without it the box has no accessible name. Best practices: - **When:** Use a Checkbox for an on/off choice in a form or for picking several items from a list. For a settings row that toggles, use a Switch; for one choice out of several, use a Radio Group. - **Do:** Give every checkbox a visible label through `Label` or `Field`. Clicking the label then toggles the box, and screen readers have a name to announce. - **Do:** Set `indeterminate` on a select-all box when only some rows are selected. Screen readers hear `mixed`, and the box shows a dash instead of the check. - **Do:** Mark an unchecked required box with `aria-invalid` and say what to do in the error text: `Accept the terms to continue.` - **Don't:** Don't build a checkbox from a styled `
`. The component already handles Space, the focus ring and the larger hit area around the small box. Theming: reads `--border-control`, `--destructive`, `--muted`, `--primary`, `--primary-foreground`, `--ring`, `--duration-fast`, `--duration-slow`, `--ease-entrance`. #### Switch A toggle control for a binary setting. ```tsx ``` ```ts function Switch({ className, size, inputRef, ...props }: Switch.Root.Props & { size?: "sm" | "default"; }): React.JSX.Element; ``` | Prop | Type | Default | | --- | --- | --- | | `checked` | `boolean` | — | | `className` | `string \| ((state: State) => string \| undefined)` | — | | `defaultChecked` | `boolean` | `false` | | `disabled` | `boolean` | `false` | | `form` | `string` | — | | `id` | `string` | — | | `inputRef` | `React.Ref` | — | | `name` | `string` | — | | `nativeButton` | `boolean` | `false` | | `onCheckedChange` | `((checked: boolean, eventDetails: SwitchRoot.ChangeEventDetails) => void)` | — | | `readOnly` | `boolean` | `false` | | `render` | `React.ReactElement \| ComponentRenderFn` | — | | `required` | `boolean` | `false` | | `size` | `"sm" \| "default"` | `"default"` | | `style` | `React.CSSProperties \| ((state: State) => React.CSSProperties \| undefined)` | — | | `uncheckedValue` | `string` | — | | `value` | `string` | — | Also accepts the React.HTMLAttributes attributes (273). Anatomy: - `Switch` (`data-slot="switch"`): A `switch` role with a sliding thumb, in `default` or `sm` size. Its hit area reaches past the track so a near miss still toggles it. - `SwitchItemCell` — optional: A table cell that puts a Switch and its text in one `