Skip to content
General

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.

Sign-in links and receipts go here.

tsxcomponents/profile-settings-form.tsx
"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>
  )
}

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

tsxcomponents/delete-project.tsx
"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

UsesKPI GroupPro componentKPIPro component

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.

12.4%

Monthly revenue

$84.2K

Prev: $74.9K

3.1%

Active workspaces

1,204

Prev: 1,168

0.4%

Churn

2.1%

Prev: 2.5%

tsxcomponents/revenue-kpis.tsx
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

UsesData GridPro componentSegmentPro componentBadgeButton

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.

StatusAmount
INV-2041Juno AlbrechtPaid$480
INV-2042Theo MarchettiDue$1,200
INV-2043Priya OkaforOverdue$320
INV-2044Elin SatoPaid$960
INV-2045Ravi LindgrenDue$240
0 of 7 selected
Page 1 of 2
tsxcomponents/invoice-table.tsx
"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

UsesChart CardPro componentArea ChartPro component

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

RevenueCosts
tsxcomponents/revenue-card.tsx
"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.

x
tsxcomponents/brand-section.tsx
"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>
  )
}

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.

tsxcomponents/theme-toggle.tsx
"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.

Where to go next