Components
Every component, live and interactive. Copy the install command, or grab the source.
Layout
Stack, Row, Grid, Shell, Sidebar, Spacer — flex and grid without inline styles.
npx shadcn@latest add https://pouf.worksonmy.dev/r/layout.jsonimport { cva } from 'class-variance-authority'
import type { ReactNode } from 'react'
/** Layout primitives exist so a screen never writes `display:flex` itself.
* Without them the first screen that needs a gap reaches for an inline style,
* and spacing silently escapes the design system. */
type Gap = 1 | 2 | 3 | 4 | 5 | 6
/* The --gap indirection is kept from the original: children may not read it,
* but emitting the same custom property keeps the computed-style contract
* byte-identical, and it remains the single knob a wrapper can retune. */
const gapVariant = {
1: '[--gap:var(--s1)] gap-[var(--gap,var(--s4))]',
2: '[--gap:var(--s2)] gap-[var(--gap,var(--s4))]',
3: '[--gap:var(--s3)] gap-[var(--gap,var(--s4))]',
4: '[--gap:var(--s4)] gap-[var(--gap,var(--s4))]',
5: '[--gap:var(--s5)] gap-[var(--gap,var(--s4))]',
6: '[--gap:var(--s6)] gap-[var(--gap,var(--s4))]',
} as const
/* min-width: 0 on BOTH layout primitives, everywhere.
*
* A flex item defaults to `min-width: auto` — "never shrink below my content" —
* and that default cascades: `<Text truncate>` can only ellipsis if EVERY
* ancestor between it and the sized container is allowed to shrink. One Stack
* without this and a long title (some run 200 characters of Persian)
* drags the whole row wider than the dialog, producing a horizontal scrollbar
* and pushing content off-screen.
*
* Set on the primitives rather than at call sites, because "remember min-width:0
* on every wrapper" is exactly the kind of rule that gets forgotten once and
* then looks like a mysterious layout bug. */
const stack = cva('pouf-stack flex flex-col min-w-0', {
variants: { gap: gapVariant },
defaultVariants: { gap: 4 },
})
interface StackProps {
children: ReactNode
gap?: Gap
}
export function Stack({ children, gap }: StackProps) {
return <div className={stack({ gap })}>{children}</div>
}
const row = cva('pouf-row flex flex-row min-w-0', {
variants: {
gap: gapVariant,
align: { center: 'items-center', top: 'items-start' },
justify: { start: '', center: 'justify-center', between: 'justify-between', end: 'justify-end' },
wrap: { true: 'flex-wrap', false: 'flex-nowrap' },
},
defaultVariants: { gap: 4, align: 'center', justify: 'start', wrap: true },
})
interface RowProps {
children: ReactNode
gap?: Gap
align?: 'center' | 'top'
/** `center` exists because without it a screen has no way to centre anything
* and reaches for an inline style — which is how the QR code ended up
* left-aligned in its dialog. */
justify?: 'start' | 'center' | 'between' | 'end'
wrap?: boolean
}
export function Row({ children, gap, align, justify, wrap }: RowProps) {
return <div className={row({ gap, align, justify, wrap })}>{children}</div>
}
export function Spacer() {
return <div className="pouf-spacer flex-auto min-w-0" />
}
/* Column counts are variants, not a --cols override, so a screen never needs an
* inline style to lay out. Every variant collapses to one column under 900px. */
const grid = cva('pouf-grid grid', {
variants: {
cols: {
2: 'grid-cols-2 max-[900px]:grid-cols-1',
3: 'grid-cols-3 max-[900px]:grid-cols-1',
4: 'grid-cols-4 max-[900px]:grid-cols-1',
sidebar:
'[grid-template-columns:minmax(0,2fr)_minmax(0,1fr)] max-[900px]:[grid-template-columns:minmax(0,1fr)]',
},
gap: gapVariant,
},
defaultVariants: { cols: 2, gap: 4 },
})
interface GridProps {
children: ReactNode
cols?: 2 | 3 | 4 | 'sidebar'
gap?: Gap
}
export function Grid({ children, cols, gap }: GridProps) {
return <div className={grid({ cols, gap })}>{children}</div>
}
export function Shell({ children }: { children: ReactNode }) {
return (
<div
className={[
'pouf-shell grid [grid-template-columns:260px_minmax(0,1fr)] gap-(--s6) p-(--s8) max-w-[1440px] mx-auto [align-items:start]',
/* Below 900px: single column, tighter padding, and clearance for the
* fixed bottom bar plus the home-indicator inset on iOS. */
'max-[900px]:[grid-template-columns:minmax(0,1fr)] max-[900px]:p-(--s4)',
'max-[900px]:pb-[calc(96px+env(safe-area-inset-bottom,0px))]',
].join(' ')}
>
{children}
</div>
)
}
export function Sidebar({ children }: { children: ReactNode }) {
return (
<aside className="pouf-sidebar sticky top-(--s8) flex flex-col gap-(--s2) max-[900px]:static">
{children}
</aside>
)
}Grid
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
cols | 2 | 3 | 4 | 'sidebar' | |
gap | Gap |
Row
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
gap | Gap | |
align | 'center' | 'top' | |
justify | 'start' | 'center' | 'between' | 'end' | `center` exists because without it a screen has no way to centre anything and reaches for an inline style — which is how the QR code ended up left-aligned in its dialog. |
wrap | boolean |
Stack
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
gap | Gap |
Separator
A hairline divider.
npx shadcn@latest add https://pouf.worksonmy.dev/r/separator.jsonimport * as RSeparator from '@radix-ui/react-separator'
export function Separator() {
return (
<RSeparator.Root
className="pouf-separator border-none h-px bg-[rgba(201,168,255,0.3)] my-(--s2) mx-0"
decorative
/>
)
}ScrollArea
A scroll container with a styled bar.
npx shadcn@latest add https://pouf.worksonmy.dev/r/scroll-area.jsonimport * as RScroll from '@radix-ui/react-scroll-area'
import type { ReactNode } from 'react'
interface ScrollAreaProps {
children: ReactNode
maxHeight?: string
}
export function ScrollArea({ children, maxHeight = '400px' }: ScrollAreaProps) {
return (
// maxHeight lives on the VIEWPORT, not the Root: the viewport scrolls itself,
// and a percentage height:100% against a Root that only has max-height never
// resolves (a percentage height needs an explicit parent height), so the old
// build just clipped and never scrolled. A max-height on the scrolling element
// directly needs no such resolution.
// type="auto" rather than Radix's default "hover": the bar is then present
// whenever the content actually overflows, instead of only once a pointer is
// already inside. With the default, a capped area reads as CONTENT THAT JUST
// ENDS — the last line is sliced in half with nothing on screen saying it
// scrolls, which is exactly how it looked in the gallery. Touch and
// keyboard users never hover at all.
<RScroll.Root className="pouf-scroll" type="auto">
<RScroll.Viewport className="pouf-scroll__viewport" style={{ maxHeight }}>
{children}
</RScroll.Viewport>
<RScroll.Scrollbar className="pouf-scroll__bar" orientation="vertical">
<RScroll.Thumb className="pouf-scroll__thumb" />
</RScroll.Scrollbar>
</RScroll.Root>
)
}| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
maxHeight | string |
AspectRatio
A framed, ratio-locked box.
npx shadcn@latest add https://pouf.worksonmy.dev/r/aspect-ratio.jsonimport * as RAspect from '@radix-ui/react-aspect-ratio'
import type { ReactNode } from 'react'
export function AspectRatio({ ratio = 16 / 9, children }: { ratio?: number; children: ReactNode }) {
return (
<div className="pouf-aspect w-full rounded-control overflow-hidden bg-bg cushion-field">
<RAspect.Root ratio={ratio}>{children}</RAspect.Root>
</div>
)
}Text & Headings
Heading, Text, Highlight, Eyebrow — the type scale.
npx shadcn@latest add https://pouf.worksonmy.dev/r/text.jsonimport { cva, cx } from 'class-variance-authority'
import type { ReactNode } from 'react'
import { toneClass, type Tone } from './tone'
interface HeadingProps {
children: ReactNode
level?: 1 | 2 | 3
}
const heading = cva('font-black', {
variants: {
level: {
1: 'pouf-h1 text-[48px] tracking-[-1px] leading-[1.1]',
2: 'pouf-h2 text-[28px] tracking-[-0.5px] leading-[1.2]',
/* h1/h2 set 1.1/1.2; without this h3 inherits the body's 1.5 and its line
box towers over the 44px blob it commonly sits beside. */
3: 'pouf-h3 text-[19px] tracking-[-0.2px] leading-[1.2]',
},
},
defaultVariants: { level: 2 },
})
export function Heading({ children, level = 2 }: HeadingProps) {
const Tag = `h${level}` as const
return <Tag className={heading({ level })}>{children}</Tag>
}
/** The reference's yellow highlight-swatch behind a word. */
export function Highlight({ children, tone = 'yellow' }: { children: ReactNode; tone?: Tone }) {
return (
<span
className={cx(
'pouf-highlight inline-block px-[14px] rounded-control bg-[var(--tone,var(--yellow))]',
'[box-shadow:inset_0_-6px_0_rgba(0,0,0,0.08)]',
toneClass(tone),
)}
>
{children}
</span>
)
}
/** The reference's uppercase section eyebrow. Restricted to white surfaces:
* --muted measures 3.93:1 on --bg (fails) but 4.64:1 on white (passes). */
export function Eyebrow({ children }: { children: ReactNode }) {
return (
<div className="pouf-eyebrow text-[14px] tracking-[2px] uppercase font-extrabold text-muted">
{children}
</div>
)
}
interface TextProps {
children: ReactNode
size?: 'sm' | 'md'
muted?: boolean
/** Tabular numerals — use for any figure in a column that must align. */
num?: boolean
mono?: boolean
truncate?: boolean
}
const text = cva('pouf-text font-bold', {
variants: {
size: { md: 'text-[15px]', sm: 'text-[13px]' },
muted: { true: 'text-muted' },
num: { true: '[font-variant-numeric:tabular-nums] [font-feature-settings:"tnum"]' },
mono: { true: "[font-family:ui-monospace,'SF_Mono',Menlo,monospace] [font-variant-numeric:tabular-nums]" },
truncate: { true: 'truncate min-w-0' },
},
defaultVariants: { size: 'md' },
})
export function Text({ children, size, muted, num, mono, truncate }: TextProps) {
return (
// dir="auto" by default, and deliberately not opt-in.
//
// Almost everything an app renders is user-generated: names, titles and
// raw message text, in whatever language the person writes. Without this,
// a Persian or Arabic title renders with its emoji and punctuation on the
// wrong side — visibly wrong, and easy to miss if your own test data is
// all English.
//
// Safe as a blanket default: dir="auto" resolves from the first STRONG
// character, and digits/punctuation are neutral — so "+2.41%" and "SKU-1420"
// stay LTR. Opting in per call site would mean remembering it at every one,
// which is how the bug comes back.
<span dir="auto" className={text({ size, muted, num, mono, truncate })}>
{children}
</span>
)
}Heading
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
level | 1 | 2 | 3 |
Text
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
size | 'sm' | 'md' | |
muted | boolean | |
num | boolean | Tabular numerals — use for any figure in a column that must align. |
mono | boolean | |
truncate | boolean |
Card
Card and RowCard — every surface a cushion.
npx shadcn@latest add https://pouf.worksonmy.dev/r/surface.jsonimport { cva } from 'class-variance-authority'
import type { ReactNode } from 'react'
/* Vertical padding is biased by half the lip on every padded variant: the
* cushion's floor inset paints INSIDE the box, so symmetric padding reads
* bottom-heavy. Same total height, optically centred content. */
const card = cva('pouf-card bg-surface rounded-card cushion-card', {
variants: {
variant: {
default: 'px-(--s7) pt-[calc(var(--s7)-var(--lip)/2)] pb-[calc(var(--s7)+var(--lip)/2)]',
flush: 'p-0',
tight: 'px-(--s4) pt-[calc(var(--s4)-var(--lip)/2)] pb-[calc(var(--s4)+var(--lip)/2)]',
},
},
defaultVariants: { variant: 'default' },
})
interface CardProps {
children: ReactNode
/** flush: no padding, clipped — for a card that wraps its own scroll region.
* tight: 16px instead of the reference's 32px, for dense panels. */
variant?: 'default' | 'flush' | 'tight'
}
export function Card({ children, variant }: CardProps) {
return <div className={card({ variant })}>{children}</div>
}
interface RowCardProps {
children: ReactNode
/** Renders a <button> when provided — keyboard-operable for free. */
onClick?: () => void
selected?: boolean
}
/* Interactivity and selection are cva variants rather than cascading selector
* overrides: a selected row simply never emits the hover/active utilities, so
* it cannot flicker back to a white cushion under the pointer. [font:inherit]
* keeps the button voice identical to the div voice (the shorthand also
* resets line-height to inherit, which the button base compensation set to
* normal). */
const rowCard = cva(
[
'pouf-rowcard block w-full text-left [font:inherit] text-inherit border-none',
'rounded-control px-(--s4) pt-[calc(var(--s4)-var(--lip-row)/2)] pb-[calc(var(--s4)+var(--lip-row)/2)]',
'[transition:box-shadow_140ms_ease,transform_140ms_ease]',
],
{
variants: {
interactive: { true: 'cursor-pointer' },
selected: {
/* The same words navlink--active speaks for "the active one of a set":
* a tone fill on the whole cushion. Ink on --purple is 6.10:1. */
true: 'bg-purple cushion-control',
false: 'bg-surface cushion-row',
},
},
compoundVariants: [
{
interactive: true,
selected: false,
className:
'hover:cushion-row-hover hover:[transform:translateY(-1px)] active:[transform:translateY(1px)] active:cushion-control-active',
},
],
defaultVariants: { selected: false },
},
)
/** "Every row a cushion" — one puffy surface per position / item / message.
*
* No tonal-edge prop. It was tried as a border and as an inset bar and read as
* an artifact against the cushion's rounded silhouette both times. Rows convey
* their state through the Blob's tone and the Badge's text, which had to exist
* regardless — so the prop is gone rather than left as a tempting no-op.
*/
export function RowCard({ children, onClick, selected }: RowCardProps) {
const className = rowCard({ interactive: !!onClick, selected: !!selected })
if (onClick) {
return (
<button type="button" className={className} onClick={onClick} aria-pressed={selected}>
{children}
</button>
)
}
return <div className={className}>{children}</div>
}Card
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
variant | 'default' | 'flush' | 'tight' | flush: no padding, clipped — for a card that wraps its own scroll region. tight: 16px instead of the reference's 32px, for dense panels. |
RowCard
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
onClick | () => void | Renders a <button> when provided — keyboard-operable for free. |
selected | boolean |
Stat & Metric
Headline figures on their own cushion.
npx shadcn@latest add https://pouf.worksonmy.dev/r/readout.jsonimport type { ReactNode } from 'react'
import { Card } from './surface'
import { Blob } from './media'
import { Text } from './text'
import type { IconLike } from './Icon'
import type { Tone } from './tone'
/** A headline figure on its own cushion.
*
* This existed twice, verbatim — `StatTile` in one screen and `Stat` in
* another — and the copies had already drifted apart: one took the full Tone,
* the other narrowed it to four. Divergent copies of one idea is the thing a
* design system exists to stop, so it lives here now. It takes the full Tone;
* the narrowed signature was the drift, not the intent.
*
* The tile leads with its own icon and tone because the block it replaced put
* four unadorned numbers on one card, where "0", "0 / 0" and "+$0.00" all
* carried identical weight and nothing said which one you actually came for.
*/
export function Stat({ label, value, icon, tone }: { label: string; value: string; icon: IconLike; tone: Tone }) {
return (
<Card variant="tight">
<div className="pouf-stat flex items-center gap-(--s3)">
<Blob tone={tone} size="sm" icon={icon} />
<div className="pouf-stat__text flex flex-col gap-[2px] min-w-0">
<span className="pouf-stat__label text-[12px] font-extrabold tracking-[1.4px] uppercase text-muted whitespace-nowrap">
{label}
</span>
{/* dir="auto" for the same reason Text sets it: a value can be a
non-Latin string (a person's name in a "top contributor" tile). */}
<span
className='pouf-stat__value text-[26px] font-black leading-[1.05] tracking-[-0.5px] text-ink [font-variant-numeric:tabular-nums] [font-feature-settings:"tnum"]'
dir="auto"
>
{value}
</span>
</div>
</div>
</Card>
)
}
interface MetricProps {
label: string
/** `null` means UNKNOWN, and renders as an em-dash — never as 0.
*
* This is the whole reason the component exists. The pattern it replaces was
* hand-written at ~34 sites, each re-deriving the rule, e.g.
* `{p.reviews > 0 ? `${pct}%` : '—'}` with the comment "A satisfaction score
* over zero reviews is not 0% — it's unknown." Any site that forgot printed
* "0%", which reads as "everybody hated it" — a real number, and the opposite
* of the truth. The caller still decides *whether* a value is known; only that
* answer needs domain knowledge. What is uniform, and therefore belongs here,
* is how unknown looks. */
value: ReactNode | null
/** Tabular figures, so digits line up down a column. On by default: a metric
* is a number unless it isn't (a plan name, a region). */
num?: boolean
mono?: boolean
}
/** A labelled readout: small muted label, value beneath.
*
* A readout is atomic: "−$24.75" must never break after the minus, and a
* two-line label reads as two metrics — hence nowrap; the ROW wraps whole
* metrics instead. Inside a Row ([.pouf-row>&]) metrics divide the spare
* width evenly (a KPI strip) but never below one-line content. */
export function Metric({ label, value, num = true, mono }: MetricProps) {
return (
<div className="pouf-metric flex flex-col gap-(--s1) min-w-0 whitespace-nowrap [.pouf-row>&]:flex-[1_1_0] [.pouf-row>&]:min-w-max">
<Text size="sm" muted>
{label}
</Text>
<Text num={num} mono={mono}>
{value ?? '—'}
</Text>
</div>
)
}| Prop | Type | Notes |
|---|---|---|
label* | string | |
value* | ReactNode | null | `null` means UNKNOWN, and renders as an em-dash — never as 0. This is the whole reason the component exists. The pattern it replaces was hand-written at ~34 sites, each re-deriving the rule, e.g. `{p.reviews > 0 ? `${pct}%` : '—'}` with the comment "A satisfaction score over zero reviews is not 0% — it's unknown." Any site that forgot printed "0%", which reads as "everybody hated it" — a real number, and the opposite of the truth. The caller still decides *whether* a value is known; only that answer needs domain knowledge. What is uniform, and therefore belongs here, is how unknown looks. |
num | boolean | Tabular figures, so digits line up down a column. On by default: a metric is a number unless it isn't (a plan name, a region). |
mono | boolean |
Blob, Badge, Dot, Figure
Icon tiles, flat labels, status dots, framed images.
npx shadcn@latest add https://pouf.worksonmy.dev/r/media.jsonimport { cva, cx } from 'class-variance-authority'
import type { ReactNode } from 'react'
import { toneClass, type Tone } from './tone'
import { renderIcon, type IconLike } from './Icon'
interface BlobProps {
/** A named role (Tabler-backed, from Icon) OR any icon element from any
* library — lucide, phosphor, your own SVG. The named set is a convenience,
* not a requirement. */
icon: IconLike
tone?: Tone
size?: 'sm' | 'md' | 'lg'
/** Decorative by default — a blob beside a visible label is noise to a
* screen reader. Pass a label only when the icon is the sole meaning. */
label?: string
}
/* Optically centred, not geometrically centred: the blob carries a 4px white
* highlight on top and an 8px dark floor on the bottom. That floor reads as
* the cushion's underside rather than its face, so the face a viewer sees is
* the upper portion — and a mathematically perfect centre sits visibly low in
* it. Nudging the glyph by half the difference, (8-4)/2, puts it in the middle
* of the face people actually perceive. */
const blob = cva(
[
'pouf-blob inline-flex items-center justify-center flex-none text-[var(--on-accent)]',
'bg-[var(--tone,var(--purple))] cushion-blob [&>svg]:[transform:translateY(-2px)]',
],
{
variants: {
size: {
sm: 'w-11 h-11 rounded-[14px] text-[20px]',
md: 'w-[60px] h-[60px] rounded-[18px] text-[26px]',
lg: 'w-20 h-20 rounded-blob text-[36px]',
},
},
defaultVariants: { size: 'lg' },
},
)
/** The reference's icon tile. */
export function Blob({ icon, tone = 'purple', size = 'lg', label }: BlobProps) {
return (
<span
className={cx(blob({ size }), toneClass(tone))}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
{renderIcon(icon, size === 'sm' ? 'sm' : size === 'md' ? 'md' : 'lg')}
</span>
)
}
/* FLAT, deliberately. A badge is a label, not a control. It used to carry the
* cushion recipe and so read as a small pressable pill. In a design system
* whose entire vocabulary for "interactive" is depth, letting a static label
* borrow that depth devalues the signal for every real button on screen.
* flex-none + self-start: never squashed by a long neighbour, and never
* stretched into a banner by a flex column. A badge hugs its own text. */
export function Badge({ children, tone = 'purple' }: { children: ReactNode; tone?: Tone }) {
return (
<span
className={cx(
'pouf-badge inline-flex items-center gap-[6px] text-[12px] font-black tracking-[0.4px] uppercase',
'text-[var(--on-accent)] bg-[var(--tone,var(--purple))] rounded-pill px-3 py-[5px] [box-shadow:none]',
'flex-none whitespace-nowrap self-start',
toneClass(tone),
)}
>
{children}
</span>
)
}
/** A status dot. Always pair with text — colour alone can't carry state. */
export function Dot({ tone = 'purple' }: { tone?: Tone }) {
return (
<span
className={cx(
'pouf-dot w-[9px] h-[9px] rounded-[50%] bg-[var(--tone,var(--purple))]',
'[box-shadow:inset_0_-2px_0_rgba(0,0,0,0.15)] flex-none',
toneClass(tone),
)}
aria-hidden="true"
/>
)
}
/** An image, framed in clay.
*
* `alt` is required with no default: these render user-supplied screenshots,
* and an unlabelled image is exactly the case alt text exists for.
* Lazy by default — a gallery can be dozens of photos. */
export function Figure({ src, alt }: { src: string; alt: string }) {
return (
<img
className="pouf-figure block max-w-[420px] w-full h-auto rounded-control bg-bg cushion-field"
src={src}
alt={alt}
loading="lazy"
/>
)
}| Prop | Type | Notes |
|---|---|---|
icon* | IconLike | A named role (Tabler-backed, from Icon) OR any icon element from any library — lucide, phosphor, your own SVG. The named set is a convenience, not a requirement. |
tone | Tone | |
size | 'sm' | 'md' | 'lg' | |
label | string | Decorative by default — a blob beside a visible label is noise to a screen reader. Pass a label only when the icon is the sole meaning. |
Avatar
A puffy avatar with image, initials, or icon fallback.
npx shadcn@latest add https://pouf.worksonmy.dev/r/avatar.jsonimport * as RAvatar from '@radix-ui/react-avatar'
import { cva, cx } from 'class-variance-authority'
import { toneClass, type Tone } from './tone'
import { renderIcon, type IconLike } from './Icon'
interface AvatarProps {
src?: string
alt?: string
fallback?: string
icon?: IconLike
tone?: Tone
size?: 'sm' | 'md' | 'lg'
}
/* The root carries the cast shadow: overflow:hidden clips a CHILD's shadow
* (the old build put the whole blob recipe on the fallback and lost the drop),
* but never the element's own. Inner highlight/floor live on the fallback
* face; a photo supplies its own light, so an image avatar keeps just the
* drop. */
const avatar = cva(
'pouf-avatar inline-flex items-center justify-center overflow-hidden flex-none bg-bg [box-shadow:0_10px_20px_rgba(58,46,92,0.15)]',
{
variants: {
size: {
sm: 'w-8 h-8 rounded-[14px]',
md: 'w-12 h-12 rounded-blob',
lg: 'w-[72px] h-[72px] rounded-blob',
},
},
defaultVariants: { size: 'md' },
},
)
/* SOFT, blurred insets — not the hard 0-blur bands the Blob uses. On a circle,
* overflow:hidden clips a hard `inset 0 -6px 0` band into a crescent that
* reads as a second, clipped disc. A blurred inset instead shades the sphere
* smoothly: light gathered at the top, a soft floor at the bottom, no hard
* edge to clip. Initials scale with the face — 15px letters were rattling
* around the 72px avatar and crowding the 32px one. */
const fallback = cva(
[
'pouf-avatar__fallback w-full h-full flex items-center justify-center font-black text-[var(--on-accent)]',
'bg-[var(--tone,var(--purple))]',
'[box-shadow:inset_0_4px_8px_rgba(255,255,255,0.5),inset_0_-7px_11px_rgba(0,0,0,0.06)]',
],
{
variants: {
size: { sm: 'text-[12px]', md: 'text-[15px]', lg: 'text-[22px]' },
},
defaultVariants: { size: 'md' },
},
)
export function Avatar({ src, alt, fallback: fallbackText, icon, tone = 'purple', size = 'md' }: AvatarProps) {
return (
<RAvatar.Root className={avatar({ size })}>
{src && <RAvatar.Image className="pouf-avatar__img w-full h-full object-cover" src={src} alt={alt ?? ''} />}
<RAvatar.Fallback className={cx(fallback({ size }), toneClass(tone))}>
{icon ? renderIcon(icon, size === 'lg' ? 'md' : 'sm') : fallbackText?.slice(0, 2).toUpperCase()}
</RAvatar.Fallback>
</RAvatar.Root>
)
}| Prop | Type | Notes |
|---|---|---|
src | string | |
alt | string | |
fallback | string | |
icon | IconLike | |
tone | Tone | |
size | 'sm' | 'md' | 'lg' |
Input & Textarea
Field, Input, Textarea — recessed cushions for text entry.
npx shadcn@latest add https://pouf.worksonmy.dev/r/input.jsonimport { cva } from 'class-variance-authority'
import { useId, type ReactNode } from 'react'
interface FieldProps {
label: string
children: (id: string, describedBy: string | undefined) => ReactNode
hint?: string
error?: string
}
/** Wraps any control with a real <label for>, hint and error text, and wires
* aria-describedby. Screens pass a render fn so the same wrapper serves Input,
* Select and Switch without duplicating the a11y plumbing. */
export function Field({ label, children, hint, error }: FieldProps) {
const id = useId()
const describedBy = error ? `${id}-err` : hint ? `${id}-hint` : undefined
return (
<div className="pouf-field flex flex-col gap-(--s2)">
{/* --muted fails on --bg at small sizes (3.93:1); labels use ink. */}
<label className="pouf-label text-[13px] font-black tracking-[0.6px] uppercase text-ink" htmlFor={id}>
{label}
</label>
{children(id, describedBy)}
{hint && !error && (
<span className="pouf-hint text-[13px] font-bold text-muted" id={`${id}-hint`}>
{hint}
</span>
)}
{/* FIELD-level validation message: a compact one-liner under a control.
* NOT the page alert cushion (ErrorNote) — this one must stay quiet, so
* it keeps the flat little pill it always was. self-start: hug the
* message rather than stretch to fill a flex/grid cell. */}
{error && (
<span
className="pouf-error text-[13px] font-extrabold text-ink bg-orange rounded-xl py-(--s2) px-(--s3) [align-self:start] max-w-full"
id={`${id}-err`}
role="alert"
>
{error}
</span>
)}
</div>
)
}
/* One cva for every input voice in the system, exported because the Select and
* Combobox triggers (and Combobox's search input) wear the same field chrome.
* `bare` is the NumberInput-capsule voice: the capsule carries the chrome, so
* the input inside goes transparent and shadowless, and even its disabled fade
* is suppressed (the capsule already fades; double-fading blurs the value).
* Shadow ownership is per-variant because same-property utilities don't
* cascade; a focused invalid input shows the focus ring (pseudo specificity),
* matching the original selector order. */
export const inputClasses = cva(
'pouf-input font-bold text-[15px] text-ink border-none rounded-control w-full placeholder:text-muted',
{
variants: {
bare: {
false: 'bg-bg px-5 py-4 min-h-[52px] focus:outline-none focus-visible:outline-none disabled:opacity-55 disabled:cursor-not-allowed',
true: 'bg-transparent flex-1 min-w-0 min-h-0 text-center px-0 py-2 [box-shadow:none] focus:[box-shadow:none] focus:outline-none focus-visible:outline-none disabled:opacity-100 disabled:cursor-not-allowed',
},
invalid: { true: '', false: '' },
/* mono OWNS font-family — a base font-pouf would fight it (same-property
* utilities don't cascade; stylesheet order is not the cascade). */
mono: {
true: "[font-family:ui-monospace,'SF_Mono',Menlo,monospace] [font-variant-numeric:tabular-nums]",
false: 'font-pouf',
},
},
compoundVariants: [
{ bare: false, invalid: false, className: 'cushion-field focus:[box-shadow:var(--pouf-field-focus)]' },
{
bare: false,
invalid: true,
className:
'[box-shadow:var(--pouf-field),inset_0_0_0_3px_var(--orange)] focus:[box-shadow:var(--pouf-field-focus)]',
},
],
defaultVariants: { bare: false, invalid: false, mono: false },
},
)
interface InputProps {
value: string
onChange: (value: string) => void
id?: string
describedBy?: string
placeholder?: string
type?: 'text' | 'password'
mono?: boolean
invalid?: boolean
disabled?: boolean
label?: string
/** Internal: the NumberInput capsule carries the chrome. */
bare?: boolean
}
export function Input({
value,
onChange,
id,
describedBy,
placeholder,
type = 'text',
mono,
invalid,
disabled,
label,
bare,
}: InputProps) {
return (
<input
id={id}
className={inputClasses({ bare: !!bare, invalid: !!invalid, mono })}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
type={type}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={label}
/>
)
}
/* ------------------------------------------------------------------ */
/* Textarea */
/* ------------------------------------------------------------------ */
interface TextareaProps {
value: string
onChange: (value: string) => void
id?: string
describedBy?: string
placeholder?: string
rows?: number
mono?: boolean
invalid?: boolean
disabled?: boolean
label?: string
}
export function Textarea({
value,
onChange,
id,
describedBy,
placeholder,
rows = 4,
mono,
invalid,
disabled,
label,
}: TextareaProps) {
return (
<textarea
id={id}
className={`${inputClasses({ invalid: !!invalid, mono })} pouf-textarea resize-y min-h-[100px]`}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={label}
/>
)
}Field
| Prop | Type | Notes |
|---|---|---|
label* | string | |
children* | (id: string, describedBy: string | undefined) => ReactNode | |
hint | string | |
error | string |
Input
| Prop | Type | Notes |
|---|---|---|
value* | string | |
onChange* | (value: string) => void | |
id | string | |
describedBy | string | |
placeholder | string | |
type | 'text' | 'password' | |
mono | boolean | |
invalid | boolean | |
disabled | boolean | |
label | string | |
bare | boolean | Internal: the NumberInput capsule carries the chrome. |
Textarea
| Prop | Type | Notes |
|---|---|---|
value* | string | |
onChange* | (value: string) => void | |
id | string | |
describedBy | string | |
placeholder | string | |
rows | number | |
mono | boolean | |
invalid | boolean | |
disabled | boolean | |
label | string |
NumberInput
A spinbutton capsule with decimal-safe stepping.
npx shadcn@latest add https://pouf.worksonmy.dev/r/number-input.jsonimport { cva } from 'class-variance-authority'
import { inputClasses } from './Input'
import { useEffect, useRef, type KeyboardEvent, type PointerEvent } from 'react'
import { Icon } from './Icon'
import { normalizeOnBlur, sanitizeNumeric, stepValue } from './numberinput-math'
interface NumberInputProps {
value: string
onChange: (value: string) => void
step?: number
min?: number
max?: number
id?: string
describedBy?: string
placeholder?: string
invalid?: boolean
disabled?: boolean
label?: string
}
/** A spinbutton composite replacing <input type="number">: the native spinner
* can only be hidden, never restyled, and its arrows are sub-touch-size.
* Deliberately NOT role="spinbutton" — that role on a text input is unreliable
* across screen readers (react-aria's documented finding), so this stays a
* plain labelled textbox and the stepping lives in two labelled buttons.
*
* The buttons are tabIndex={-1}, like the native spinner's arrows: keyboard
* users already have ArrowUp/Down, PageUp/Down (±10×step) and Home/End on the
* field itself, so tabbing through them would be two stops of pure noise. */
/* The capsule carries the field chrome; the input inside goes bare and the
* focus ring moves up here (focus-within) to wrap the whole control, steppers
* included. Invalid outranks focus-within, matching the original :has() rule's
* higher specificity. The capsule fades for disabled; the bare input suppresses
* its own fade so the value doesn't double-blur. */
const capsule = cva('pouf-numberinput flex items-center gap-1 h-14 px-2 py-0 rounded-pill bg-bg', {
variants: {
invalid: {
false: 'cushion-field focus-within:[box-shadow:var(--pouf-field-focus)]',
true: '[box-shadow:var(--pouf-field),inset_0_0_0_3px_var(--orange)]',
},
disabled: { true: 'opacity-55' },
},
defaultVariants: { invalid: false },
})
/* A scaled-down control cushion: full --pouf-control's 16px drop would bleed
* past the capsule and read as smudge, not lift. The mini's 5px floor lip
* paints inside the box, so a geometrically centred icon reads low — same
* optical trick as the blob's glyph. touch-action: rapid taps must step, not
* double-tap-zoom. */
const stepper = [
'pouf-numberinput__btn flex-none inline-flex items-center justify-center w-10 h-10 p-0 border-none',
'rounded-pill bg-purple text-ink cursor-pointer',
'[box-shadow:inset_0_-5px_0_rgba(0,0,0,0.12),inset_0_3px_0_rgba(255,255,255,0.4),0_3px_6px_rgba(58,46,92,0.18)]',
'[transition:box-shadow_120ms_ease,transform_120ms_ease] [touch-action:manipulation] select-none',
'[&_svg]:[transform:translateY(-1px)]',
'enabled:active:[transform:translateY(1px)] enabled:active:cushion-control-active',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:cushion-control-active disabled:[transform:translateY(1px)]',
].join(' ')
export function NumberInput({
value,
onChange,
step = 1,
min,
max,
id,
describedBy,
placeholder,
invalid,
disabled,
label,
}: NumberInputProps) {
// Hold-to-repeat reads through a ref: the timer callback outlives the render
// that armed it, and stepping from a stale value would rubber-band.
const latest = useRef({ value, step, min, max, onChange })
latest.current = { value, step, min, max, onChange }
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const stop = () => {
if (timer.current !== null) {
clearTimeout(timer.current)
timer.current = null
}
}
useEffect(() => stop, [])
const fire = (dir: 1 | -1) => {
const { value, step, min, max, onChange } = latest.current
const next = stepValue(value, dir, { step, min, max })
// Parked at a bound: stop the repeat rather than tick forever. Once the
// button disables at the bound it swallows pointer events, so the
// pointerup that would normally clear this timer never arrives.
if (next === value) {
stop()
return
}
onChange(next)
}
const press = (dir: 1 | -1) => (e: PointerEvent<HTMLButtonElement>) => {
// Native spinners never move focus; preventDefault keeps it that way and
// suppresses long-press selection on touch.
e.preventDefault()
fire(dir)
const tick = () => {
fire(dir)
timer.current = setTimeout(tick, 60)
}
timer.current = setTimeout(tick, 400)
}
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const apply = (dir: 1 | -1, mult = 1) => {
e.preventDefault()
onChange(stepValue(value, dir, { step, min, max, mult }))
}
if (e.key === 'ArrowUp') apply(1)
else if (e.key === 'ArrowDown') apply(-1)
else if (e.key === 'PageUp') apply(1, 10)
else if (e.key === 'PageDown') apply(-1, 10)
else if (e.key === 'Home' && min !== undefined) {
e.preventDefault()
onChange(String(min))
} else if (e.key === 'End' && max !== undefined) {
e.preventDefault()
onChange(String(max))
}
}
const parsed = Number.parseFloat(value)
const atMin = min !== undefined && Number.isFinite(parsed) && parsed <= min
const atMax = max !== undefined && Number.isFinite(parsed) && parsed >= max
return (
<div className={capsule({ invalid: !!invalid, disabled: !!disabled })}>
<button
type="button"
className={stepper}
aria-label="Decrease"
tabIndex={-1}
disabled={disabled || atMin}
onPointerDown={press(-1)}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
>
<Icon name="flat" />
</button>
<input
id={id}
className={inputClasses({ bare: true, mono: true, invalid: !!invalid })}
value={value}
onChange={(e) => onChange(sanitizeNumeric(e.target.value))}
onBlur={() => onChange(normalizeOnBlur(value, { min, max }))}
onKeyDown={onKeyDown}
inputMode="decimal"
placeholder={placeholder}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={label}
/>
<button
type="button"
className={stepper}
aria-label="Increase"
tabIndex={-1}
disabled={disabled || atMax}
onPointerDown={press(1)}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
>
<Icon name="add" />
</button>
</div>
)
}| Prop | Type | Notes |
|---|---|---|
value* | string | |
onChange* | (value: string) => void | |
step | number | |
min | number | |
max | number | |
id | string | |
describedBy | string | |
placeholder | string | |
invalid | boolean | |
disabled | boolean | |
label | string |
Checkbox
A cushioned checkbox with a draw-in check.
npx shadcn@latest add https://pouf.worksonmy.dev/r/checkbox.jsonimport * as RCheck from '@radix-ui/react-checkbox'
import clsx from 'clsx'
interface CheckboxProps {
checked: boolean | 'indeterminate'
onChange: (checked: boolean | 'indeterminate') => void
id?: string
disabled?: boolean
label?: string
/** Keep `label` as the accessible name but stop rendering it visibly.
* For rows that already display the text themselves — without this, the
* only way to give the box an accessible name was to also print a second
* copy of the text next to it. Opt-in, so existing call sites are
* unchanged. */
hideLabel?: boolean
}
export function Checkbox({ checked, onChange, id, disabled, label, hideLabel }: CheckboxProps) {
return (
<div className="pouf-checkbox-row inline-flex items-center gap-(--s2)">
<RCheck.Root
id={id}
className={clsx([
'pouf-checkbox w-7 h-7 rounded-[9px] border-none p-0 cursor-pointer cushion-field flex-none',
'flex items-center justify-center',
'[transition:background_160ms_ease,transform_160ms_cubic-bezier(0.23,1,0.32,1)]',
'enabled:active:[transform:scale(0.92)]',
'data-[state=checked]:bg-purple disabled:opacity-50 disabled:cursor-not-allowed',
],
checked === 'indeterminate' ? 'bg-purple' : 'bg-bg')}
// Pass the real tri-state to Radix. Passing `checked === true` collapsed
// indeterminate to false, so Radix hid the Indicator entirely and the box
// painted a blank purple blob with no dash. Radix renders the Indicator for
// 'indeterminate' too, so the minus glyph below now shows.
checked={checked === 'indeterminate' ? 'indeterminate' : checked}
onCheckedChange={onChange}
disabled={disabled}
aria-label={label}
>
<RCheck.Indicator className={[
'pouf-checkbox__indicator text-ink flex [&_svg]:w-5 [&_svg]:h-5',
/* The check draws itself in. Radix mounts the indicator on check, so
* a keyframe entry is correct; uncheck unmounts instantly. Reduced
* motion keeps the state change legible, drops the drawing. */
'[&_path]:[stroke-dasharray:1]',
'[&_path]:[animation:pouf-check-draw_250ms_cubic-bezier(0.23,1,0.32,1)_both]',
'motion-reduce:[&_path]:[animation-name:pouf-check-fade]',
].join(' ')}>
{/* Inline rather than Icon: the draw-in animation needs pathLength=1
on the stroke, which the shared icon vocabulary has no reason to
carry. Geometry and stroke match the Tabler set (24 viewbox, 2.4
stroke, round caps) so the glyphs read as the same hand. */}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{checked === 'indeterminate' ? (
<path d="M5 12h14" pathLength={1} />
) : (
<path d="M5 12l5 5L20 7" pathLength={1} />
)}
</svg>
</RCheck.Indicator>
</RCheck.Root>
{label && !hideLabel && (
<label htmlFor={id} className="pouf-checkbox__label text-[15px] font-bold text-ink cursor-pointer">
{label}
</label>
)}
</div>
)
}| Prop | Type | Notes |
|---|---|---|
checked* | boolean | 'indeterminate' | |
onChange* | (checked: boolean | 'indeterminate') => void | |
id | string | |
disabled | boolean | |
label | string | |
hideLabel | boolean | Keep `label` as the accessible name but stop rendering it visibly. For rows that already display the text themselves — without this, the only way to give the box an accessible name was to also print a second copy of the text next to it. Opt-in, so existing call sites are unchanged. |
RadioGroup
Single-select, sized to match the checkbox.
npx shadcn@latest add https://pouf.worksonmy.dev/r/radio-group.jsonimport * as RRadio from '@radix-ui/react-radio-group'
interface RadioOption {
value: string
label: string
}
interface RadioGroupProps {
value: string
onChange: (value: string) => void
options: RadioOption[]
label: string
disabled?: boolean
}
export function RadioGroup({ value, onChange, options, label, disabled }: RadioGroupProps) {
return (
<RRadio.Root
className="pouf-radio-group flex flex-col gap-(--s2)"
value={value}
onValueChange={onChange}
disabled={disabled}
aria-label={label}
>
{options.map((o) => (
<div key={o.value} className="pouf-radio-row flex items-center gap-(--s2)">
<RRadio.Item id={`radio-${o.value}`} value={o.value} className={[
/* Sized with the checkbox — they sit in the same forms. */
'pouf-radio w-7 h-7 rounded-[50%] bg-bg border-none p-0 cursor-pointer cushion-field flex-none',
'flex items-center justify-center [transition:background_160ms_ease]',
'data-[state=checked]:bg-purple disabled:opacity-50 disabled:cursor-not-allowed',
].join(' ')}>
<RRadio.Indicator className={[
/* A white dot on the purple disc, not a dark one: an ink dot on
* lavender read as a pupil in a puffy eye. */
'pouf-radio__indicator w-2.5 h-2.5 rounded-[50%] bg-surface',
'[box-shadow:0_1px_1px_rgba(58,46,92,0.3)]',
].join(' ')} />
</RRadio.Item>
<label htmlFor={`radio-${o.value}`} className="pouf-radio__label text-[15px] font-bold text-ink cursor-pointer">
{o.label}
</label>
</div>
))}
</RRadio.Root>
)
}| Prop | Type | Notes |
|---|---|---|
value* | string | |
onChange* | (value: string) => void | |
options* | RadioOption[] | |
label* | string | |
disabled | boolean |
Slider
A bead of clay lying in its channel.
npx shadcn@latest add https://pouf.worksonmy.dev/r/slider.jsonimport * as RSlider from '@radix-ui/react-slider'
interface SliderProps {
value: number[]
onChange: (value: number[]) => void
min?: number
max?: number
step?: number
disabled?: boolean
label?: string
}
export function Slider({ value, onChange, min = 0, max = 100, step = 1, disabled, label }: SliderProps) {
return (
<RSlider.Root
className={[
'pouf-slider relative flex items-center w-full h-7 [touch-action:none] select-none',
'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
].join(' ')}
value={value}
onValueChange={onChange}
min={min}
max={max}
step={step}
disabled={disabled}
aria-label={label}
>
<RSlider.Track className="pouf-slider__track relative flex-1 h-3.5 rounded-[999px] bg-bg cushion-field">
<RSlider.Range className="pouf-slider__range absolute h-full rounded-[999px] bg-purple [box-shadow:inset_0_-3px_0_rgba(0,0,0,0.1)]" />
</RSlider.Track>
{value.map((_, i) => (
<RSlider.Thumb key={i} className={[
/* Ties to the range it drags via the same purple ring — one
* control, not a bead and a separate knob. */
'pouf-slider__thumb block w-[22px] h-[22px] rounded-[50%] bg-surface border-none cursor-pointer',
'[box-shadow:inset_0_0_0_3px_var(--purple),inset_0_-2px_0_rgba(0,0,0,0.08),0_3px_6px_rgba(58,46,92,0.22)]',
'[transition:box-shadow_140ms_ease,transform_140ms_ease]',
'hover:[transform:scale(1.08)] active:[transform:scale(0.94)]',
'focus-visible:[outline:3px_solid_var(--ink)] focus-visible:outline-offset-[3px]',
'data-[disabled]:cursor-not-allowed',
].join(' ')} aria-label={label ? `${label} thumb ${i + 1}` : undefined} />
))}
</RSlider.Root>
)
}| Prop | Type | Notes |
|---|---|---|
value* | number[] | |
onChange* | (value: number[]) => void | |
min | number | |
max | number | |
step | number | |
disabled | boolean | |
label | string |
Segmented
A pressed-in segment marks selection with depth, not colour.
npx shadcn@latest add https://pouf.worksonmy.dev/r/segmented.jsonimport clsx from 'clsx'
import { type Tone } from './tone'
import { buttonClasses } from './Button'
export interface SegmentedOption<T extends string> {
value: T
label: string
}
interface SegmentedProps<T extends string> {
value: T
onChange: (value: T) => void
options: SegmentedOption<T>[]
/** Names the group for a screen reader — "Showing", "View". Required: a bare
* set of buttons announces no reason for existing. */
label: string
tone?: Tone
}
/** A mutually-exclusive choice: grid vs list, draft vs live, and similar.
*
* Replaces a row of Buttons that signalled selection with `tone={mode === m ?
* 'purple' : 'blue'}` and nothing else. That was wrong twice over: colour was
* the only carrier of which option was active (WCAG 2.2 SC 1.4.1, Level A), and
* with no aria-pressed a screen reader announced two identical buttons and no
* current state.
*
* The selected segment is pressed IN — translateY plus the active shadow. That
* is depth, not hue, so it survives greyscale and any colour vision; and it is
* the system's existing vocabulary for "engaged", being exactly what every
* pouf-btn already does on :active. RowCard sets the precedent for aria-pressed
* as the selection signal.
*
* Generic in T so `<Segmented value={view} .../>` keeps the union ('grid' |
* 'list') end to end — passing an option this control cannot produce is a
* compile error, not a runtime surprise.
*/
export function Segmented<T extends string>({ value, onChange, options, label, tone = 'blue' }: SegmentedProps<T>) {
return (
<div className="pouf-seg inline-flex gap-(--s2)" role="group" aria-label={label}>
{options.map((o) => {
const on = o.value === value
return (
<button
key={o.value}
type="button"
className={clsx(buttonClasses({ size: 'sm', tone }), /* Pressed IN, not recoloured: depth is the system's word for engaged (SC
* 1.4.1 — tone alone vanishes in greyscale). aria-pressed carries it. */
'aria-pressed:[transform:translateY(2px)] aria-pressed:cushion-control-active')}
aria-pressed={on}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
)
})}
</div>
)
}| Prop | Type | Notes |
|---|---|---|
value* | T | |
onChange* | (value: T) => void | |
options* | SegmentedOption<T>[] | |
label* | string | Names the group for a screen reader — "Showing", "View". Required: a bare set of buttons announces no reason for existing. |
tone | Tone |
ToggleGroup
Multi-select toggles that press in when on.
npx shadcn@latest add https://pouf.worksonmy.dev/r/toggle.jsonimport * as RToggle from '@radix-ui/react-toggle-group'
import clsx from 'clsx'
import { type Tone } from './tone'
import { buttonClasses } from './Button'
import { renderIcon } from './Icon'
import type { IconLike } from './Icon'
export interface ToggleOption {
value: string
label: string
icon?: IconLike
}
interface ToggleGroupProps {
value: string[]
onChange: (value: string[]) => void
options: ToggleOption[]
label: string
tone?: Tone
}
export function ToggleGroup({ value, onChange, options, label, tone = 'purple' }: ToggleGroupProps) {
return (
<RToggle.Root
type="multiple"
value={value}
onValueChange={onChange}
className="pouf-toggle inline-flex flex-wrap gap-(--s2)"
aria-label={label}
>
{options.map((o) => (
// Literal pouf-btn classes, like Segmented: an "on" item is pressed in
// (pouf-toggle__item[data-state='on']), never merely re-coloured.
<RToggle.Item
key={o.value}
value={o.value}
className={clsx(buttonClasses({ size: 'sm', tone }), 'pouf-toggle__item data-[state=on]:[transform:translateY(2px)] data-[state=on]:cushion-control-active')}
>
{o.icon && renderIcon(o.icon, 'sm')}
{o.label}
</RToggle.Item>
))}
</RToggle.Root>
)
}| Prop | Type | Notes |
|---|---|---|
value* | string[] | |
onChange* | (value: string[]) => void | |
options* | ToggleOption[] | |
label* | string | |
tone | Tone |
Select, Switch, Dialog, Confirm, Tooltip, Combobox
The Radix-backed controls, skinned in clay.
npx shadcn@latest add https://pouf.worksonmy.dev/r/controls.jsonimport * as RSelect from '@radix-ui/react-select'
import * as RSwitch from '@radix-ui/react-switch'
import * as RTooltip from '@radix-ui/react-tooltip'
import * as RAlert from '@radix-ui/react-alert-dialog'
import * as RDialog from '@radix-ui/react-dialog'
import * as RPopover from '@radix-ui/react-popover'
import { useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { Button } from './Button'
import { Icon } from './Icon'
import { inputClasses } from './Input'
import { Row, Stack } from './layout'
import { Heading, Text } from './text'
import type { Tone } from './tone'
/* Radix supplies the behaviour (focus traps, typeahead, escape handling,
* aria wiring); pouf.css supplies the skin. We never restyle Radix inline —
* each part gets a pouf class, so a token change reaches inside the overlays
* exactly like it reaches the page. */
export interface SelectOption {
value: string
label: string
}
interface SelectProps {
value: string
onChange: (value: string) => void
options: SelectOption[]
id?: string
describedBy?: string
placeholder?: string
disabled?: boolean
label?: string
}
export function Select({ value, onChange, options, id, describedBy, placeholder, disabled, label }: SelectProps) {
return (
<RSelect.Root value={value} onValueChange={onChange} disabled={disabled}>
<RSelect.Trigger
id={id}
className={`${inputClasses()} flex flex-row items-center flex-wrap min-w-0 gap-[var(--gap,var(--s4))] justify-between`}
aria-describedby={describedBy}
aria-label={label}
>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon>
<Icon name="expand" size="sm" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content className="pouf-popover pouf-popover--select" position="popper" sideOffset={8}>
<RSelect.Viewport>
{options.map((o) => (
<RSelect.Item key={o.value} value={o.value} className="pouf-option">
<RSelect.ItemText>{o.label}</RSelect.ItemText>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
)
}
interface SwitchProps {
checked: boolean
onChange: (checked: boolean) => void
id?: string
describedBy?: string
disabled?: boolean
label?: string
}
export function Switch({ checked, onChange, id, describedBy, disabled, label }: SwitchProps) {
return (
<RSwitch.Root
id={id}
className={[
'pouf-switch group w-[60px] h-[34px] rounded-pill bg-bg border-none p-[3px] cursor-pointer',
'cushion-field flex-none [transition:background_160ms_ease]',
'data-[state=checked]:bg-mint disabled:opacity-50 disabled:cursor-not-allowed',
].join(' ')}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-describedby={describedBy}
aria-label={label}
>
<RSwitch.Thumb className={[
'pouf-switch__thumb block w-7 h-7 rounded-[50%] bg-surface',
'[box-shadow:inset_0_-3px_0_rgba(0,0,0,0.12),inset_0_2px_0_rgba(255,255,255,0.9),0_4px_8px_rgba(58,46,92,0.2)]',
'[transition:transform_160ms_cubic-bezier(0.2,0.9,0.3,1.3)] [transform:translateX(0)]',
'group-data-[state=checked]:[transform:translateX(26px)]',
].join(' ')} />
</RSwitch.Root>
)
}
export function TooltipProvider({ children }: { children: ReactNode }) {
return <RTooltip.Provider delayDuration={300}>{children}</RTooltip.Provider>
}
/** Tooltip owns its anchor rather than asChild-ing onto its child.
*
* `asChild` clones the child and merges handlers, aria-* and a ref onto it. No
* pouf primitive can receive any of that: they take closed prop lists and
* forward no ref, which is the same rule that guarantees no primitive accepts a
* className. So `<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>`
* dropped every prop on the floor and the tooltip never opened — on anything.
* It looked fine because the one call site that mattered had
* hand-wrapped its Switch in a raw <span>, which is what actually made it work.
*
* Anchoring here instead of forwarding props in Button/Switch also handles the
* case prop-forwarding cannot: a disabled control fires no pointer events at
* all (HTML, not Radix), so only an element *around* it can carry the hover.
*
* Known limit: Radix puts aria-describedby on the trigger, which is now this
* span rather than the control. The tip is a supplementary visual affordance —
* controls carry their own accessible names via `label` — but a screen reader
* focusing the child will not hear the tip. Fixing that properly means opening
* the primitives' prop surface, which trades a real guarantee for a small win.
* Covered by the gallery snapshot gate, including the disabled case.
*/
export function Tooltip({ tip, children }: { tip: string; children: ReactNode }) {
return (
<RTooltip.Root>
<RTooltip.Trigger asChild>
<span className="pouf-tip-anchor">{children}</span>
</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content className="pouf-tooltip" sideOffset={8}>
{tip}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
)
}
interface DialogProps {
/** The element that opens it. */
trigger: ReactNode
title: string
description?: string
children: ReactNode
/** Controlled open state — needed when the dialog must close in response to
* something inside it (picking an item), not just the close button. */
open?: boolean
onOpenChange?: (open: boolean) => void
size?: 'md' | 'lg'
}
/** A plain modal (Radix Dialog), distinct from Confirm (Radix AlertDialog).
*
* The distinction is behavioural, not cosmetic: an alert dialog is for a
* decision with consequences — it takes focus assertively and cannot be
* dismissed by clicking outside. This one is for browsing, so it dismisses
* easily. Using AlertDialog to show a plain list would train the user to
* dismiss the same chrome that later asks them to confirm something destructive.
*/
export function Dialog({ trigger, title, description, children, open, onOpenChange, size = 'md' }: DialogProps) {
return (
<RDialog.Root open={open} onOpenChange={onOpenChange}>
<RDialog.Trigger asChild>{trigger}</RDialog.Trigger>
<RDialog.Portal>
{/* Enter AND exit are CSS animations keyed off Radix's data-state. Radix's
own Presence keeps the node mounted until the [data-state='closed']
animation finishes, so the close animates without framer — and without
framer's inline transform, the CSS translate(-50%,-50%) centring holds
through both. (prefers-reduced-motion is honoured globally in pouf.css.) */}
<RDialog.Overlay className="pouf-overlay" />
<RDialog.Content className={size === 'lg' ? 'pouf-dialog pouf-dialog--lg' : 'pouf-dialog'}>
<Stack gap={4}>
<div className="pouf-dialog__head">
<Stack gap={1}>
<RDialog.Title asChild>
<div>
<Heading level={3}>{title}</Heading>
</div>
</RDialog.Title>
{description && (
<RDialog.Description asChild>
<div>
<Text size="sm" muted>
{description}
</Text>
</div>
</RDialog.Description>
)}
</Stack>
<RDialog.Close asChild>
<Button variant="quiet" size="sm" label="Close">
<Icon name="close" size="sm" />
</Button>
</RDialog.Close>
</div>
<div className="pouf-dialog__body">{children}</div>
</Stack>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
)
}
interface ConfirmProps {
children: ReactNode
title: string
body: string
/** Must name the outcome, not agree with a question: "Close 3 positions",
* never "Yes"/"OK". */
confirmLabel: string
/** Must name the opposite outcome: "Keep positions open", never "Cancel".
* Required, because a sensible default here does not exist — only the caller
* knows what NOT doing the thing means. */
cancelLabel: string
onConfirm: () => void
tone?: Tone
loading?: boolean
/** The specific records this will affect — symbols, sizes, live P&L. */
details?: ReactNode
}
/** Every irreversible action routes through here — delete an account, empty a
* trash, revoke a key, disconnect an integration. Radix gives the focus trap
* and escape handling.
*
* The API is shaped by NN/g's confirmation-dialog guidance, which is why it
* looks stricter than a typical confirm:
* - Yes/No is banned by construction: both labels are required strings, so a
* caller cannot ship "Are you sure? [Yes] [No]" without deliberately typing
* something worse than the alternative.
* - `details` exists because "Delete all items?" is not answerable. "Delete
* Invoice #1042 and Invoice #1043 (both paid)" is.
* - Cancel is the default focus and sits away from confirm (Fitts, inverted):
* the safe option should be the easy one to hit.
* Confirmation is used here only because undo is genuinely impossible. Overusing
* it would train the user to click through, which is the failure mode NN/g warns of.
*/
export function Confirm({
children,
title,
body,
confirmLabel,
cancelLabel,
onConfirm,
tone = 'orange',
loading,
details,
}: ConfirmProps) {
return (
<RAlert.Root>
<RAlert.Trigger asChild>{children}</RAlert.Trigger>
<RAlert.Portal>
{/* Enter/exit via Radix data-state CSS animations — same reasoning as Dialog. */}
<RAlert.Overlay className="pouf-overlay" />
<RAlert.Content className="pouf-dialog">
<Stack gap={4}>
<RAlert.Title asChild>
<div>
<Heading level={3}>{title}</Heading>
</div>
</RAlert.Title>
<RAlert.Description asChild>
<div>
<Text muted>{body}</Text>
</div>
</RAlert.Description>
{details}
<Row gap={3} justify="end">
<RAlert.Cancel asChild>
<Button variant="quiet" size="sm">
{cancelLabel}
</Button>
</RAlert.Cancel>
<RAlert.Action asChild>
<Button tone={tone} size="sm" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</RAlert.Action>
</Row>
</Stack>
</RAlert.Content>
</RAlert.Portal>
</RAlert.Root>
)
}
interface ComboboxProps {
value: string
onChange: (value: string) => void
/** The ids fetched from the provider. `[]` is a valid working state, not an error:
* an endpoint with no /models leaves this empty and the field must still work. */
options: string[]
loading?: boolean
/** Why the list is missing. Rendered inside the popover; never blocks typing. */
error?: string
/** Fired on open — the caller uses it to trigger the lazy fetch. */
onOpen?: () => void
id?: string
describedBy?: string
placeholder?: string
mono?: boolean
}
/** A typeable select: pick from the provider's list, or type an id it doesn't offer.
*
* The one pouf primitive with hand-rolled keyboard nav, because Radix ships no
* combobox and Select cannot take arbitrary text. Radix Popover still supplies the
* parts that are easy to get wrong — portalling, escape, outside-click, focus return
* to the trigger — so only the arrow/enter handling is ours.
*
* Typing is not a fallback bolted on for one edge case; it is the contract.
* A source of options may not exist yet, may 404, may be unreachable, or may simply
* not include the value the user needs. In every one of those cases this degrades
* to the free-text input it replaced, which is why `error` never disables it.
*/
export function Combobox({
value,
onChange,
options,
loading,
error,
onOpen,
id,
describedBy,
placeholder,
mono,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
return q ? options.filter((o) => o.toLowerCase().includes(q)) : options
}, [options, query])
const typed = query.trim()
const canCreate = typed.length > 0 && !options.includes(typed)
const createIndex = shown.length
const commit = (v: string) => {
onChange(v)
setOpen(false)
}
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const max = shown.length + (canCreate ? 1 : 0) - 1
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((i) => Math.min(i + 1, max))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
if (canCreate && active === createIndex) commit(typed)
else if (shown[active] !== undefined) commit(shown[active])
}
}
return (
<RPopover.Root
open={open}
onOpenChange={(o) => {
setOpen(o)
if (o) {
setQuery('')
setActive(0)
onOpen?.()
}
}}
>
<RPopover.Trigger asChild>
<button
type="button"
id={id}
role="combobox"
aria-expanded={open}
aria-describedby={describedBy}
className={clsx(
inputClasses({ mono }),
/* Row's layout, inlined: the trigger borrowed .pouf-row classes,
* which no longer exist as CSS rules after layout.tsx migrated. */
'flex flex-row items-center flex-wrap min-w-0 gap-[var(--gap,var(--s4))] justify-between',
)}
>
<span className={clsx(!value && 'pouf-combobox__placeholder')}>{value || placeholder}</span>
<Icon name="expand" size="sm" />
</button>
</RPopover.Trigger>
<RPopover.Portal>
<RPopover.Content className="pouf-popover pouf-popover--combobox" sideOffset={8} align="start">
<input
className={inputClasses({ mono })}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={onKeyDown}
placeholder="Search, or type a model id"
aria-label="Search models"
autoFocus
/>
<div className="pouf-combobox__list" role="listbox">
{loading && <div className="pouf-combobox__note">Loading models…</div>}
{!loading && error && <div className="pouf-combobox__note">{error}</div>}
{!loading &&
shown.map((o, i) => (
<button
key={o}
type="button"
role="option"
aria-selected={o === value}
// Set by hand because these rows are plain buttons, not Radix Select
// items — this is what lets them reuse .pouf-option's existing skin
// (pouf.css nav-link section) instead of duplicating it.
data-highlighted={i === active ? '' : undefined}
data-state={o === value ? 'checked' : undefined}
// .pouf-option already supplies flex/align/gap; only the
// right-pushed check needs justify-between (+ wrap/min-w-0).
className="pouf-option flex-wrap min-w-0 justify-between"
onClick={() => commit(o)}
onMouseEnter={() => setActive(i)}
>
<span>{o}</span>
{o === value && <Icon name="ok" size="sm" />}
</button>
))}
{canCreate && (
<button
type="button"
role="option"
aria-selected={false}
data-highlighted={active === createIndex ? '' : undefined}
className="pouf-option"
onClick={() => commit(typed)}
onMouseEnter={() => setActive(createIndex)}
>
Use “{typed}”
</button>
)}
{!loading && !error && shown.length === 0 && !canCreate && (
<div className="pouf-combobox__note">No models offered — type an id.</div>
)}
</div>
</RPopover.Content>
</RPopover.Portal>
</RPopover.Root>
)
}Combobox
| Prop | Type | Notes |
|---|---|---|
value* | string | |
onChange* | (value: string) => void | |
options* | string[] | The ids fetched from the provider. `[]` is a valid working state, not an error: an endpoint with no /models leaves this empty and the field must still work. |
loading | boolean | |
error | string | Why the list is missing. Rendered inside the popover; never blocks typing. |
onOpen | () => void | Fired on open — the caller uses it to trigger the lazy fetch. |
id | string | |
describedBy | string | |
placeholder | string | |
mono | boolean |
Confirm
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
title* | string | |
body* | string | |
confirmLabel* | string | Must name the outcome, not agree with a question: "Close 3 positions", never "Yes"/"OK". |
cancelLabel* | string | Must name the opposite outcome: "Keep positions open", never "Cancel". Required, because a sensible default here does not exist — only the caller knows what NOT doing the thing means. |
onConfirm* | () => void | |
tone | Tone | |
loading | boolean | |
details | ReactNode | The specific records this will affect — symbols, sizes, live P&L. |
Dialog
| Prop | Type | Notes |
|---|---|---|
trigger* | ReactNode | The element that opens it. |
title* | string | |
description | string | |
children* | ReactNode | |
open | boolean | Controlled open state — needed when the dialog must close in response to something inside it (picking an item), not just the close button. |
onOpenChange | (open: boolean) => void | |
size | 'md' | 'lg' |
Select
| Prop | Type | Notes |
|---|---|---|
value* | string | |
onChange* | (value: string) => void | |
options* | SelectOption[] | |
id | string | |
describedBy | string | |
placeholder | string | |
disabled | boolean | |
label | string |
Switch
| Prop | Type | Notes |
|---|---|---|
checked* | boolean | |
onChange* | (checked: boolean) => void | |
id | string | |
describedBy | string | |
disabled | boolean | |
label | string |
HoverCard
A card that rises on hover.
npx shadcn@latest add https://pouf.worksonmy.dev/r/hover-card.jsonimport * as RHover from '@radix-ui/react-hover-card'
import type { ReactNode } from 'react'
import { motion } from 'framer-motion'
interface HoverCardProps {
children: ReactNode
content: ReactNode
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}
export function HoverCard({ children, content, side = 'bottom', align = 'center' }: HoverCardProps) {
return (
<RHover.Root openDelay={300} closeDelay={200}>
<RHover.Trigger asChild>
<span className="pouf-hover__anchor">{children}</span>
</RHover.Trigger>
<RHover.Portal>
<RHover.Content className="pouf-hover" sideOffset={8} side={side} align={align} asChild>
<motion.div
initial={{ opacity: 0, scale: 0.97, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
{content}
</motion.div>
</RHover.Content>
</RHover.Portal>
</RHover.Root>
)
}| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
content* | ReactNode | |
side | 'top' | 'right' | 'bottom' | 'left' | |
align | 'start' | 'center' | 'end' |
Sheet
A side panel that slides in from the edge.
npx shadcn@latest add https://pouf.worksonmy.dev/r/sheet.jsonimport * as RDialog from '@radix-ui/react-dialog'
import type { ReactNode } from 'react'
import { Button } from './Button'
import { Icon } from './Icon'
import { Stack } from './layout'
import { Heading, Text } from './text'
interface SheetProps {
/** Omit for a fully controlled sheet (open/onOpenChange) with no in-place
* trigger — e.g. opened from a table row click. */
trigger?: ReactNode
title: string
description?: string
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
/** A side sheet — NOT a centred dialog.
*
* The old Sheet reused .pouf-dialog, so "Open sheet" produced something visually
* identical to a modal. A sheet reads as a panel that slides in from an edge: the
* right on desktop, the bottom on a phone (where the thumb is). Enter and exit are
* CSS animations keyed off Radix's data-state — Radix's Presence keeps the node
* mounted until the close animation ends, so it slides out too, no framer needed.
*/
export function Sheet({ trigger, title, description, children, open, onOpenChange }: SheetProps) {
return (
<RDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="pouf-overlay" />
<RDialog.Content className="pouf-sheet-dialog">
<div className="pouf-sheet-panel">
<div className="pouf-dialog__head">
<Stack gap={1}>
<RDialog.Title asChild>
<div>
<Heading level={3}>{title}</Heading>
</div>
</RDialog.Title>
{description && (
<RDialog.Description asChild>
<div>
<Text size="sm" muted>
{description}
</Text>
</div>
</RDialog.Description>
)}
</Stack>
<RDialog.Close asChild>
<Button variant="quiet" size="sm" label="Close">
<Icon name="close" size="sm" />
</Button>
</RDialog.Close>
</div>
<div className="pouf-dialog__body">{children}</div>
</div>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
)
}| Prop | Type | Notes |
|---|---|---|
trigger | ReactNode | Omit for a fully controlled sheet (open/onOpenChange) with no in-place trigger — e.g. opened from a table row click. |
title* | string | |
description | string | |
children* | ReactNode | |
open | boolean | |
onOpenChange | (open: boolean) => void |
Tabs, Accordion, Collapsible
Panels that switch and reveal.
npx shadcn@latest add https://pouf.worksonmy.dev/r/disclosure.jsonimport * as RAccordion from '@radix-ui/react-accordion'
import * as RTabs from '@radix-ui/react-tabs'
import * as RCollapse from '@radix-ui/react-collapsible'
import clsx from 'clsx'
import { buttonClasses } from './Button'
import { useState, type ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Icon } from './Icon'
import { toneClass, type Tone } from './tone'
/* ------------------------------------------------------------------ */
/* Tabs */
/* ------------------------------------------------------------------ */
interface Tab {
value: string
label: string
content: ReactNode
}
interface TabsProps {
tabs: Tab[]
value: string
onChange: (value: string) => void
tone?: Tone
}
/** Panel switching in the Segmented voice: each trigger IS a small clay button
* and the active one is pressed in. Tabs and Segmented are the same control at
* different altitudes — one swaps a panel, the other a value — so giving tabs
* a second visual language (the underline rail this replaces) made the UI
* claim they were different things. */
export function Tabs({ tabs, value, onChange, tone = 'blue' }: TabsProps) {
return (
<RTabs.Root className="pouf-tabs" value={value} onValueChange={onChange}>
<RTabs.List className="pouf-tabs__list">
{tabs.map((t) => (
<RTabs.Trigger
key={t.value}
value={t.value}
className={clsx(buttonClasses({ size: 'sm', tone }), 'pouf-tabs__trigger')}
>
{t.label}
</RTabs.Trigger>
))}
</RTabs.List>
<AnimatePresence mode="wait">
{tabs.map(
(t) =>
t.value === value && (
<RTabs.Content
key={t.value}
value={t.value}
className="pouf-tabs__content"
forceMount
asChild
>
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
{t.content}
</motion.div>
</RTabs.Content>
),
)}
</AnimatePresence>
</RTabs.Root>
)
}
/* ------------------------------------------------------------------ */
/* Accordion */
/* ------------------------------------------------------------------ */
interface AccordionItem {
value: string
title: string
children: ReactNode
}
interface AccordionProps {
items: AccordionItem[]
defaultValue?: string
}
/** Plain Radix + the CSS keyframes, no framer-motion. The motion build kept
* every panel forceMounted with `animate="open"` hard-coded, so nothing ever
* told a closed panel to close: all three sat expanded forever. Radix already
* mounts content on open and holds it through the exit animation — the CSS
* pouf-accordion-down/up keyframes are the whole choreography. */
export function Accordion({ items, defaultValue }: AccordionProps) {
return (
<RAccordion.Root
className="pouf-accordion"
type="single"
defaultValue={defaultValue as string}
collapsible
>
{items.map((item) => (
<RAccordion.Item key={item.value} value={item.value} className="pouf-accordion__item">
<RAccordion.Header>
<RAccordion.Trigger className="pouf-accordion__trigger">
<span className="pouf-accordion__title">{item.title}</span>
<span className="pouf-accordion__chevron">
<Icon name="expand" size="sm" />
</span>
</RAccordion.Trigger>
</RAccordion.Header>
<RAccordion.Content className="pouf-accordion__content">
<div className="pouf-accordion__body">{item.children}</div>
</RAccordion.Content>
</RAccordion.Item>
))}
</RAccordion.Root>
)
}
/* ------------------------------------------------------------------ */
/* Collapsible */
/* ------------------------------------------------------------------ */
interface CollapsibleProps {
children: ReactNode
trigger: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function Collapsible({ children, trigger, open: controlledOpen, onOpenChange: controlledOnOpen }: CollapsibleProps) {
const [internalOpen, setInternalOpen] = useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : internalOpen
const setOpen = isControlled ? controlledOnOpen : setInternalOpen
return (
<RCollapse.Root open={open} onOpenChange={setOpen}>
<RCollapse.Trigger asChild>
<button type="button" className="pouf-collapsible__trigger">
{trigger}
</button>
</RCollapse.Trigger>
<RCollapse.Content className="pouf-collapsible__content" forceMount>
<motion.div
initial={false}
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
transition={{ duration: 0.2, ease: [0.2, 0.9, 0.3, 1] }}
style={{ overflow: 'hidden' }}
>
<div className="pouf-collapsible__body">{children}</div>
</motion.div>
</RCollapse.Content>
</RCollapse.Root>
)
}Accordion
| Prop | Type | Notes |
|---|---|---|
items* | AccordionItem[] | |
defaultValue | string |
Collapsible
| Prop | Type | Notes |
|---|---|---|
children* | ReactNode | |
trigger* | ReactNode | |
open | boolean | |
onOpenChange | (open: boolean) => void |
Tabs
| Prop | Type | Notes |
|---|---|---|
tabs* | Tab[] | |
value* | string | |
onChange* | (value: string) => void | |
tone | Tone |
Pagination
Page-by-page navigation.
npx shadcn@latest add https://pouf.worksonmy.dev/r/pagination.jsonimport { Button } from './Button'
import { Row } from './layout'
import { Icon } from './Icon'
import { Text } from './text'
interface PaginationProps {
page: number
total: number
onChange: (page: number) => void
}
/** Chevrons, not arrows: `up`/`down` carry trend meaning in this system, and the
* first build used them here — a "previous page" that read as a value falling.
* The indicator is plain ink; muted fails contrast on --bg at 13px. */
export function Pagination({ page, total, onChange }: PaginationProps) {
if (total <= 1) return null
return (
<Row gap={2} justify="end">
<Button variant="quiet" size="sm" disabled={page <= 1} onClick={() => onChange(page - 1)} label="Previous page">
<Icon name="prev" size="sm" />
</Button>
<Text size="sm" num>
{page} / {total}
</Text>
<Button variant="quiet" size="sm" disabled={page >= total} onClick={() => onChange(page + 1)} label="Next page">
<Icon name="next" size="sm" />
</Button>
</Row>
)
}| Prop | Type | Notes |
|---|---|---|
page* | number | |
total* | number | |
onChange* | (page: number) => void |
CTA
A call-to-action banner — the one loud thing.
npx shadcn@latest add https://pouf.worksonmy.dev/r/cta.jsonimport { cva } from 'class-variance-authority'
import type { ReactNode } from 'react'
const cta = cva('pouf-cta rounded-card px-(--s7) py-(--s8) flex flex-col items-center text-center gap-(--s4) cushion-control', {
variants: {
tone: {
purple: 'bg-purple',
pink: 'bg-pink',
blue: 'bg-blue',
mint: 'bg-mint',
yellow: 'bg-yellow',
surface: 'bg-surface',
},
},
defaultVariants: { tone: 'purple' },
})
interface CTAProps {
title: string
description?: string
/** The action(s) — a Button, or two. */
action: ReactNode
tone?: 'purple' | 'pink' | 'blue' | 'mint' | 'yellow' | 'surface'
}
/** A call-to-action banner: a big tonal cushion with a headline and an action.
* The one loud thing at the bottom of a page. */
export function CTA({ title, description, action, tone }: CTAProps) {
return (
<section className={cta({ tone })}>
<h2 className="m-0 font-black text-[clamp(28px,4vw,44px)] tracking-[-0.02em] leading-[1.05] text-ink">{title}</h2>
{description && <p className="m-0 font-bold text-[18px] text-ink/70 max-w-[52ch]">{description}</p>}
<div className="flex flex-wrap gap-(--s3) justify-center mt-(--s2)">{action}</div>
</section>
)
}| Prop | Type | Notes |
|---|---|---|
title* | string | |
description | string | |
action* | ReactNode | The action(s) — a Button, or two. |
tone | 'purple' | 'pink' | 'blue' | 'mint' | 'yellow' | 'surface' |
Status, Freshness, ModeBanner
Liveness signals and the environment banner.
npx shadcn@latest add https://pouf.worksonmy.dev/r/status.jsonimport clsx from 'clsx'
import { useEffect, useState } from 'react'
import { Row } from './layout'
import { Text } from './text'
import { Blob, Dot } from './media'
import { toneClass, type Tone } from './tone'
/** How old a reading may be before we stop vouching for it. A dashboard that
* polls every 5s means 15s is "we've missed roughly three polls" — that's a
* connection problem, not jitter. */
const STALE_AFTER_MS = 15_000
export interface StatusProps {
label: string
tone: Tone
/** Epoch ms of the reading. Omit only for values that cannot go stale. */
at?: number
}
function duration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m`
return `${Math.floor(m / 60)}h`
}
function age(ms: number): string {
return `${duration(ms)} ago`
}
/** A status readout that refuses to overstate its own confidence.
*
* Two things this fixes, both of which a bare coloured dot gets wrong:
*
* 1. A green dot on a polled UI means "green as of some past moment we aren't
* showing you." That can be worse than no indicator: the user reads
* "connected" off a value that may be a minute stale, from a poll that has
* been failing. So a reading past STALE_AFTER_MS drops to an explicit unknown
* state rather than keeping its last colour.
* 2. WCAG 2.2 SC 1.4.1 (Level A) forbids colour as the only carrier of
* information. The dot is decorative (aria-hidden) and the text is the
* actual signal — so this reads correctly with no colour vision at all.
*/
export function Status({ label, tone, at }: StatusProps) {
// Re-render on a timer so the age text ages even when no data arrives —
// otherwise a dead poll leaves a confident "2s ago" frozen on screen forever,
// which is the exact lie this component exists to prevent.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (at === undefined) return
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [at])
const elapsed = at === undefined ? 0 : Math.max(0, now - at)
const stale = at !== undefined && elapsed > STALE_AFTER_MS
return (
<Row gap={2} wrap={false}>
<Dot tone={stale ? 'warn' : tone} />
<Text size="sm">{stale ? `${label} — unconfirmed` : label}</Text>
{at !== undefined && (
<Text size="sm" muted num>
{age(elapsed)}
</Text>
)}
</Row>
)
}
interface FreshnessProps {
/** react-query's dataUpdatedAt — when a fetch last SUCCEEDED. */
at: number
isError?: boolean
}
/** Speaks only when the data on screen can no longer be trusted.
*
* A healthy poll renders nothing: a routine "Updated 2s ago" readout on every
* screen is chatter, and chatter trains the user to stop reading. What
* remains are the two states that must not be missed — the most recent fetch
* failed (what's on screen is the last known value, not the current one), or
* no fetch has succeeded for longer than STALE_AFTER_MS.
*/
export function Freshness({ at, isError }: FreshnessProps) {
// The 1s tick still runs while healthy: staleness is the *absence* of new
// data, so nothing else would trigger the re-render that surfaces it.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const elapsed = at > 0 ? Math.max(0, now - at) : 0
const stale = at > 0 && elapsed > STALE_AFTER_MS
if (!isError && !stale) return null
return (
<Row gap={2} wrap={false}>
<Dot tone={isError ? 'down' : 'warn'} />
<Text size="sm" muted>
{isError ? 'Update failed — showing last known' : `No update for ${duration(elapsed)}`}
</Text>
</Row>
)
}
/** The draft/live (or any two-mode) indicator.
*
* Lives in the shell rather than on a settings screen: the single most
* expensive mistake it guards against is believing you are in the safe mode
* while you are actually live, and a checkbox you have to go and look at cannot
* prevent it. Text carries the state; the tone and pulse only amplify it (SC 1.4.1).
*/
export function ModeBanner({ live }: { live: boolean }) {
return (
<div
className={clsx('pouf-mode', live && 'pouf-mode--live', toneClass(live ? 'orange' : 'blue'))}
role="status"
aria-live="polite"
>
<Blob tone={live ? 'orange' : 'blue'} size="sm" icon={live ? 'live' : 'draft'} />
<Text>{live ? 'LIVE — changes are public' : 'DRAFT — only you can see this'}</Text>
</div>
)
}Freshness
| Prop | Type | Notes |
|---|---|---|
at* | number | react-query's dataUpdatedAt — when a fetch last SUCCEEDED. |
isError | boolean |
Status
| Prop | Type | Notes |
|---|---|---|
label* | string | |
tone* | Tone | |
at | number | Epoch ms of the reading. Omit only for values that cannot go stale. |
Empty, Skeleton, ErrorNote
Designed empty, loading, and error states.
npx shadcn@latest add https://pouf.worksonmy.dev/r/feedback.jsonimport clsx from 'clsx'
import type { ReactNode } from 'react'
import { Blob } from './media'
import { Icon } from './Icon'
import { Stack } from './layout'
import { Text } from './text'
import type { IconLike } from './Icon'
export function Empty({ icon = 'idle', title, children }: { icon?: IconLike; title: string; children?: ReactNode }) {
return (
<div className="pouf-empty">
<Blob tone="purple" size="md" icon={icon} />
<Text>{title}</Text>
{children && (
<Text size="sm" muted>
{children}
</Text>
)}
</div>
)
}
export function Skeleton({ variant = 'row', count = 1 }: { variant?: 'text' | 'row' | 'card'; count?: number }) {
return (
<Stack gap={3}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className={clsx('pouf-skeleton', `pouf-skeleton--${variant}`)} aria-hidden="true" />
))}
</Stack>
)
}
/** Failure is a first-class state here: an app that talks to a network must say
* when a request died, rather than render an empty list that reads as
* "you have nothing".
*
* A puffy clay cushion with a raised warning blob — the same physical language
* as a toast, not the flat orange slab it used to be. */
export function ErrorNote({ children }: { children: ReactNode }) {
return (
<div className="pouf-error-note" role="alert">
<span className="pouf-error-note__icon">
<Icon name="warn" size="sm" />
</span>
<span className="pouf-error-note__text">{children}</span>
</div>
)
}Toasts
Pushed alerts with a puffy fill and a raised icon.
npx shadcn@latest add https://pouf.worksonmy.dev/r/toaster.jsonimport { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
import { Icon } from './Icon'
import type { IconName } from './Icon'
/* ============================================================================
* Programmatic toast API — `toast("message")` from anywhere.
*
* Modeled after sonner / shadcn. A module-level array + subscriber pattern so
* screen code never touches toast state. Call `toast("Saved")` and a Toaster
* mounted at the root handles rendering, auto-dismiss, and stacking.
* ========================================================================= */
/* -- types ---------------------------------------------------------------- */
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
interface ToastAction {
label: string
onClick: () => void
}
interface ToastOptions {
/** Secondary text beneath the title. */
description?: string
/** Auto-dismiss after this many ms. Pass Infinity for a sticky toast. */
duration?: number
/** An action button. */
action?: ToastAction
}
interface ToastEntry {
id: string
title: string
description?: string
variant: ToastVariant
duration: number
action?: ToastAction
}
/* -- module-level state --------------------------------------------------- */
let count = 0
let toasts: ToastEntry[] = []
let listeners: Array<() => void> = []
function emit() {
for (const fn of listeners) fn()
}
function add(entry: ToastEntry) {
toasts = [...toasts, entry]
emit()
}
function remove(id: string) {
toasts = toasts.filter((t) => t.id !== id)
emit()
}
/* -- public API ----------------------------------------------------------- */
/* Roles, not moods. `up` means direction of a value (a "saved OK" toast must not
* claim a number went up), and error/warning sharing the triangle made "upload
* failed" and "approaching your quota" the same visual claim. */
const ICON: Record<ToastVariant, IconName> = {
default: 'ok',
success: 'ok',
error: 'fail',
warning: 'warn',
info: 'info',
}
const DEFAULT_DURATION = 5_000
function createToast(variant: ToastVariant) {
return (title: string, opts?: ToastOptions) => {
const id = `toast-${++count}`
const entry: ToastEntry = {
id,
title,
description: opts?.description,
variant,
duration: opts?.duration ?? DEFAULT_DURATION,
action: opts?.action,
}
add(entry)
return id
}
}
export const toast = Object.assign(createToast('default'), {
success: createToast('success'),
error: createToast('error'),
warning: createToast('warning'),
info: createToast('info'),
})
export function dismissToast(id: string) {
remove(id)
}
/* -- Toast item component ------------------------------------------------- */
/** Comes in from the side, tiny, and fades up to full size — a gentle spring,
* not a bounce (the bouncy version fought the layout reflow and stuttered).
* Tuned live in docs/toast-anim-playground.html. Under prefers-reduced-motion it
* collapses to a plain opacity fade — WCAG 2.3.3. (docs/pouf-components-research.md) */
function toastMotion(reduce: boolean) {
if (reduce) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.12 },
}
}
return {
initial: { opacity: 0, x: 140, scale: 0.7 },
animate: { opacity: 1, x: 0, scale: 1 },
exit: { opacity: 0, x: 100, scale: 0.8, transition: { duration: 0.14, ease: 'easeIn' as const } },
transition: { type: 'spring' as const, stiffness: 280, damping: 26, mass: 1 },
}
}
function ToastItem({ t, onDismiss }: { t: ToastEntry; onDismiss: (id: string) => void }) {
const sticky = t.duration === Infinity
const reduce = useReducedMotion() ?? false
useEffect(() => {
if (sticky) return
const timer = setTimeout(() => onDismiss(t.id), t.duration)
return () => clearTimeout(timer)
}, [sticky, t.duration, t.id, onDismiss])
return (
<motion.div
className={`pouf-toast pouf-toast--${t.variant}`}
role={t.variant === 'error' ? 'alert' : 'status'}
aria-live={t.variant === 'error' ? 'assertive' : 'polite'}
layout="position"
{...toastMotion(reduce)}
>
<div className="pouf-toast__icon">
<Icon name={ICON[t.variant]} size="sm" />
</div>
<div className="pouf-toast__body">
<div className="pouf-toast__title">{t.title}</div>
{t.description && <div className="pouf-toast__desc">{t.description}</div>}
{t.action && (
<button type="button" className="pouf-toast__action" onClick={t.action.onClick}>
{t.action.label}
</button>
)}
</div>
<button type="button" className="pouf-toast__close" onClick={() => onDismiss(t.id)} aria-label="Dismiss notification">
<Icon name="close" size="sm" />
</button>
</motion.div>
)
}
/* -- Toaster component ---------------------------------------------------- */
/** Renders bare items, no positioned wrapper: the shell owns ONE .pouf-toasts
* stack and mounts this beside ToastViewport inside it. When each system
* carried its own fixed stack they sat at identical coordinates, and a critical
* alert could render underneath a routine "saved" toast. */
export function Toaster() {
const [items, setItems] = useState<ToastEntry[]>(toasts)
useEffect(() => {
// Remove OUR listener on unmount. The old cleanup dropped whichever
// subscriber happened to be last in the array — with two Toasters ever
// mounted (e.g. StrictMode double-mount), one went permanently deaf.
const listener = () => setItems([...toasts])
listeners.push(listener)
return () => {
listeners = listeners.filter((l) => l !== listener)
}
}, [])
const dismiss = useCallback((id: string) => remove(id), [])
return (
// popLayout so a dismissed toast is pulled out of flow immediately and the
// survivors spring up to close the gap, rather than the stack jumping.
<AnimatePresence mode="popLayout">
{items.map((t) => (
<ToastItem key={t.id} t={t} onDismiss={dismiss} />
))}
</AnimatePresence>
)
}AlertBell
The record behind the toasts.
npx shadcn@latest add https://pouf.worksonmy.dev/r/alert-bell.jsonimport { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
import { Icon } from './Icon'
import { Text } from './text'
import { Empty } from './feedback'
/** The alert history behind the toasts.
*
* Toasts are the interruption; this is the record. It exists because a toast that has
* faded (or that fired while the tab was in the background) must still be discoverable —
* otherwise "I stepped away for ten minutes" means "I have no idea what happened".
*
* It is a *session* record, not an archive: it holds what the app kept in memory,
* while your own backend remains the permanent log.
*/
export interface AlertBellItem {
id: number
severity: 'critical' | 'info'
at: string
text: string
}
/** Same `*bold*` handling as Toast — see the note there on why the markers survive. */
function emphasise(text: string): ReactNode[] {
return text
.split(/\*([^*]+)\*/g)
.map((part, i) => (i % 2 === 1 ? <strong key={i}>{part}</strong> : <span key={i}>{part}</span>))
}
interface Props {
alerts: AlertBellItem[]
unread: number
connected: boolean
onOpen: () => void
}
export function AlertBell({ alerts, unread, connected, onOpen }: Props) {
const [open, setOpen] = useState(false)
const root = useRef<HTMLDivElement>(null)
// Close on outside click and on Escape. A panel pinned open over the dashboard is
// worse than one that is slightly too eager to close.
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!root.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
const toggle = () => {
const next = !open
setOpen(next)
if (next) onOpen() // opening IS reading — that's what clears the badge
}
return (
<div className="pouf-bell" ref={root}>
<button
type="button"
className="pouf-bell__button"
onClick={toggle}
aria-expanded={open}
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
>
<Icon name="alerts" size="sm" />
<span className="pouf-bell__label">Alerts</span>
{/* The count is aria-hidden because the button's own label already states it —
otherwise a screen reader reads the number twice. */}
{unread > 0 && (
<span className="pouf-bell__badge" aria-hidden="true">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div className="pouf-bell__panel">
{/* An empty panel is ambiguous: "nothing happened" and "we lost the stream and
can't tell you" look identical. Say which one it is. */}
{!connected && (
<div className="pouf-bell__offline" role="status">
<Text size="sm">Alert stream disconnected — reconnecting. You may be missing alerts.</Text>
</div>
)}
{alerts.length === 0 ? (
<Empty icon="alerts" title="No alerts yet">
Updates and alerts appear here as things happen.
</Empty>
) : (
<ul className="pouf-bell__list">
{alerts.map((a) => (
<li key={a.id} className={clsx('pouf-bell__item', a.severity === 'critical' && 'tone-down')}>
<Icon name={a.severity === 'critical' ? 'warn' : 'ok'} size="sm" />
<div className="pouf-bell__itembody">
<Text size="sm">{emphasise(a.text)}</Text>
<Text size="sm" muted num>
{new Date(a.at).toLocaleTimeString()}
</Text>
</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}| Prop | Type | Notes |
|---|---|---|
alerts* | AlertBellItem[] | |
unread* | number | |
connected* | boolean | |
onOpen* | () => void |
Progress
Determinate and indeterminate bars.
npx shadcn@latest add https://pouf.worksonmy.dev/r/progress.jsonimport * as RProgress from '@radix-ui/react-progress'
import clsx from 'clsx'
import { motion } from 'framer-motion'
import { toneClass, type Tone } from './tone'
interface ProgressProps {
value: number
max?: number
tone?: Tone
label?: string
}
export function Progress({ value, max = 100, tone = 'purple', label }: ProgressProps) {
const pct = Math.min(100, Math.max(0, (value / max) * 100))
return (
<RProgress.Root className="pouf-progress" value={value} max={max} aria-label={label}>
<RProgress.Indicator asChild>
{/* data-zero releases the fill's min-width floor: the floor keeps a
low percentage from squashing into an ellipse, but 0 must render
as nothing, not as a 16px bead of imaginary progress. */}
<motion.div
className={clsx('pouf-progress__fill', toneClass(tone))}
data-zero={pct === 0 ? '' : undefined}
animate={{ width: `${pct}%` }}
transition={{ type: 'spring', stiffness: 200, damping: 24 }}
/>
</RProgress.Indicator>
</RProgress.Root>
)
}| Prop | Type | Notes |
|---|---|---|
value* | number | |
max | number | |
tone | Tone | |
label | string |
Table
Rows of data with optional row actions.
npx shadcn@latest add https://pouf.worksonmy.dev/r/table.jsonimport { useMemo, useState, type ReactNode } from 'react'
import { Text } from './text'
/* ------------------------------------------------------------------ */
/* Table */
/* ------------------------------------------------------------------ */
interface TableColumn<T> {
key: string
header: string
render: (row: T) => ReactNode
align?: 'left' | 'right'
mono?: boolean
truncate?: boolean
/** Supply a comparator to make this column's header a sort control.
* Opt-in on purpose: a column without `sort` renders the same inert header
* it always has, so adding sorting to the system changed no existing
* snapshot. Sorting is internal state — `rows` stays the source order. */
sort?: (a: T, b: T) => number
}
interface TableProps<T> {
columns: TableColumn<T>[]
rows: T[]
getKey: (row: T) => string
/** Makes every row an interactive target (button semantics: Enter/Space work,
* and it lands in the tab order). Use for "open this record", never for
* destructive actions — a click has no confirm step. */
onRowClick?: (row: T) => void
}
export function Table<T>({ columns, rows, getKey, onRowClick }: TableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
const [desc, setDesc] = useState(false)
const active = sortKey ? columns.find((c) => c.key === sortKey) : undefined
/* Sort a copy: mutating `rows` would reorder the caller's array, and a caller
* that memoised it would see its own data silently rearranged. */
const sorted = useMemo(() => {
if (!active?.sort) return rows
const out = [...rows].sort(active.sort)
return desc ? out.reverse() : out
}, [rows, active, desc])
function toggle(col: TableColumn<T>) {
if (col.key === sortKey) setDesc((d) => !d)
else {
setSortKey(col.key)
setDesc(false)
}
}
return (
<div className="pouf-table__wrap">
<table className="pouf-table">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={col.align === 'right' ? 'pouf-table__h--right' : ''}
aria-sort={
col.sort
? col.key === sortKey
? desc
? 'descending'
: 'ascending'
: 'none'
: undefined
}
>
{col.sort ? (
<button
type="button"
className="pouf-table__sort"
onClick={() => toggle(col)}
>
<Text size="sm" muted>{col.header}</Text>
{/* Both arrows always render, the inactive one faded, so the
* header never changes width when you sort it. */}
<span aria-hidden="true" className="pouf-table__caret">
{col.key === sortKey ? (desc ? '▾' : '▴') : '⇅'}
</span>
</button>
) : (
<Text size="sm" muted>{col.header}</Text>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={columns.length} className="pouf-table__empty">
<Text size="sm" muted>No data.</Text>
</td>
</tr>
) : (
sorted.map((row) => (
<tr
key={getKey(row)}
className={onRowClick ? 'pouf-table__row pouf-table__row--click' : 'pouf-table__row'}
onClick={onRowClick ? () => onRowClick(row) : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onRowClick(row)
}
}
: undefined
}
tabIndex={onRowClick ? 0 : undefined}
role={onRowClick ? 'button' : undefined}
>
{columns.map((col) => (
<td
key={col.key}
className={
col.align === 'right' ? 'pouf-table__cell--right' : ''
}
>
<Text
size="sm"
mono={col.mono}
num={col.align === 'right'}
truncate={col.truncate}
>
{col.render(row)}
</Text>
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}| Prop | Type | Notes |
|---|---|---|
columns* | TableColumn<T>[] | |
rows* | T[] | |
getKey* | (row: T) => string | |
onRowClick | (row: T) => void | Makes every row an interactive target (button semantics: Enter/Space work, and it lands in the tab order). Use for "open this record", never for destructive actions — a click has no confirm step. |
Charts
Area, Bar, Line, and Pie — Recharts, themed.
npx shadcn@latest add https://pouf.worksonmy.dev/r/charts.jsonimport {
AreaChart as RAreaChart,
BarChart as RBarChart,
LineChart as RLineChart,
PieChart as RPieChart,
Area,
Bar,
Line,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import type { ValueType } from 'recharts/types/component/DefaultTooltipContent'
import type { Tone } from './tone'
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
interface ChartSeries {
key: string
label?: string
tone?: Tone
}
interface ChartTooltipEntry {
color?: string
name?: string
value?: ValueType
}
function PoufTooltip({ active, payload, label }: { active?: boolean; payload?: ChartTooltipEntry[]; label?: string }) {
if (!active || !payload?.length) return null
return (
<div className="pouf-chart__tooltip">
{label && <div className="pouf-chart__tooltip__label">{label}</div>}
{payload.map((entry: ChartTooltipEntry, i: number) => (
<div key={i} className="pouf-chart__tooltip__item">
<span className="pouf-chart__tooltip__swatch" style={{ background: entry.color }} />
<span>{entry.name}: {typeof entry.value === 'number' ? entry.value.toLocaleString() : entry.value}</span>
</div>
))}
</div>
)
}
/** Maps a Tone to a corresponding chart color, falling back to --purple. */
function chartColor(tone?: Tone): string {
const m: Record<string, string> = {
purple: '#c9a8ff',
pink: '#ffb3d1',
blue: '#9ec8ff',
mint: '#a8f0d0',
yellow: '#ffe58a',
orange: '#ffb38a',
up: '#a8f0d0',
down: '#ffb3d1',
warn: '#ffe58a',
info: '#9ec8ff',
idle: '#c9a8ff',
}
return m[tone ?? 'purple'] ?? m.purple!
}
/* ------------------------------------------------------------------ */
/* Area Chart */
/* ------------------------------------------------------------------ */
interface AreaChartProps {
data: Record<string, unknown>[]
dataKey: string
series: ChartSeries[]
height?: number
stacked?: boolean
}
export function AreaChart({ data, dataKey, series, height = 300, stacked }: AreaChartProps) {
return (
<div className="pouf-chart">
<ResponsiveContainer width="100%" height={height}>
<RAreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="pouf-chart__grid" />
<XAxis dataKey={dataKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<Tooltip content={<PoufTooltip />} />
{series.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label ?? s.key}
stroke={chartColor(s.tone)}
fill={chartColor(s.tone)}
fillOpacity={0.25}
strokeWidth={2.5}
stackId={stacked ? '1' : undefined}
/>
))}
</RAreaChart>
</ResponsiveContainer>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Bar Chart */
/* ------------------------------------------------------------------ */
interface BarChartProps {
data: Record<string, unknown>[]
dataKey: string
series: ChartSeries[]
height?: number
stacked?: boolean
/** Name of a per-datum field holding a Tone. When set (single series only),
* each bar takes its own datum's tone — e.g. mint for a region above target,
* pink for one below — instead of the series-wide colour. */
toneKey?: string
}
export function BarChart({ data, dataKey, series, height = 300, stacked, toneKey }: BarChartProps) {
return (
<div className="pouf-chart">
<ResponsiveContainer width="100%" height={height}>
<RBarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="pouf-chart__grid" vertical={false} />
<XAxis dataKey={dataKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<Tooltip content={<PoufTooltip />} />
{series.map((s) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.label ?? s.key}
fill={chartColor(s.tone)}
radius={[6, 6, 0, 0]}
stackId={stacked ? '1' : undefined}
>
{toneKey &&
series.length === 1 &&
data.map((d, i) => <Cell key={i} fill={chartColor(d[toneKey] as Tone | undefined)} />)}
</Bar>
))}
</RBarChart>
</ResponsiveContainer>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Line Chart */
/* ------------------------------------------------------------------ */
interface LineChartProps {
data: Record<string, unknown>[]
dataKey: string
series: ChartSeries[]
height?: number
}
export function LineChart({ data, dataKey, series, height = 300 }: LineChartProps) {
return (
<div className="pouf-chart">
<ResponsiveContainer width="100%" height={height}>
<RLineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="pouf-chart__grid" />
<XAxis dataKey={dataKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<Tooltip content={<PoufTooltip />} />
{series.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label ?? s.key}
stroke={chartColor(s.tone)}
strokeWidth={2.5}
dot={false}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
))}
</RLineChart>
</ResponsiveContainer>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Pie / Donut Chart */
/* ------------------------------------------------------------------ */
interface PieSlice {
key: string
label: string
value: number
tone?: Tone
}
interface PieChartProps {
data: PieSlice[]
height?: number
/** Renders a donut when true, a full pie when false. */
donut?: boolean
/** Show label on each slice. */
labelled?: boolean
}
export function PieChart({ data, height = 280, donut = true, labelled }: PieChartProps) {
return (
<div className="pouf-chart">
<ResponsiveContainer width="100%" height={height}>
<RPieChart margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<Tooltip content={<PoufTooltip />} />
<Pie
data={data}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={donut ? 64 : 0}
outerRadius={donut ? 100 : 110}
paddingAngle={3}
strokeWidth={0}
label={labelled ? ({ name, value }) => `${name} (${value})` : undefined}
>
{data.map((slice) => (
<Cell key={slice.key} fill={chartColor(slice.tone)} />
))}
</Pie>
</RPieChart>
</ResponsiveContainer>
</div>
)
}AreaChart
| Prop | Type | Notes |
|---|---|---|
data* | Record<string, unknown>[] | |
dataKey* | string | |
series* | ChartSeries[] | |
height | number | |
stacked | boolean |
BarChart
| Prop | Type | Notes |
|---|---|---|
data* | Record<string, unknown>[] | |
dataKey* | string | |
series* | ChartSeries[] | |
height | number | |
stacked | boolean | |
toneKey | string | Name of a per-datum field holding a Tone. When set (single series only), each bar takes its own datum's tone — e.g. mint for a region above target, pink for one below — instead of the series-wide colour. |
LineChart
| Prop | Type | Notes |
|---|---|---|
data* | Record<string, unknown>[] | |
dataKey* | string | |
series* | ChartSeries[] | |
height | number |
PieChart
| Prop | Type | Notes |
|---|---|---|
data* | PieSlice[] | |
height | number | |
donut | boolean | Renders a donut when true, a full pie when false. |
labelled | boolean | Show label on each slice. |