Find It. Try It. Ship It.
Explore every 1st-Pouf component at a useful size. Change values, open overlays, inspect props, and copy the source without leaving the page.
Layout
4 componentsLayout
Stack, Row, Grid, Shell, Sidebar, Spacer — flex and grid without inline styles.
import { 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,
mobile = 'show',
}: {
children: ReactNode
/** Hide desktop sidebar chrome when the screen supplies BottomNav on phones. */
mobile?: 'show' | 'hide'
}) {
return (
<aside
className={[
'pouf-sidebar sticky top-(--s8) flex flex-col gap-(--s2)',
mobile === 'hide' ? 'max-[900px]:hidden' : 'max-[900px]:static',
].join(' ')}
>
{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.
import * 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.
import * 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.
import * 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>
)
}Typography
1 componentText & Headings
Heading, Text, Highlight, Eyebrow — the type scale.
Level one
Level two
Level three
soft & puffy
Section title
import { 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 [text-wrap:balance]', {
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 text-[var(--on-accent)] bg-[var(--tone,var(--yellow))]',
'[box-shadow:inset_0_-6px_0_rgba(0,0,0,0.08)]',
toneClass(tone),
)}
>
{children}
</span>
)
}
/** The reference's compact uppercase section eyebrow. The muted token clears
* AA contrast on both the page background and white surfaces. */
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 [overflow-wrap:anywhere]', {
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]" },
/* Truncation needs a real box. On an inline span the parent can be 120px
* wide while the span's own visual rect remains its full 380px intrinsic
* width, creating page overflow in documentation and real layouts. */
truncate: { true: 'block truncate min-w-0 max-w-full' },
},
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 |
Surfaces
4 componentsCard
Card and RowCard — cushions with optional lift and playful tilt.
import { 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)]',
},
motion: {
none: '',
lift:
'[transition:transform_160ms_ease] hover:[transform:translateY(-4px)] motion-reduce:[transform:none] motion-reduce:hover:[transform:none]',
'tilt-left':
'[transform:rotate(-0.6deg)] [transition:transform_160ms_ease] hover:[transform:rotate(0deg)_translateY(-4px)] motion-reduce:[transform:none] motion-reduce:hover:[transform:none]',
'tilt-right':
'[transform:rotate(0.6deg)] [transition:transform_160ms_ease] hover:[transform:rotate(0deg)_translateY(-4px)] motion-reduce:[transform:none] motion-reduce:hover:[transform:none]',
},
},
defaultVariants: { variant: 'default', motion: 'none' },
})
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'
/** Playful, composited hover motion for linked or featured cards. */
motion?: 'none' | 'lift' | 'tilt-left' | 'tilt-right'
}
export function Card({ children, variant, motion }: CardProps) {
return <div className={card({ variant, motion })}>{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 text-[var(--on-accent)] cushion-control',
/* Headings and muted Text own explicit dark-mode colours, so normal
* inheritance cannot make them readable on the selected pastel.
* Selection owns the whole cushion and therefore its complete text
* palette, not only the outer button's currentColor. */
'[&_.pouf-h1]:text-[var(--on-accent)] [&_.pouf-h2]:text-[var(--on-accent)]',
'[&_.pouf-h3]:text-[var(--on-accent)] [&_.pouf-text]:text-[var(--on-accent)]',
].join(' '),
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}
data-selected={selected || undefined}
>
{children}
</button>
)
}
return <div className={className} data-selected={selected || undefined}>{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. |
motion | 'none' | 'lift' | 'tilt-left' | 'tilt-right' | Playful, composited hover motion for linked or featured cards. |
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.
import 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.
import { 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, width, height }: { src: string; alt: string; width: number; height: number }) {
return (
<img
className="pouf-figure block max-w-[420px] w-full h-auto rounded-control bg-bg cushion-field"
src={src}
alt={alt}
width={width}
height={height}
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.
import * 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' |
Forms
8 componentsInput & Textarea
Field, Input, Textarea — recessed cushions for text entry.
import { cva } from 'class-variance-authority'
import {
forwardRef,
useId,
type InputHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} 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)">
{/* Labels use ink so their compact uppercase treatment stays emphatic. */}
<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-[var(--on-accent)] 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 pt-[14px] pb-[18px] 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 pt-[6px] pb-[10px] [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
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'children' | 'className' | 'style' | 'value' | 'onChange'
> {
value: string
onChange: (value: string) => void
onBlur?: InputHTMLAttributes<HTMLInputElement>['onBlur']
id?: string
name?: string
describedBy?: string
placeholder?: string
type?: InputHTMLAttributes<HTMLInputElement>['type']
autoComplete?: string
inputMode?: InputHTMLAttributes<HTMLInputElement>['inputMode']
autoCapitalize?: string
spellCheck?: boolean
required?: boolean
mono?: boolean
invalid?: boolean
disabled?: boolean
label?: string
/** Internal: the NumberInput capsule carries the chrome. */
bare?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
value,
onChange,
describedBy,
type = 'text',
mono,
invalid,
label,
bare,
...nativeProps
},
ref,
) {
return (
<input
ref={ref}
{...nativeProps}
className={inputClasses({ bare: !!bare, invalid: !!invalid, mono })}
value={value}
onChange={(e) => onChange(e.target.value)}
type={type}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={label}
/>
)
})
/* ------------------------------------------------------------------ */
/* Textarea */
/* ------------------------------------------------------------------ */
interface TextareaProps
extends Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
'children' | 'className' | 'style' | 'value' | 'onChange'
> {
value: string
onChange: (value: string) => void
onBlur?: TextareaHTMLAttributes<HTMLTextAreaElement>['onBlur']
id?: string
name?: string
describedBy?: string
placeholder?: string
autoComplete?: string
spellCheck?: boolean
required?: boolean
rows?: number
mono?: boolean
invalid?: boolean
disabled?: boolean
label?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{
value,
onChange,
describedBy,
rows = 4,
mono,
invalid,
label,
...nativeProps
},
ref,
) {
return (
<textarea
ref={ref}
{...nativeProps}
className={`${inputClasses({ invalid: !!invalid, mono })} pouf-textarea resize-y min-h-[100px]`}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
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 | |
onBlur | InputHTMLAttributes<HTMLInputElement>['onBlur'] | |
id | string | |
name | string | |
describedBy | string | |
placeholder | string | |
type | InputHTMLAttributes<HTMLInputElement>['type'] | |
autoComplete | string | |
inputMode | InputHTMLAttributes<HTMLInputElement>['inputMode'] | |
autoCapitalize | string | |
spellCheck | boolean | |
required | boolean | |
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 | |
onBlur | TextareaHTMLAttributes<HTMLTextAreaElement>['onBlur'] | |
id | string | |
name | string | |
describedBy | string | |
placeholder | string | |
autoComplete | string | |
spellCheck | boolean | |
required | boolean | |
rows | number | |
mono | boolean | |
invalid | boolean | |
disabled | boolean | |
label | string |
NumberInput
A spinbutton capsule with decimal-safe stepping.
import { 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-[var(--on-accent)] 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.
import * as RCheck from '@radix-ui/react-checkbox'
import clsx from 'clsx'
import { useId } from 'react'
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) {
const generatedId = useId()
const controlId = id ?? generatedId
return (
<div className="pouf-checkbox-row inline-flex items-center gap-(--s2)">
<RCheck.Root
id={controlId}
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-[var(--on-accent)] flex [&_svg]:w-5 [&_svg]:h-5',
/* Radix mounts the indicator on check, so animate that HTML wrapper
* with compositor-friendly properties instead of animating the SVG
* path's stroke. Reduced motion keeps a short opacity-only fade. */
'[animation:pouf-check-pop_250ms_cubic-bezier(0.23,1,0.32,1)_both]',
'motion-reduce:[animation-name:pouf-check-fade]',
].join(' ')}>
{/* Inline rather than Icon so the indeterminate state can swap the
stroke while keeping geometry consistent with the Tabler set. */}
<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" />
) : (
<path d="M5 12l5 5L20 7" />
)}
</svg>
</RCheck.Indicator>
</RCheck.Root>
{label && !hideLabel && (
<label htmlFor={controlId} 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.
import * as RRadio from '@radix-ui/react-radio-group'
import { useId } from 'react'
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) {
const groupId = useId()
return (
<RRadio.Root
className="pouf-radio-group flex flex-col gap-(--s2)"
value={value}
onValueChange={onChange}
disabled={disabled}
aria-label={label}
>
{options.map((o) => {
const optionId = `${groupId}-${o.value}`
return (
<div key={o.value} className="pouf-radio-row flex items-center gap-(--s2)">
<RRadio.Item id={optionId} value={o.value} aria-label={o.label} 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={optionId} 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.
import * 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}
>
<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.
import 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.
import * 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 |
Overlays
5 componentsSelect, Switch, Dialog, Confirm, Tooltip, Combobox
The Radix-backed controls, skinned in clay.
import * 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 { useId, 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
/** Accessible name when the control is not wrapped in Field. */
label?: string
}
/** 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,
label,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const listboxId = useId()
const optionPrefix = useId()
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 optionCount = shown.length + (canCreate ? 1 : 0)
const activeIndex = optionCount > 0 ? Math.min(active, optionCount - 1) : -1
const activeOptionId =
activeIndex < 0
? undefined
: activeIndex === createIndex && canCreate
? `${optionPrefix}-create`
: `${optionPrefix}-${activeIndex}`
const commit = (v: string) => {
onChange(v)
setOpen(false)
}
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const max = optionCount - 1
if (e.key === 'ArrowDown') {
e.preventDefault()
if (max >= 0) 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 && activeIndex === createIndex) commit(typed)
else if (shown[activeIndex] !== undefined) commit(shown[activeIndex])
}
}
return (
<RPopover.Root
open={open}
onOpenChange={(o) => {
setOpen(o)
if (o) {
setQuery('')
setActive(0)
onOpen?.()
}
}}
>
<RPopover.Trigger asChild>
<button
type="button"
id={id}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-describedby={describedBy}
aria-label={label}
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 value…"
role="combobox"
aria-label={label ? `Search ${label}` : 'Search options'}
aria-autocomplete="list"
aria-expanded="true"
aria-controls={listboxId}
aria-activedescendant={activeOptionId}
autoComplete="off"
spellCheck={false}
autoFocus
/>
<div className="pouf-combobox__list" id={listboxId} role="listbox" aria-busy={loading || undefined}>
{loading && <div className="pouf-combobox__note" role="status" aria-live="polite">Loading options…</div>}
{!loading && error && <div className="pouf-combobox__note" role="status" aria-live="polite">{error}</div>}
{!loading &&
shown.map((o, i) => (
<button
key={o}
id={`${optionPrefix}-${i}`}
type="button"
role="option"
tabIndex={-1}
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"
id={`${optionPrefix}-create`}
role="option"
tabIndex={-1}
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 | |
label | string | Accessible name when the control is not wrapped in Field. |
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.
import * as RHover from '@radix-ui/react-hover-card'
import type { ReactNode } from 'react'
import { motion, useReducedMotion } 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) {
const reduceMotion = useReducedMotion()
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={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: reduceMotion ? 0.01 : 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.
import * 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 |
Navigation
6 componentsTabs, Accordion, Collapsible
Panels that switch and reveal.
import * 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, useReducedMotion } 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) {
const reduceMotion = useReducedMotion()
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={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: reduceMotion ? 0.01 : 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 reduceMotion = useReducedMotion()
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: reduceMotion ? 0 : 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.
import { 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 stays plain ink so the current page remains visually primary. */
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 |
Sections
3 componentsCTA
A call-to-action banner — the one loud thing.
Ready to get puffy?
One command drops a component into your project.
Start your free trial
import { 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 text-[var(--on-accent)] [--quiet-ink:var(--on-accent)]',
pink: 'bg-pink text-[var(--on-accent)] [--quiet-ink:var(--on-accent)]',
blue: 'bg-blue text-[var(--on-accent)] [--quiet-ink:var(--on-accent)]',
mint: 'bg-mint text-[var(--on-accent)] [--quiet-ink:var(--on-accent)]',
yellow: 'bg-yellow text-[var(--on-accent)] [--quiet-ink:var(--on-accent)]',
surface: 'bg-surface text-ink [--quiet-ink:var(--ink)]',
},
},
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-current">{title}</h2>
{description && <p className="m-0 font-bold text-[18px] text-current/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' |
Feedback
5 componentsStatus, Freshness, ModeBanner
Liveness signals and the environment banner.
import 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.
import 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.
import { 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.
import { 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.
import * as RProgress from '@radix-ui/react-progress'
import clsx from 'clsx'
import { motion, useReducedMotion } 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 reduceMotion = useReducedMotion()
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>
<motion.div
className={clsx('pouf-progress__fill', toneClass(tone))}
initial={false}
animate={{ scaleX: pct / 100 }}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 200, damping: 24 }
}
/>
</RProgress.Indicator>
</RProgress.Root>
)
}| Prop | Type | Notes |
|---|---|---|
value* | number | |
max | number | |
tone | Tone | |
label | string |
Data
2 componentsTable
Rows of data with optional row actions.
| Fruit | Kind | Qty | Total |
|---|---|---|---|
| Apple | Fresh | 0.4 | +$31.20 |
| Banana | Dried | 2.1 | −$8.05 |
| Cherry | Fresh | 5.0 | +$12.40 |
| Fruit | Kind | Qty | Total |
|---|---|---|---|
| No data. | |||
| Fruit | Kind | Qty | Total |
|---|---|---|---|
| Fresh | 0.4 | +$31.20 | |
| Dried | 2.1 | −$8.05 | |
| Fresh | 5.0 | +$12.40 |
import { 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 TableBaseProps<T> {
columns: TableColumn<T>[]
rows: T[]
getKey: (row: T) => string
}
type TableProps<T> = TableBaseProps<T> &
(
| { onRowClick?: undefined; getRowLabel?: never }
| {
/** Makes the row clickable while keeping keyboard semantics on a real
* button in its first cell. Use for opening a record, not deletion. */
onRowClick: (row: T) => void
/** Names that first-cell button, e.g. "Open Apple". */
getRowLabel: (row: T) => string
}
)
export function Table<T>({ columns, rows, getKey, onRowClick, getRowLabel }: 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
? (event) => {
const target = event.target as Element
const interactive = target.closest('a, button, input, select, textarea')
if (interactive && !interactive.classList.contains('pouf-table__rowaction')) return
onRowClick(row)
}
: undefined
}
>
{columns.map((col, index) => {
const cell = (
<Text
size="sm"
mono={col.mono}
num={col.align === 'right'}
truncate={col.truncate}
>
{col.render(row)}
</Text>
)
return (
<td
key={col.key}
className={col.align === 'right' ? 'pouf-table__cell--right' : ''}
>
{index === 0 && onRowClick ? (
<button
type="button"
className="pouf-table__rowaction"
aria-label={getRowLabel(row)}
>
{cell}
</button>
) : cell}
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
)
}| Prop | Type | Notes |
|---|---|---|
columns* | TableColumn<T>[] | |
rows* | T[] | |
getKey* | (row: T) => string |
Charts
Area, Bar, Line, and Pie — Recharts, themed.
import {
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. |
No components found
Try a component name such as “Button”, “Dialog”, or “Chart”.