Components
46 components, each with a live preview: every state at once, a playground wired to the real props, and the import line.
Before you start
once per app
Every card below shows an import line. These three steps make it work.
1. Install the package
terminalnpm i topclassui@beta zodThree peer dependencies: react, react-dom and Tailwind CSS 4.2 or later. Your project already has them, and zod installs with the package.
2. The Tailwind CSS v4 entry
app/globals.css@import "tailwindcss"; @import "topclassui/tokens.css"; @import "topclassui/styles.css";Three lines, in this order, and no tailwind.config.js. The third carries the mapping that gives Tailwind the semantic class names.
3. The root layout
app/[locale]/layout.tsxconst dir = locale === 'ar' ? 'rtl' : 'ltr' return ( <html lang={locale} dir={dir} data-theme="dark" suppressHydrationWarning> <head><ThemeScript /></head> <body> <TopClassProvider direction={dir} locale={locale}> {children} </TopClassProvider> </body> </html> )Pass direction and locale to TopClassProvider as props from your layout; never read them from document.dir in an effect. ThemeScript goes first in <head>: it sets data-theme before the first paint.
How to read a card
Every component below is laid out the same way, so you learn it once. Five parts, top to bottom.
The stage
The component in its most common configuration, on the page background, so you see it in context.
Try it
Every prop you can change, with its allowed values.
The code
The JSX that produces the preview above it. Copy it, paste it and it runs.
Every state
Hover, focus, disabled, loading, error - all at once. You never hover to review a state.
Import it
The one import line from the package, and the install command.
Actions
4 components
Things a person clicks. Each one renders on the server unless it needs an event handler in the browser.
Button
server - 0 KBFive variants across six tones, rendered on the server with no JavaScript. Through render it becomes a real link that looks the same.
Try it
The code
import { Button } from 'topclassui/button'
<Button>Save</Button>loading blocks clicks and announces itself with aria-busy, but keeps full colour, so it never reads as disabled.
Every state
5 statesTag
split - size not measured yetA label chip for a topic, rendered on the server. Passing onRemove adds an x button, the only part that runs in the browser.
Try it
The code
import { Tag } from 'topclassui/tag'
<Tag>Design systems</Tag>A plain Tag stays on the server; onRemove is the switch that adds the x, and taking the tag out of the list is yours.
Every state
2 statesImport from the package
import { Tag } from 'topclassui/tag'Install once:npm i topclassui@beta zod
Kbd
server - 0 KBA keycap for a key the reader presses, rendered on the server. A combination is sibling keys, with no group part and no plus.
The code
import { Kbd } from 'topclassui/kbd'
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Kbd>K</Kbd>
<Kbd>Tab</Kbd>
<Kbd>Ctrl</Kbd>
<Kbd>Shift</Kbd>
<Kbd>Enter</Kbd>
<Kbd>Esc</Kbd>
</div>
)
}
An icon inside a key is an <Svg> with no size: at its 1em default it follows the key's own text.
Import from the package
import { Kbd } from 'topclassui/kbd'Install once:npm i topclassui@beta zod
Segmented control
client - size not measured yetOne choice out of two to five short ones, drawn as one track. It is a radio group: the arrow keys select as they move, and reverse in RTL.
Try it
The code
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
<SegmentedControlRoot aria-label="View" defaultValue="board">
<SegmentedControlItem value="board">Board</SegmentedControlItem>
<SegmentedControlItem value="table">Table</SegmentedControlItem>
<SegmentedControlItem value="calendar">Calendar</SegmentedControlItem>
</SegmentedControlRoot>The arrow keys move and select at once; under an RTL TopClassProvider, ArrowRight goes to the segment before.
Every state
4 statesImport from the package
import { SegmentedControlRoot, SegmentedControlItem } from 'topclassui/segmented-control'Install once:npm i topclassui@beta zod
Forms
13 components
Each control carries a real name and submits inside a plain <form>. Input and Textarea work before any JavaScript loads; the rest submit their default value and become interactive once it does.
Input
server - 0 KBA native input that renders on the server and ships 0 KB of JavaScript. The types refuse one with no name.
Try it
The code
import { Input } from 'topclassui/input'
<div className="grid max-w-sm gap-1">
<label htmlFor="input-playground" className="text-sm font-medium text-body">Email</label>
<Input id="input-playground" name="email" type="email" autoComplete="email" />
</div>Outside a Field, Input needs id, aria-label or aria-labelledby: the types reject an unlabelled one.
Every state
6 statesImport from the package
import { Input } from 'topclassui/input'Install once:npm i topclassui@beta zod
Textarea
split - size not measured yetA native textarea rendered on the server. Autosize and the live counter are the only parts that load in the browser.
Try it
The code
import { Textarea } from 'topclassui/textarea'
<div className="grid max-w-sm gap-1">
<label htmlFor="textarea-playground" className="text-sm font-medium text-body">About you</label>
<Textarea id="textarea-playground" name="bio" placeholder="Write a short paragraph" />
</div>On its own it is server HTML; autosize or showCount is what loads the small client half.
Every state
6 statesImport from the package
import { Textarea } from 'topclassui/textarea'Install once:npm i topclassui@beta zod
Select
client - size not measured yetOne choice from a list of plain data. The popup stays inside the viewport and scrolls itself, seven rows tall by default.
Try it
The code
import { Select } from 'topclassui/select'
<div className="grid justify-items-start gap-1.5">
<label htmlFor="select-playground" className="text-sm text-body">Country</label>
<Select id="select-playground" items={COUNTRIES} defaultValue="jo" />
</div>items is plain data, so a server page can render a Select: no function crosses to the browser.
Every state
4 statesImport from the package
import { Select } from 'topclassui/select'Install once:npm i topclassui@beta zod
Combobox
client - size not measured yetA text input that filters a list as you type. It is windowed above 200 options and calls loadMore for the next page.
Try it
The code
import { Combobox } from 'topclassui/combobox'
<div className="grid justify-items-start gap-1">
<label htmlFor="combobox-playground" className="text-sm font-medium text-body">Framework</label>
<Combobox id="combobox-playground" items={FRAMEWORKS} placeholder="Search frameworks" />
</div>Filtering runs over the options loaded so far; the next page arrives through hasMore and loadMore.
Every state
4 statesImport from the package
import { Combobox } from 'topclassui/combobox'Install once:npm i topclassui@beta zod
Checkbox
client - size not measured yetChecked, unchecked or mixed. A hidden input sends the default value before the page hydrates, and a form reset brings it back.
Try it
The code
import { Checkbox } from 'topclassui/checkbox'
<div className="flex items-center gap-2">
<Checkbox id="checkbox-playground" defaultChecked />
<label htmlFor="checkbox-playground" className="text-sm text-body">I accept the terms</label>
</div>The box is a native <button role="checkbox">, and a hidden checkbox beside it is what the form sends.
Every state
9 statesImport from the package
import { Checkbox } from 'topclassui/checkbox'Install once:npm i topclassui@beta zod
Radio group
client - size not measured yetOne choice out of a few, all shown at once. One Tab stop for the group, and the checked radio is the tab stop before hydration.
Try it
The code
import { RadioGroupItem, RadioGroupRoot } from 'topclassui/radio-group'
<div className="grid justify-items-start gap-2">
<span id="radio-group-playground-label" className="text-sm text-body">Billing</span>
<RadioGroupRoot aria-labelledby="radio-group-playground-label" defaultValue="yearly">
<RadioGroupItem value="monthly">Monthly</RadioGroupItem>
<RadioGroupItem value="yearly">Yearly</RadioGroupItem>
<RadioGroupItem value="lifetime">Lifetime</RadioGroupItem>
</RadioGroupRoot>
</div>The group carries the state and the form; each RadioGroupItem carries only its value and its text.
Every state
4 statesImport from the package
import { RadioGroupRoot, RadioGroupItem } from 'topclassui/radio-group'Install once:npm i topclassui@beta zod
Switch
client - size not measured yetAn on and off switch for a setting that takes effect at once. Its thumb travels the right way in right-to-left layouts.
Try it
The code
import { Switch } from 'topclassui/switch'
<div className="flex items-center gap-2">
<Switch id="switch-playground" defaultChecked />
<label htmlFor="switch-playground" className="text-sm text-body">Weekly digest</label>
</div>The xs and sm switches still grow to a 44px target under a touch pointer, so the thumb stays easy to tap.
Every state
7 statesImport from the package
import { Switch } from 'topclassui/switch'Install once:npm i topclassui@beta zod
Slider
client - size not measured yetA single value or a range of two. Arrow keys and a press on the track give the same result as every drag.
Try it
The code
import { Slider } from 'topclassui/slider'
<div className="grid justify-items-start gap-2">
<span id="slider-playground-label" className="text-sm text-body">Volume</span>
<Slider aria-labelledby="slider-playground-label" defaultValue={60} />
</div>A hidden range input carries the value, so the slider submits with its form; the thumb moves once the page hydrates.
Every state
4 statesImport from the package
import { Slider } from 'topclassui/slider'Install once:npm i topclassui@beta zod
Number field
client - size not measured yetA number between known bounds, typed or stepped. A hidden input carries the plain number, so the form submits before hydration.
Try it
The code
import { NumberField } from 'topclassui/number-field'
<div className="grid justify-items-start gap-2">
<label htmlFor="number-field-playground-guests" className="text-sm text-body">Guests</label>
<NumberField id="number-field-playground-guests" defaultValue={2} />
</div>min, max and step are clamped at runtime, so a typed or passed value cannot break the field.
Every state
4 statesImport from the package
import { NumberField } from 'topclassui/number-field'Install once:npm i topclassui@beta zod
OTP field
client - size not measured yetA one-time code typed one character per cell. A paste fills every cell, and one hidden input carries the code to the form.
Try it
The code
import { OTPField } from 'topclassui/otp-field'
<div className="grid justify-items-start gap-2">
<label htmlFor="otp-playground-code" className="text-sm text-body">Verification code</label>
<OTPField id="otp-playground-code" />
</div>The export is OTPField. In a form the hidden input sends what is typed so far, a partial code too.
Every state
6 statesImport from the package
import { OtpField } from 'topclassui/otp-field'Install once:npm i topclassui@beta zod
Date picker
client - size not measured yet, lazyOne day picked from a month. The calendar loads on its first open, and every month name and digit comes from Intl.
Try it
The code
import { DatePicker } from 'topclassui/date-picker'
<div className="grid justify-items-start gap-1.5">
<label htmlFor="date-picker-playground" className="text-sm text-body">Check-in</label>
<DatePicker id="date-picker-playground" defaultValue="2026-09-22" />
</div>The value is an ISO date string, YYYY-MM-DD; the shown format comes from the provider's locale.
Every state
3 statesImport from the package
import { DatePicker } from 'topclassui/date-picker'Install once:npm i topclassui@beta zod
File upload
client - size not measured yetA drop zone that is a real file input, so it works and posts before any script runs. Picked files show as a list.
Try it
The code
import { FileUpload } from 'topclassui/file-upload'
<div className="grid gap-1.5">
<label htmlFor="file-upload-playground" className="text-sm font-medium text-body">Attachment</label>
<FileUpload id="file-upload-playground" name="attachment" accept=".png,.jpg,.jpeg,.pdf">
PNG, JPG or PDF
</FileUpload>
</div>maxSize is in bytes and checked in the browser only: check it again in the action.
Every state
2 statesImport from the package
import { FileUpload } from 'topclassui/file-upload'Install once:npm i topclassui@beta zod
Rating
split - size not measured yetRead-only stars are server HTML and ship no script. Only an editable rating hydrates, as a group of native radios.
Try it
The code
import { Rating } from 'topclassui/rating'
import type { RatingDesignProps } from 'topclassui/rating'
/** The site's Try it stage: each schema prop arrives from a control, and an absent one keeps its default. */
export default function Demo(props: RatingDesignProps = {}) {
const { readOnly, defaultValue, disabled, ...shown } = props
if (readOnly === false) {
return <Rating defaultValue={defaultValue ?? 3} disabled={disabled} {...shown} readOnly={false} aria-label="Rate this course" />
}
return <Rating value={3.5} {...shown} />
}
value alone renders stars on the server; only readOnly={false} ships the client half.
Every state
6 statesImport from the package
import { Rating } from 'topclassui/rating'Install once:npm i topclassui@beta zod
Display
7 components
Presentational pieces. Nearly all of them render on the server and ship no JavaScript; where one adds a button, such as a removable Tag, the chip on its card says so.
Badge
server - 0 KBA short status label in three variants across six tones. It renders on the server and ships no JavaScript.
Try it
The code
import { Badge } from 'topclassui/badge'
<Badge>Paid</Badge>Badge has no ARIA role: the text carries the meaning, colour never does alone.
Every state
3 statesImport from the package
import { Badge } from 'topclassui/badge'Install once:npm i topclassui@beta zod
Avatar
split - size not measured yetA picture, initials or an icon in a circle, on the same five sizes as every control so an avatar and a button line up.
Try it
The code
import { AvatarFallback, AvatarImage, AvatarRoot } from 'topclassui/avatar'
<AvatarRoot>
<AvatarImage src={PORTRAIT} alt="Omar Abed" />
<AvatarFallback>OA</AvatarFallback>
</AvatarRoot>The fallback shows while the picture loads and after it fails; initials are its children.
Every state
5 statesImport from the package
import { AvatarRoot, AvatarImage, AvatarFallback, AvatarSkeleton } from 'topclassui/avatar'Install once:npm i topclassui@beta zod
Avatar group
server - 0 KBOverlapping avatars with a +N counter for the rest. Render every member; max is clamped from 1 to 20.
Try it
The code
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
<AvatarGroup aria-label="Project team">
{TEAM.map(([initials, name]) => (
<AvatarRoot key={initials} size={props.size} role="img" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>max counts children, so render every member and let the group hide the overflow.
Every state
1 stateImport from the package
import { AvatarGroup } from 'topclassui/avatar-group'Install once:npm i topclassui@beta zod
Card
server - 0 KBA surface for content that belongs together, with a larger radius than a control so it never reads as a button.
Storage
The code
import { CardBody, CardHeader, CardRoot } from 'topclassui/card'
/** The first drawing: a title and a line of text, with nothing to press. */
export default function Demo() {
return (
<div className="max-w-xs">
<CardRoot>
<CardHeader>
<h2>Storage</h2>
</CardHeader>
<CardBody>You have used 18 GB of the 50 GB in your plan.</CardBody>
</CardRoot>
</div>
)
}
Card sets the type of its title and leaves the heading level to you: put the h2 or h3 inside CardHeader.
Every state
3 statesStorage
Version 0.4
Import from the package
import { CardRoot, CardHeader, CardBody, CardFooter, CardSkeleton } from 'topclassui/card'Install once:npm i topclassui@beta zod
Separator
server - 0 KBA hairline between two groups, across a column or down a row. Decorative by default, so it is not announced.
Try it
The code
import { Separator } from 'topclassui/separator'
<div className="flex min-h-5 flex-wrap items-center gap-3 text-sm text-body">
<span>Profile</span>
<Separator />
<span>Billing</span>
</div>A vertical Separator takes its height from a flex or grid row; give the row a height or let it stretch.
Every state
2 statesImport from the package
import { Separator } from 'topclassui/separator'Install once:npm i topclassui@beta zod
Skeleton
server - 0 KBA placeholder that holds the shape of content while it loads: lines of text, a circle, a rectangle or a card.
Try it
The code
import { Skeleton } from 'topclassui/skeleton'
<div className="max-w-sm">
<Skeleton />
</div>Give the skeleton the real dimensions through className; rows is clamped from 1 to 50, and a wrong size is a layout shift.
Every state
1 stateImport from the package
import { Skeleton } from 'topclassui/skeleton'Install once:npm i topclassui@beta zod
Code block
split - size not measured yetCode in a box of its own, read left to right on any page. The block renders on the server; the copy button is its only script.
export default async function Page() {
const invoices = await getInvoices({ status: 'overdue', sortBy: 'dueDate', order: 'descending' })
return <InvoiceTable items={invoices} />
}Try it
The code
import type { CodeBlockDesignProps } from 'topclassui/code-block'
import { CodeBlock } from 'topclassui/code-block'
/** The docs stage: the Try it controls hand their values in as props. One line is long, so wrap shows. */
export default function Demo(props: CodeBlockDesignProps) {
return (
<CodeBlock lang="tsx" title="app/invoices/page.tsx" {...props}>
{`export default async function Page() {
const invoices = await getInvoices({ status: 'overdue', sortBy: 'dueDate', order: 'descending' })
return <InvoiceTable items={invoices} />
}`}
</CodeBlock>
)
}
Pass the code as a template-literal child. No highlighter is built in: pass coloured nodes as highlighted.
Every state
3 statesexport default async function Page() {
const invoices = await getInvoices()
return <InvoiceTable items={invoices} />
}export const columns = ['number', 'customer', 'issued', 'due', 'amount', 'status', 'actions'] as constexport default async function Page() {
const invoices = await getInvoices()
return <InvoiceTable items={invoices} />
}Import from the package
import { CodeBlock } from 'topclassui/code-block'Install once:npm i topclassui@beta zod
Data
4 components
Every list component works the same way: a prop for visible rows, windowing above 200 items, and a loadMore callback. It never fetches.
Table
split - size not measured yetA real HTML table rendered on the server: sorting is a link per header, the first column can stick, and it ships no JavaScript.
Try it
The code
import { Table, type TableDesignProps } from 'topclassui/table'
<Table
caption="Quarterly revenue by region"
items={regions}
getKey={(region) => region.name}
columns={[
{ key: 'name', header: 'Region', rowHeader: true, cell: (region) => region.name },
{ key: 'q1', header: 'Q1', align: 'end', cell: (region) => region.q1 },
{ key: 'q2', header: 'Q2', align: 'end', cell: (region) => region.q2 },
{ key: 'q3', header: 'Q3', align: 'end', cell: (region) => region.q3 },
{ key: 'q4', header: 'Q4', align: 'end', cell: (region) => region.q4 },
{ key: 'total', header: 'Full year', align: 'end', cell: (region) => region.total },
]}
/>Sorting is a link the server answers with the rows in their new order, so a sorted table costs no JavaScript.
Every state
7 statesImport from the package
import { Table } from 'topclassui/table'Install once:npm i topclassui@beta zod
Tree view
client - size not measured yetNodes in levels that open and close. Open branches flatten into one windowed list, so 10,000 nodes stay fast.
Try it
The code
import { fileText } from 'topclassui/icons/file-text'
import { folder } from 'topclassui/icons/folder'
import { Svg } from 'topclassui/svg'
import { TreeView, type TreeViewDesignProps } from 'topclassui/tree-view'
<TreeView aria-label="Files" items={ITEMS} defaultExpanded={['src', 'app']} />items is plain data and a node's icon is an element, so a Server Component passes the tree as it is; there is no getKey.
Every state
4 statesImport from the package
import { TreeView } from 'topclassui/tree-view'Install once:npm i topclassui@beta zod
Timeline
server - 0 KBA rail of dated events, each with a marker in its tone. It renders on the server and mirrors by itself in right-to-left layouts.
- DoneProject created
- DoneFirst deploy
- CurrentIn reviewToday
- Not startedLaunchNot scheduled
The code
import { Timeline } from 'topclassui/timeline'
const EVENTS = [
{ id: 'created', title: 'Project created', time: <time dateTime="2026-10-02T09:12">2 October, 09:12</time>, status: 'done' },
{ id: 'deployed', title: 'First deploy', time: <time dateTime="2026-10-04T16:40">4 October, 16:40</time>, status: 'done' },
{ id: 'review', title: 'In review', time: 'Today', status: 'current' },
{ id: 'launch', title: 'Launch', time: 'Not scheduled', status: 'todo' },
] as const
/** The drawn rail: done filled in success, the current step a dot in a ring, and a hollow todo.
* A `<time>` gives a date a machine can read; each status is also said in hidden text. */
export default function Demo() {
return <Timeline items={EVENTS} getKey={(event) => event.id} />
}
An item's icon is an element, such as <Svg icon={check} size="xs" />, not an icon name: no icon loader ships.
Every state
6 states- DoneDoneA filled dot in success
- CurrentCurrentA dot in a ring, read as the current step
- Not startedTo doA hollow dot
- A tone of its ownNo status, the danger tone
- DoneAn iconTakes the tone of its status
- Not startedAn icon to do
Import from the package
import { Timeline } from 'topclassui/timeline'Install once:npm i topclassui@beta zod
Stepper
server - 0 KBWhere a reader is in a task of a few steps. A done step carries a tick, not just a colour, so it survives a greyscale screenshot.
- AccountCompleted
- Profile
- TeamNot started
- DoneNot started
Try it
The code
import { Stepper, type StepperDesignProps } from 'topclassui/stepper'
<Stepper items={STEPS} value="profile" aria-label="Sign-up" />Stepper renders on the server: pass value, the id of the current step. It shows progress; it does not navigate.
Every state
4 states- Cart
- AddressNot started
- PaymentNot started
- CartCompleted
- Address
- PaymentNot started
- CartCompleted
- AddressNeeds attention
- PaymentNot started
- CartCompleted
- AddressCompleted
- PaymentCompleted
Import from the package
import { Stepper } from 'topclassui/stepper'Install once:npm i topclassui@beta zod
Feedback
6 components
Status, progress and empty states. Toast and Progress announce themselves to screen readers; the rest stay silent.
Alert
server - 0 KBA message that belongs to the page, in six tones. Info, success, warning and danger each draw their own icon, so it reads without colour.
Your trial ends in three days
Try it
The code
import { Alert } from 'topclassui/alert'
<Alert title="Your trial ends in three days">
Deploys pause when it does. Adding a payment method turns them back on.
</Alert>Not a live region: it is page content. Pass role="alert" when it appears after load, or announce the event with Toast.
Every state
13 statesSoft
Solid
Outline
Primary
Neutral
Success
Warning
Danger
Info
Title only
No icon
With actions
Import from the package
import { Alert } from 'topclassui/alert'Install once:npm i topclassui@beta zod
Toast
client - size not measured yetA short notice that something happened. It shows in a corner, leaves by itself, and always has an x, because a swipe is a drag.
Try it
The code
'use client'
import { Button } from 'topclassui/button'
import { toast } from 'topclassui/toast'
import type { ToastDesignProps } from 'topclassui/toast'
/** The docs stage: the Try it controls set the options of the toast the press adds. */
export default function Demo(props: ToastDesignProps = {}) {
return (
<Button
variant="outline"
onClick={() => { toast.add({ ...props, title: 'Project created', description: 'It is ready for its first deploy.' }) }}
>
Show a toast
</Button>
)
}
TopClassProvider mounts the one viewport, so there is nothing to mount; toast.add works from any browser module, inside React or not.
Every state
11 statesImport from the package
import { Toast } from 'topclassui/toast'Install once:npm i topclassui@beta zod
Banner
split - size not measured yetOne message for the whole page, full width above the header. It sits in the page flow, so it pushes the page down and covers nothing.
Try it
The code
import { bell } from 'topclassui/icons/bell'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Banner } from 'topclassui/banner'
<Banner
id="banner-playground"
aria-label="Version 2 announcement"
icon={<Svg icon={bell} size="xs" />}
action={<Button size="sm" variant="outline" tone="neutral" render={<a href="/changelog" />}>Read the changelog</Button>}
>
Version 2 is out, with faster builds and a new theme editor.
</Banner>Dismissal is remembered per id in this browser, so change the id when the message changes.
Every state
9 statesProgress
server - 0 KBHow far along a task is: a neutral track with a bar in the tone, read by a screen reader as a progressbar from 0 to 100.
Try it
The code
import { Progress } from 'topclassui/progress'
<div className="grid max-w-sm">
<Progress aria-label="Upload" />
</div>It renders no text and clamps value to 0-100, so name it with aria-label or aria-labelledby.
Every state
5 statesImport from the package
import { Progress } from 'topclassui/progress'Install once:npm i topclassui@beta zod
Spinner
server - 0 KBFor a wait of unknown length. A spinner is an icon, so it takes the icon sizes, 16 to 40 px, and can follow the text it sits in.
Try it
The code
import { Spinner } from 'topclassui/spinner'
<Spinner />Prefer Skeleton when the shape of what is coming is known; Spinner is for unknown waits.
Every state
3 statesDecorative, in the text colour
Import from the package
import { Spinner } from 'topclassui/spinner'Install once:npm i topclassui@beta zod
Empty state
server - 0 KBWhat a list, a table or a search shows when there is nothing to show: an icon, a line, a sentence and your own action.
Nothing here yet
The code
import { EmptyState } from 'topclassui/empty-state'
/** No props at all: the tray icon, then the English title and description from labels. */
export default function Demo() {
return (
<div className="max-w-85">
<EmptyState />
</div>
)
}
With no props it shows the tray icon and the two labels.state strings; a title of your own drops the default description.
Every state
5 statesNothing here yet
Title only
With action
Failed
Filtered
Import from the package
import { EmptyState } from 'topclassui/empty-state'Install once:npm i topclassui@beta zod
Overlays
6 components
Overlays share one scroll rule: the overlay scrolls, the page behind it does not. Placement is logical, so it mirrors in Arabic.
Dialog
client - size not measured yetA modal dialog whose body scrolls between a sticky header and footer, while the page behind it stays still.
Try it
The code
import { Button } from 'topclassui/button'
import {
<DialogRoot>
<DialogTrigger render={<Button>Publish</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Publish changes</DialogTitle>
<DialogDescription>Your changes go live right away.</DialogDescription>
</DialogHeader>
<DialogBody>11 files changed since the last publish.</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose render={<Button>Publish now</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>Wrap the parts in a form with the class contents, and a Server Action submits from the footer with no client handler.
Every state
5 statesImport from the package
import { DialogRoot, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle, DialogTrigger } from 'topclassui/dialog'Install once:npm i topclassui@beta zod
Drawer
client - size not measured yetA dialog that slides in from a logical edge, so it mirrors in Arabic with no code change. A swipe or the x closes it.
Try it
The code
import { Button } from 'topclassui/button'
import {
<DrawerRoot>
<DrawerTrigger render={<Button variant="outline">Filters</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Narrow the list of projects.</DrawerDescription>
</DrawerHeader>
<DrawerBody className="grid content-start gap-2.5">
<p>Status</p>
<p>Owner</p>
<p>Updated</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Reset</Button>} />
<DrawerClose render={<Button>Apply</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>placement is logical: start opens from the left in English and from the right in Arabic. Never left or right.
Every state
5 statesImport from the package
import { DrawerRoot, DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerTitle, DrawerTrigger } from 'topclassui/drawer'Install once:npm i topclassui@beta zod
Popover
client - size not measured yetAn anchored panel for a link, a field or an action. It flips and shrinks to the room it has instead of overflowing.
Try it
The code
import { Button } from 'topclassui/button'
import { PopoverDescription, PopoverPopup, PopoverRoot, PopoverTitle, PopoverTrigger } from 'topclassui/popover'
<PopoverRoot>
<PopoverTrigger openOnHover render={<Button variant="outline">Share</Button>} />
<PopoverPopup>
<PopoverTitle>Share this page</PopoverTitle>
<PopoverDescription>Anyone with the link can read it. Only your team can edit.</PopoverDescription>
<Button size="sm" className="self-start">Copy link</Button>
</PopoverPopup>
</PopoverRoot>A tooltip is plain text only: its children are typed as text. Anything with a link or a control is a Popover.
Every state
5 statesImport from the package
import { PopoverRoot, PopoverClose, PopoverDescription, PopoverPopup, PopoverTitle, PopoverTrigger } from 'topclassui/popover'Install once:npm i topclassui@beta zod
Tooltip
client - size not measured yetOne or two lines of plain text on hover or focus: the name of an icon-only control, or a label that truncates.
Try it
The code
import { linkSimple } from 'topclassui/icons/link-simple'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
<TooltipRoot>
<TooltipTrigger
render={
<Button variant="ghost" aria-label="Copy link">
<Svg icon={linkSimple} />
</Button>
}
/>
<TooltipPopup>Copy link</TooltipPopup>
</TooltipRoot>Touch never shows a tooltip, so the trigger's own name must repeat the text: give an icon-only Button an aria-label.
Every state
4 statesImport from the package
import { TooltipRoot, TooltipPopup, TooltipTrigger } from 'topclassui/tooltip'Install once:npm i topclassui@beta zod
Dropdown menu
client - size not measured yetA menu of actions and links behind one button. Link rows are real anchors, so open in a new tab and middle-click work.
Try it
The code
import { copy } from 'topclassui/icons/copy'
import { gear } from 'topclassui/icons/gear'
import { trash } from 'topclassui/icons/trash'
import { user } from 'topclassui/icons/user'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'
<DropdownRoot>
<DropdownTrigger openOnHover render={<Button variant="outline">Actions</Button>} />
<DropdownPopup>
<DropdownItem icon={<Svg icon={user} />} shortcut="Ctrl P">Profile</DropdownItem>
<DropdownItem icon={<Svg icon={gear} />}>Settings</DropdownItem>
<DropdownItem icon={<Svg icon={copy} />}>Duplicate</DropdownItem>
<DropdownSeparator />
<DropdownItem icon={<Svg icon={trash} />}>Delete</DropdownItem>
</DropdownPopup>
</DropdownRoot>href and Server Action items work from a server page; onSelect is a plain function and needs a client file.
Every state
4 statesImport from the package
import { DropdownRoot, DropdownGroup, DropdownGroupLabel, DropdownItem, DropdownLinkItem, DropdownMenu, DropdownPopup, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'Install once:npm i topclassui@beta zod
Command palette
client - size not measured yet, lazyA searchable palette of actions, opened with Ctrl+K or Cmd+K. It loads on its first open, never with the page.
Try it
The code
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
import type { CommandDesignProps } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'button', label: 'Button' },
{ id: 'dialog', label: 'Dialog' },
{ id: 'drawer', label: 'Drawer' },
{ id: 'popover', label: 'Popover' },
{ id: 'tabs', label: 'Tabs' },
{ id: 'accordion', label: 'Accordion' },
{ id: 'table', label: 'Table' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'breadcrumb', label: 'Breadcrumb' },
{ id: 'tooltip', label: 'Tooltip' },
]
/** The docs stage: the basic palette, with the schema's props passed through, so the Try it
* controls drive it. defaultOpen is where the open state starts; the Button opens it too. */
export default function Demo({ visibleRows, virtualize, shortcut, defaultOpen }: CommandDesignProps = {}) {
const [open, setOpen] = useState(defaultOpen ?? false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />Search</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
visibleRows={visibleRows}
virtualize={virtualize}
shortcut={shortcut}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
getKey, getLabel and onSelect are functions, so the palette is configured inside a client file.
Every state
6 statesImport from the package
import { Command } from 'topclassui/command'Install once:npm i topclassui@beta zod
Navigation
6 components
Links are links. Every one of these renders a real <a href>, so middle-click, open in a new tab and crawlers all work.
Navbar
split - size not measured yetA sticky header whose links are server HTML. One small client part publishes its height as --tc-sticky-top.
Try it
The code
import { Button } from 'topclassui/button'
import { NavbarActions, NavbarBrand, NavbarLink, NavbarLinks, NavbarRoot } from 'topclassui/navbar'
<NavbarRoot>
<NavbarBrand href="/">TopClass UI</NavbarBrand>
<NavbarLinks>
<NavbarLink href="/docs">Docs</NavbarLink>
<NavbarLink href="/components">Components</NavbarLink>
<NavbarLink href="/icons">Icons</NavbarLink>
</NavbarLinks>
<NavbarActions>
<Button render={<a href="/login" />}>Sign in</Button>
</NavbarActions>
</NavbarRoot>The bar writes its own height to --tc-sticky-top, so an #anchor target stops below it, not under it.
Every state
2 statesSidebar
split - size not measured yetA rail of server-rendered links that collapses to a column of icons. Only the rail and its toggle run in the browser.
Try it
The code
import { folder } from 'topclassui/icons/folder'
import { gear } from 'topclassui/icons/gear'
import { house } from 'topclassui/icons/house'
import { Svg } from 'topclassui/svg'
import { SidebarBody, SidebarGroup, SidebarHeader, SidebarLink, SidebarRoot } from 'topclassui/sidebar'
<div className="h-96">
<SidebarRoot>
<SidebarHeader>Workspace</SidebarHeader>
<SidebarBody>
<SidebarGroup>
<SidebarLink href="#home" icon={<Svg icon={house} />}>Home</SidebarLink>
<SidebarLink href="#projects" icon={<Svg icon={folder} />}>Projects</SidebarLink>
<SidebarLink href="#settings" icon={<Svg icon={gear} />}>Settings</SidebarLink>
</SidebarGroup>
</SidebarBody>
</SidebarRoot>
</div>defaultOpen={false} sends the rail collapsed in the server HTML, so nothing moves when the page hydrates.
Every state
2 statesTabs
split - size not measured yetViews of one thing, shown one at a time. Every panel renders on the server and stays in the HTML, so hidden text is still indexed.
Try it
The code
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
<TabsRoot defaultValue="overview">
<TabsList aria-label="Project">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="activity">Activity</TabsTab>
<TabsTab value="settings">Settings</TabsTab>
</TabsList>
<TabsPanel value="overview">Revenue is up 12% against last quarter.</TabsPanel>
<TabsPanel value="activity">Three people edited this project today.</TabsPanel>
<TabsPanel value="settings">Only an owner can rename or delete this project.</TabsPanel>
</TabsRoot>keepMounted is true here, where Base UI's default is false: inactive panels stay in the server HTML, hidden.
Every state
2 statesImport from the package
import { TabsRoot, TabsList, TabsPanel, TabsTab } from 'topclassui/tabs'Install once:npm i topclassui@beta zod
Accordion
split - size not measured yetSections a reader opens one at a time. Closed panels stay in the HTML, so a crawler reads them and find-in-page opens them.
Try it
The code
import { AccordionItem, AccordionRoot } from 'topclassui/accordion'
<AccordionRoot className="max-w-105">
<AccordionItem value="server" title="What is a server component?">
<p>A component that renders on the server and sends HTML, with no JavaScript of its own.</p>
</AccordionItem>
<AccordionItem value="arabic" title="Does it work in Arabic?">
<p>Yes. The caret moves to the left edge and the text starts at the right one.</p>
</AccordionItem>
<AccordionItem value="bundle" title="How big is the bundle?">
<p>About one kilobyte in the browser. The answers cost nothing: they are plain HTML.</p>
</AccordionItem>
</AccordionRoot>A closed panel is hidden="until-found": the browser's search, an #id link or a text fragment opens it.
Every state
2 statesImport from the package
import { AccordionRoot, AccordionItem } from 'topclassui/accordion'Install once:npm i topclassui@beta zod
Breadcrumb
server - 0 KBWhere a page sits in the site, as real links with no JavaScript. The same trail is written as BreadcrumbList JSON-LD.
The code
import { Breadcrumb } from 'topclassui/breadcrumb'
/** The last item has no href: it is the page being read, and it stays out of the JSON-LD. */
export default function Demo() {
return (
<Breadcrumb
items={[
{ href: '/', label: 'Home' },
{ href: '/docs', label: 'Docs' },
{ label: 'Breadcrumb' },
]}
/>
)
}
Leave href off the last item: it becomes the current page, and the linked items also emit the BreadcrumbList JSON-LD.
Every state
3 statesPagination
server - 0 KBPage numbers that are real links, not click handlers, so they work with no JavaScript and a crawler follows every page.
Try it
The code
import { Pagination } from 'topclassui/pagination'
<Pagination value={6} pageCount={12} getHref={(page) => `/blog?page=${String(page)}`} />getHref keeps every page crawlable; value, siblingCount and boundaryCount are clamped to a safe range.
Every state
4 statesImport from the package
import { Pagination } from 'topclassui/pagination'Install once:npm i topclassui@beta zod