1. Prerequisites
Before your first add, you need three things in place:
- A React 19 app. Every registry item targets React 19 (Pouf's own
react/react-domare pinned to^19) — Vite, Next, Astro with the React integration, whatever you already run. - Tailwind CSS v4. Pouf's stylesheet is CSS-first — no
tailwind.config.js, just@themeand@importin a.cssfile. You still need Tailwind's v4 build plugin wired into your bundler:@tailwindcss/vitefor a Vite-based app (that's what pouf.worksonmy.dev itself runs — an Astro site on Vite), or@tailwindcss/postcssfor a plain PostCSS pipeline. - The shadcn CLI, initialized. Run
initonce to create acomponents.json. Pouf doesn't need special aliases: every file in the registry declares an explicit target path (components/pouf/…), so it lands in the same place no matter how your aliases are configured.
npx shadcn@latest initEverything past that rides along automatically. Radix UI primitives, class-variance-authority, clsx, and — for a handful of components — framer-motion are declared as npm dependencies on the individual registry items that need them, so the CLI installs exactly what a given component uses the moment you add it. Nothing to pre-install by hand.
2. The stylesheet
The base registry item ships one file — pouf.css — the theme: every design token, the cushion box-shadow recipes, keyframes, and the chrome for multi-part components (dialogs, menus, the mobile sheet). Every other component depends on it, so it always comes along, but you can install it explicitly:
npx shadcn@latest add https://pouf.worksonmy.dev/r/base.jsonIt lands at components/pouf/pouf.css. Import it once, at your app root, alongside the font:
import '@fontsource-variable/nunito'
import './components/pouf/pouf.css'Two details worth knowing before you go looking for a Tailwind entry file elsewhere: pouf.css is your Tailwind entry. Its first line is @import 'tailwindcss' — importing pouf.css is what pulls Tailwind itself into your build. Don't add a second @import 'tailwindcss' anywhere.
Its second line is @source './' — Tailwind v4's automatic content scanner normally only looks at your own app's files. This directive tells it to also scan everything next to pouf.css itself, which is where the components you install actually live. Skip it (say, by copying only pieces of the file) and Tailwind never sees the utility classes baked into the .tsx sources you just installed — every cushion renders unstyled.
3. Quick start
With the base in place, add a component:
npx shadcn@latest add https://pouf.worksonmy.dev/r/button.jsonimport { Button } from './components/pouf/Button'
export function Example() {
return <Button tone="mint">Save</Button>
}4. Theming
Every token in pouf.css is a plain CSS custom property, in two layers.
@theme — the Tailwind-facing layer
This is what Tailwind reads to generate utility classes (bg-purple, rounded-card, …). It's the palette and radii, verbatim from the reference design:
@theme {
--color-bg: #f0e9ff;
--color-ink: #3a2e5c;
--color-purple: #c9a8ff;
--color-mint: #a8f0d0;
/* …pink, blue, yellow, orange, muted, surface */
--radius-card: 32px;
--radius-control: 20px;
--radius-blob: 24px;
}:root — the layer components actually speak
Short aliases that resolve to the same values (--bg, --ink, --surface, --purple, …), a spacing scale (--s1 through --s8), and the clay recipe itself — the layered box-shadows every cushion is built from: --pouf-card, --pouf-control, --pouf-field, --pouf-row (each with hover/active/focus siblings). Components read these names, not @theme's, and not a hard-coded colour anywhere.
To re-theme the palette, override the @theme values in your own stylesheet, after importing pouf.css. Because :root's aliases are written as var(--color-purple) — a live reference, not a copied value — one change reaches everything: the :root aliases, the clay recipe, and the handful of components that use a compiled Tailwind utility (like bg-purple) directly.
/* your own stylesheet, imported after pouf.css */
@theme {
--color-purple: #7c5cff;
--radius-card: 20px;
}To remap what a tone means rather than its hue, override the semantic alias directly — screens speak intent (--up, --down, --warn, --info, --idle), never colour, so a re-theme of meaning happens in exactly one place:
:root {
--down: var(--orange); /* was --pink */
}5. Dark mode
Dark is opt-in via an attribute on the document, not a media query: set data-theme="dark" on <html> and every token in pouf.css's [data-theme='dark'] block takes over. Leave the attribute off (or set it to "light") and nothing changes.
There is deliberately no @media (prefers-color-scheme: dark) rule in the stylesheet. Reading the OS preference in CSS would make the theme a property of the visitor's machine rather than of the document — every screenshot test would become environment-dependent, and the choice would be taken away from your app, which usually already owns a theme toggle. Deciding from the OS preference is a one-line job for whoever mounts the app, done once, in script — not baked into the library.
Here's the standard no-flash version — synchronous and inline in <head>, so it runs before first paint (a deferred script, or a React effect, both run after the browser has already painted the wrong theme):
<script>
(function () {
try {
var saved = localStorage.getItem('theme')
var theme =
saved === 'light' || saved === 'dark'
? saved
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
document.documentElement.setAttribute('data-theme', theme)
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light')
}
})()
</script>Toggling later is one line — document.documentElement.setAttribute('data-theme', next) — plus whatever persistence you want (this site's own toggle writes the choice to localStorage so it survives a reload).
One token needs a closer look across the swap: --on-accent — the ink colour that sits on a pastel accent fill (a solid Button, a filled Badge, an active NavLink, a Blob). The accents themselves never change between themes — they're the brand — while --ink flips from near-black to near-white for the page. Collapse the two and dark mode puts near-white text on a fill that's still pastel purple, which fails contrast outright. --on-accent is pinned separately for dark (it stays dark, because the fill it sits on stays pastel); in light mode it resolves to exactly --ink, so nothing about the light theme changes.
6. Conventions
No component takes a className or style prop. Appearance is chosen with variants — tone, size, variant — so your UI can't quietly drift off-system one utility class at a time. Tones are purple, pink, blue, mint, yellow, orange, plus five semantic aliases that speak intent instead of colour: up, down, warn, info, idle (see Theming above for what they map to, and how to remap them).
When you genuinely need an escape hatch that no variant covers, you own the file — edit it. That is the whole trade Pouf makes: a strong default that holds, with the source in your hands when it shouldn't.
7. Accessibility
Overlay behaviour — focus trapping, keyboard nav, ARIA state — comes from Radix, for free. Two conventions worth knowing before you build a form or a toolbar:
Field wires a real <label for>, hint text, and an error message together, and hands the ids back to you rather than making you guess them. It takes a render-prop child — a function, not plain JSX — because the same wrapper serves Input, Select, and Switch without duplicating the aria-describedby plumbing per control:
<Field label="Email" hint="We'll never share it.">
{(id, describedBy) => (
<Input id={id} describedBy={describedBy} value={email} onChange={setEmail} />
)}
</Field>Icon-only controls need a label prop. A Button (or a NumberInput stepper, a Checkbox, a chart legend, …) with only a glyph inside has no accessible name unless you supply one — it renders as aria-label:
<Button label="Delete row" size="sm" variant="quiet">
<Icon name="remove" />
</Button>Don't also pass a label to the Icon in that case — Icon's own label exists for when the glyph is the sole carrier of meaning with nothing else naming it. Set both and a screen reader announces the description twice.
8. Blocks vs. templates
Past a single component, Pouf ships two tiers of copy-pasteable composition. Both install exactly like a component — a registry item that lands with its own dependencies — but they promise different things:
- Blocks are sections. A pricing table, a login card, a music player — you drop one into a page you already have.
- Templates are screens. Global chrome (an app shell with navigation, or a marketing page with a navbar and footer) plus the content inside it — you ship one as a route.
npx shadcn@latest add https://pouf.worksonmy.dev/r/dashboard.jsonBrowse every block on /blocks, and every template on /examples.
Next: browse every component live on one page — copy an install command, or read the source.