Recipes
Short, practical how-tos built only from WunderUI components. Each one runs live on this page, and the code under it is the file that renders the preview, so it works as soon as and its stylesheet are set up (see Installation).
A settings form with validation
collects the values and only calls once every is valid. Each field checks itself with , which returns an error message or ; shows the message under the input. After the first submit the fields check again as the reader types, so an error goes away as soon as it is fixed. Press Save with the fields empty to see it.
"use client"
import { Button, Field, FieldControl, FieldDescription, FieldError, FieldLabel, Form, Input, Label, Switch, useToast } from "@wunderui/react"
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function ProfileSettingsForm() {
const toast = useToast()
return (
<Form
className="w-full max-w-sm"
onFormSubmit={(values: { name?: string; email?: string }) => {
// Runs only once every Field is valid: send the values to your API here.
toast.add({ type: "success", title: "Profile saved", description: `${values.name} · ${values.email}` })
}}
>
<Field name="name" validate={(value) => (String(value ?? "").trim().length >= 2 ? null : "Enter at least two characters.")}>
<FieldLabel>Display name</FieldLabel>
<FieldControl render={<Input autoComplete="name" placeholder="Mara Lindqvist" />} />
<FieldError />
</Field>
<Field name="email" validate={(value) => (EMAIL.test(String(value ?? "")) ? null : "Enter an email address like mara@wunderui.com.")}>
<FieldLabel>Work email</FieldLabel>
<FieldControl render={<Input autoComplete="email" placeholder="mara@wunderui.com" />} />
<FieldDescription>Sign-in links and receipts go here.</FieldDescription>
<FieldError />
</Field>
<Label>
<Switch defaultChecked />
Email me product updates
</Label>
<Button type="submit" className="self-start">
Save changes
</Button>
</Form>
)
}Toasts need a Toaster
works anywhere inside . Wrap your app in it once, next to ; see Toast.
Confirm a delete, then offer undo
An does not close on a click outside, only through its buttons or Escape, so a destructive action is always a deliberate choice. closes the dialog and runs its . The toast then offers Undo through , and removes it once the undo has run.
Atlas redesign
12 files · edited 2 hours ago
"use client"
import * as React from "react"
import { Trash2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogIcon,
AlertDialogTitle,
AlertDialogTrigger,
Button,
useToast,
} from "@wunderui/react"
export function DeleteProject({ name = "Atlas redesign" }: { name?: string }) {
const toast = useToast()
const [deleted, setDeleted] = React.useState(false)
function remove() {
setDeleted(true)
const id = toast.add({
title: "Project deleted",
description: `“${name}” and its 12 files were removed.`,
actionProps: {
children: "Undo",
onClick: () => {
setDeleted(false)
toast.close(id)
},
},
})
}
return (
<div className="flex w-full max-w-sm items-center justify-between gap-3 rounded-lg border border-border bg-card p-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{name}</p>
<p className="text-xs text-text-tertiary">{deleted ? "Deleted just now" : "12 files · edited 2 hours ago"}</p>
</div>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="plain" size="sm" disabled={deleted} />}>
<Trash2 data-icon="inline-start" />
Delete
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogIcon variant="destructive">
<Trash2 />
</AlertDialogIcon>
<AlertDialogTitle>Delete “{name}”?</AlertDialogTitle>
<AlertDialogDescription>The project and its 12 files are removed for everyone in the workspace.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={remove}>
Delete project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}A row of KPIs
draws one frame with hairlines between the cells, and a inside it drops its own border, radius and shadow, so you never get a card inside a card. takes 2, 3 or 4; below 640 px the row stacks. sets the arrow and the colour, which is how a falling churn rate stays green.
Monthly revenue
$84.2K
Prev: $74.9K
Active workspaces
1,204
Prev: 1,168
Churn
2.1%
Prev: 2.5%
import { KPIGroup, StatCard } from "@wunderui/react"
export function RevenueKpis() {
return (
<KPIGroup columns={3}>
<StatCard label="Monthly revenue" value="$84.2K" prev="$74.9K" delta="12.4%" trend="up" trendGood />
<StatCard label="Active workspaces" value="1,204" prev="1,168" delta="3.1%" trend="up" trendGood dotColor="var(--chart-5)" />
{/* Churn went down, which is good: trend="down" with trendGood keeps the delta green. */}
<StatCard label="Churn" value="2.1%" prev="2.5%" delta="0.4%" trend="down" trendGood dotColor="var(--chart-2)" />
</KPIGroup>
)
}A filterable table with a toolbar
The above the grid filters by status, and the grid's own search box looks through every column that has a . Columns with sort on a click on their header. The grid keeps its page when its data changes, so starts it on page 1 for each filter. Export CSV downloads the rows the filter shows.
| Status | Amount | ||
|---|---|---|---|
| INV-2041 | Juno Albrecht | $480 | |
| INV-2042 | Theo Marchetti | $1,200 | |
| INV-2043 | Priya Okafor | $320 | |
| INV-2044 | Elin Sato | $960 | |
| INV-2045 | Ravi Lindgren | $240 |
"use client"
import * as React from "react"
import { Download } from "lucide-react"
import { Badge, Button, DataGrid, Segment, type DataGridColumn } from "@wunderui/react"
type Invoice = { id: string; customer: string; status: "Paid" | "Due" | "Overdue"; amount: number }
const INVOICES: Invoice[] = [
{ id: "INV-2041", customer: "Juno Albrecht", status: "Paid", amount: 480 },
{ id: "INV-2042", customer: "Theo Marchetti", status: "Due", amount: 1200 },
{ id: "INV-2043", customer: "Priya Okafor", status: "Overdue", amount: 320 },
{ id: "INV-2044", customer: "Elin Sato", status: "Paid", amount: 960 },
{ id: "INV-2045", customer: "Ravi Lindgren", status: "Due", amount: 240 },
{ id: "INV-2046", customer: "Noor Halvorsen", status: "Paid", amount: 1440 },
{ id: "INV-2047", customer: "Caspar Nwosu", status: "Overdue", amount: 720 },
]
const STATUS_COLOR = { Paid: "green", Due: "orange", Overdue: "red" } as const
const FILTERS = ["All", "Paid", "Due", "Overdue"]
const columns: DataGridColumn<Invoice>[] = [
{ key: "id", header: "Invoice", sortable: true, width: 120, filterValue: (r) => r.id, render: (r) => <span className="font-mono text-xs">{r.id}</span> },
{ key: "customer", header: "Customer", sortable: true, width: 170, filterValue: (r) => r.customer, render: (r) => r.customer },
{ key: "status", header: "Status", width: 120, render: (r) => <Badge color={STATUS_COLOR[r.status]} badgeStyle="light">{r.status}</Badge> },
{ key: "amount", header: "Amount", width: 110, render: (r) => `$${r.amount.toLocaleString("en-US")}` },
]
function downloadCsv(rows: Invoice[]) {
const csv = ["Invoice,Customer,Status,Amount", ...rows.map((r) => [r.id, r.customer, r.status, r.amount].join(","))].join("\n")
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }))
const link = document.createElement("a")
link.href = url
link.download = "invoices.csv"
link.click()
URL.revokeObjectURL(url)
}
export function InvoiceTable() {
const [status, setStatus] = React.useState("All")
const rows = status === "All" ? INVOICES : INVOICES.filter((r) => r.status === status)
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<Segment size="sm" value={status} onValueChange={setStatus} options={FILTERS.map((f) => ({ value: f, label: f }))} />
<Button variant="plain" size="sm" onClick={() => downloadCsv(rows)}>
<Download data-icon="inline-start" />
Export CSV
</Button>
</div>
{/* key: a new filter starts the grid again on page 1 */}
<DataGrid key={status} columns={columns} data={rows} pageSize={5} selectable={false} />
</div>
)
}A dashboard card with a chart
lays out the title, the period switch and the legend; you keep the period in state and hand the chart the matching rows. The legend badges take the series colours in order, the same order the chart uses, so they always match. pins the value axis so it starts at 0 and ends on a round number.
Revenue
In thousands of US dollars
"use client"
import * as React from "react"
import { AreaChart, ChartCard } from "@wunderui/react"
const PERIODS = {
"6 months": {
ticks: [0, 20, 40, 60],
rows: [
{ label: "May", Revenue: 32, Costs: 21 },
{ label: "Jun", Revenue: 38, Costs: 23 },
{ label: "Jul", Revenue: 35, Costs: 24 },
{ label: "Aug", Revenue: 44, Costs: 26 },
{ label: "Sep", Revenue: 49, Costs: 27 },
{ label: "Oct", Revenue: 54, Costs: 29 },
],
},
"30 days": {
ticks: [0, 5, 10, 15],
rows: [
{ label: "Week 1", Revenue: 11, Costs: 7 },
{ label: "Week 2", Revenue: 13, Costs: 7 },
{ label: "Week 3", Revenue: 12, Costs: 8 },
{ label: "Week 4", Revenue: 14, Costs: 8 },
],
},
}
type Period = keyof typeof PERIODS
export function RevenueCard() {
const [period, setPeriod] = React.useState<Period>("6 months")
const { rows, ticks } = PERIODS[period]
return (
<ChartCard
title="Revenue"
description="In thousands of US dollars"
periods={Object.keys(PERIODS)}
period={period}
onPeriodChange={(next) => setPeriod(next as Period)}
legend={[{ label: "Revenue" }, { label: "Costs" }]}
>
<AreaChart
data={rows}
index="label"
categories={["Revenue", "Costs"]}
height={220}
yTicks={ticks}
yAxisFormatter={(v) => (v === 0 ? "0" : `$${v}K`)}
valueFormatter={(v) => `$${v}K`}
/>
</ChartCard>
)
}Your brand colour on one section
Every colour is a CSS variable, and variables inherit. Set , and on a wrapper and everything inside it takes the accent, while the rest of the page stays as it is. Keep text on the accent at 4.5:1 or more.
"use client"
import * as React from "react"
import { Button, Label, Progress, RadioButtonGroup, Switch, ToggleButton } from "@wunderui/react"
// Palette steps that keep white text above 4.5:1. Put your own brand colour here.
const ACCENTS = {
Indigo: "var(--brand-600)",
Green: "var(--green-800)",
Blue: "var(--blue-800)",
}
type Accent = keyof typeof ACCENTS
export function BrandSection() {
const [accent, setAccent] = React.useState<Accent>("Green")
const color = ACCENTS[accent]
return (
<div className="flex w-full flex-col gap-4">
<RadioButtonGroup aria-label="Accent" value={[accent]} onValueChange={(next) => next[0] && setAccent(next[0] as Accent)}>
{Object.keys(ACCENTS).map((name) => (
<ToggleButton key={name} value={name}>
{name}
</ToggleButton>
))}
</RadioButtonGroup>
{/* Only the components inside this wrapper take the accent. */}
<section
style={{ "--primary": color, "--primary-foreground": "#FFFFFF", "--ring": color } as React.CSSProperties}
className="flex flex-col gap-4 rounded-xl border border-border bg-card p-5"
>
<Progress value={64} aria-label="Storage used" />
<Label>
<Switch defaultChecked />
Weekly digest
</Label>
<div className="flex flex-wrap gap-2">
<Button>Upgrade plan</Button>
<Button variant="plain">Compare plans</Button>
</div>
</section>
</div>
)
}For the whole app, use the Theme Builder
The Theme Builder turns one colour into the full brand scale with light and dark values and a contrast check, as a to import after the WunderUI stylesheet. Tints, like the selected button above, are fixed per hue and do not follow ; see Theming.
A dark-mode toggle
puts the class on and remembers the choice; the toggle only has to call from , which uses for the provider. The icon waits for the first render in the browser, because the server cannot know the theme. This one switches the whole site.
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { IconButton } from "@wunderui/react"
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
// The theme is unknown on the server: false there and on the first render, true in the browser after it.
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false
)
const dark = mounted && resolvedTheme === "dark"
return (
<IconButton variant="plain" aria-label={dark ? "Switch to light mode" : "Switch to dark mode"} onClick={() => setTheme(dark ? "light" : "dark")}>
{dark ? <Sun /> : <Moon />}
</IconButton>
)
}The provider setup is on Theming. If your package manager does not hoist dependencies, add to your own before importing it.