Skip to content
TopClass UI

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. 1. Install the package

    terminal
    npm i topclassui@beta zod

    Three peer dependencies: react, react-dom and Tailwind CSS 4.2 or later. Your project already has them, and zod installs with the package.

  2. 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. 3. The root layout

    app/[locale]/layout.tsx
    const 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.

  1. The stage

    The component in its most common configuration, on the page background, so you see it in context.

  2. Try it

    Every prop you can change, with its allowed values.

  3. The code

    The JSX that produces the preview above it. Copy it, paste it and it runs.

  4. Every state

    Hover, focus, disabled, loading, error - all at once. You never hover to review a state.

  5. 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 KB

Five variants across six tones, rendered on the server with no JavaScript. Through render it becomes a real link that looks the same.

Install

Try it

variant
tone
size
loading
fullWidth

The code

button.tsx
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 states

Import from the package

topclassui/button
import { Button } from 'topclassui/button'

Install once:npm i topclassui@beta zod

Tag

split - size not measured yet

A label chip for a topic, rendered on the server. Passing onRemove adds an x button, the only part that runs in the browser.

Install
Design systems

Try it

tone
size

The code

tag.tsx
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 states
PlainRemovable

Import from the package

topclassui/tag
import { Tag } from 'topclassui/tag'

Install once:npm i topclassui@beta zod

Kbd

server - 0 KB

A keycap for a key the reader presses, rendered on the server. A combination is sibling keys, with no group part and no plus.

Install
KTabCtrlShiftEnterEsc

The code

kbd.tsx
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

topclassui/kbd
import { Kbd } from 'topclassui/kbd'

Install once:npm i topclassui@beta zod

Segmented control

client - size not measured yet

One 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.

Install

Try it

size
fullWidth
invalid

The code

segmented-control.tsx
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 states

Import from the package

topclassui/segmented-control
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 KB

A native input that renders on the server and ships 0 KB of JavaScript. The types refuse one with no name.

Install

Try it

size
invalid
fullWidth

The code

input.tsx
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 states

Import from the package

topclassui/input
import { Input } from 'topclassui/input'

Install once:npm i topclassui@beta zod

Textarea

split - size not measured yet

A native textarea rendered on the server. Autosize and the live counter are the only parts that load in the browser.

Install

Try it

size
invalid
fullWidth
rows
maxRows
autosize
showCount

The code

textarea.tsx
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 states

Import from the package

topclassui/textarea
import { Textarea } from 'topclassui/textarea'

Install once:npm i topclassui@beta zod

Select

client - size not measured yet

One choice from a list of plain data. The popup stays inside the viewport and scrolls itself, seven rows tall by default.

Install

Try it

size
invalid
fullWidth
placement
visibleRows
loadMoreThreshold

The code

select.tsx
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 states
Pick a paid plan.

Import from the package

topclassui/select
import { Select } from 'topclassui/select'

Install once:npm i topclassui@beta zod

Combobox

client - size not measured yet

A text input that filters a list as you type. It is windowed above 200 options and calls loadMore for the next page.

Install

Try it

size
invalid
fullWidth
placement
visibleRows
hasMore
loadMoreThreshold
status

The code

combobox.tsx
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 states

Import from the package

topclassui/combobox
import { Combobox } from 'topclassui/combobox'

Install once:npm i topclassui@beta zod

Checkbox

client - size not measured yet

Checked, unchecked or mixed. A hidden input sends the default value before the page hydrates, and a form reset brings it back.

Install

Try it

size
invalid

The code

checkbox.tsx
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 states

Import from the package

topclassui/checkbox
import { Checkbox } from 'topclassui/checkbox'

Install once:npm i topclassui@beta zod

Radio group

client - size not measured yet

One 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.

Install

Try it

size
orientation
invalid

The code

radio-group.tsx
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 states

Import from the package

topclassui/radio-group
import { RadioGroupRoot, RadioGroupItem } from 'topclassui/radio-group'

Install once:npm i topclassui@beta zod

Switch

client - size not measured yet

An on and off switch for a setting that takes effect at once. Its thumb travels the right way in right-to-left layouts.

Install

Try it

size
invalid

The code

switch.tsx
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 states

Import from the package

topclassui/switch
import { Switch } from 'topclassui/switch'

Install once:npm i topclassui@beta zod

Slider

client - size not measured yet

A single value or a range of two. Arrow keys and a press on the track give the same result as every drag.

Install

Try it

size
invalid
fullWidth
showValue
min
max
step
largeStep
minStepsBetweenValues

The code

slider.tsx
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 states

Import from the package

topclassui/slider
import { Slider } from 'topclassui/slider'

Install once:npm i topclassui@beta zod

Number field

client - size not measured yet

A number between known bounds, typed or stepped. A hidden input carries the plain number, so the form submits before hydration.

Install

Try it

size
invalid
fullWidth
min
max

The code

number-field.tsx
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 states

Import from the package

topclassui/number-field
import { NumberField } from 'topclassui/number-field'

Install once:npm i topclassui@beta zod

OTP field

client - size not measured yet

A one-time code typed one character per cell. A paste fills every cell, and one hidden input carries the code to the form.

Install

Try it

length
size
invalid
groupSize

The code

otp-field.tsx
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 states

Import from the package

topclassui/otp-field
import { OtpField } from 'topclassui/otp-field'

Install once:npm i topclassui@beta zod

Date picker

client - size not measured yet, lazy

One day picked from a month. The calendar loads on its first open, and every month name and digit comes from Intl.

Install

Try it

size
invalid
fullWidth
placement
firstDayOfWeek

The code

date-picker.tsx
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 states
Pick a weekday.

Import from the package

topclassui/date-picker
import { DatePicker } from 'topclassui/date-picker'

Install once:npm i topclassui@beta zod

File upload

client - size not measured yet

A drop zone that is a real file input, so it works and posts before any script runs. Picked files show as a list.

Install

Try it

maxFiles
maxSize
invalid
visibleRows

The code

file-upload.tsx
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 states
Drop files hereBrowseA file is required
    Drop files hereBrowseUploads are closed

      Import from the package

      topclassui/file-upload
      import { FileUpload } from 'topclassui/file-upload'

      Install once:npm i topclassui@beta zod

      Rating

      split - size not measured yet

      Read-only stars are server HTML and ship no script. Only an editable rating hydrates, as a group of native radios.

      Install

      Try it

      value
      max
      size
      readOnly
      defaultValue
      disabled

      The code

      rating.tsx
      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 states

      Import from the package

      topclassui/rating
      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 KB

      A short status label in three variants across six tones. It renders on the server and ships no JavaScript.

      Install
      Paid

      Try it

      variant
      tone
      size

      The code

      badge.tsx
      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 states
      SolidSoftOutline

      Import from the package

      topclassui/badge
      import { Badge } from 'topclassui/badge'

      Install once:npm i topclassui@beta zod

      Avatar

      split - size not measured yet

      A picture, initials or an icon in a circle, on the same five sizes as every control so an avatar and a button line up.

      Install
      Omar Abed

      Try it

      size

      The code

      avatar.tsx
      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 states
      Omar AbedImage
      Initials
      Icon
      Sara KhalilFailed
      Loading

      Import from the package

      topclassui/avatar
      import { AvatarRoot, AvatarImage, AvatarFallback, AvatarSkeleton } from 'topclassui/avatar'

      Install once:npm i topclassui@beta zod

      Avatar group

      server - 0 KB

      Overlapping avatars with a +N counter for the rest. Render every member; max is clamped from 1 to 20.

      Install

      Try it

      max
      size

      The code

      avatar-group.tsx
      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 state
      Two
      Four
      Overflow

      Import from the package

      topclassui/avatar-group
      import { AvatarGroup } from 'topclassui/avatar-group'

      Install once:npm i topclassui@beta zod

      Card

      server - 0 KB

      A surface for content that belongs together, with a larger radius than a control so it never reads as a button.

      Install

      Storage

      You have used 18 GB of the 50 GB in your plan.

      The code

      card.tsx
      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 states

      Storage

      You have used 18 GB of the 50 GB in your plan.
      Idle

      Version 0.4

      Card is now CardRoot. Find and replace; nothing else changed.
      With actions
      Loading

      Import from the package

      topclassui/card
      import { CardRoot, CardHeader, CardBody, CardFooter, CardSkeleton } from 'topclassui/card'

      Install once:npm i topclassui@beta zod

      Separator

      server - 0 KB

      A hairline between two groups, across a column or down a row. Decorative by default, so it is not announced.

      Install
      Profile
      Billing

      Try it

      orientation
      decorative

      The code

      separator.tsx
      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 states
      Account
      Security
      Horizontal
      Draft
      Edited 2 hours ago
      Vertical

      Import from the package

      topclassui/separator
      import { Separator } from 'topclassui/separator'

      Install once:npm i topclassui@beta zod

      Skeleton

      server - 0 KB

      A placeholder that holds the shape of content while it loads: lines of text, a circle, a rectangle or a card.

      Install

      Try it

      variant
      rows

      The code

      skeleton.tsx
      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 state
      Text
      Circle
      Rect
      Card

      Import from the package

      topclassui/skeleton
      import { Skeleton } from 'topclassui/skeleton'

      Install once:npm i topclassui@beta zod

      Code block

      split - size not measured yet

      Code 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.

      Install
      app/invoices/page.tsx
      export default async function Page() {
        const invoices = await getInvoices({ status: 'overdue', sortBy: 'dueDate', order: 'descending' })
        return <InvoiceTable items={invoices} />
      }

      Try it

      showCopy
      wrap

      The code

      code-block.tsx
      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 states
      app/page.tsx
      export default async function Page() {
        const invoices = await getInvoices()
        return <InvoiceTable items={invoices} />
      }
      Idle
      columns.ts
      export const columns = ['number', 'customer', 'issued', 'due', 'amount', 'status', 'actions'] as const
      Wrapped
      app/reports/page.tsx
      export default async function Page() {
        const invoices = await getInvoices()
        return <InvoiceTable items={invoices} />
      }
      No copy button

      Import from the package

      topclassui/code-block
      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 yet

      A real HTML table rendered on the server: sorting is a link per header, the first column can stick, and it ships no JavaScript.

      Install
      Quarterly revenue by region
      RegionQ1Q2Q3Q4Full year
      Middle East$412,000$455,300$498,120$530,900$1,896,320
      North Africa$208,400$214,950$240,010$262,700$926,060
      Western Europe$890,150$874,600$910,480$955,230$3,630,460
      North America$1,204,800$1,260,340$1,315,900$1,402,650$5,183,690

      Try it

      captionVisible
      striped
      stickyFirstColumn
      visibleRows
      loadMoreThreshold

      The code

      table.tsx
      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 states
      Deploys
      DeploySort by BranchAuthorSort by Duration
      dpl_7f3amainLayla Haddad48 s
      dpl_7f39fix/checkout-totalOmar Abed51 s
      dpl_7f38mainSara Nasser47 s
      Deploys, caption drawn
      DeploySort by BranchAuthorSort by Duration
      dpl_7f3amainLayla Haddad48 s
      dpl_7f39fix/checkout-totalOmar Abed51 s
      dpl_7f38mainSara Nasser47 s
      Deploys, striped
      DeploySort by BranchAuthorSort by Duration
      dpl_7f3amainLayla Haddad48 s
      dpl_7f39fix/checkout-totalOmar Abed51 s
      dpl_7f38mainSara Nasser47 s
      Deploys, slowest first
      DeploySort by BranchAuthorSort by Duration
      dpl_7f3amainLayla Haddad48 s
      dpl_7f39fix/checkout-totalOmar Abed51 s
      dpl_7f38mainSara Nasser47 s
      Deploys, first column stuck
      DeploySort by BranchAuthorSort by Duration
      dpl_7f3amainLayla Haddad48 s
      dpl_7f39fix/checkout-totalOmar Abed51 s
      dpl_7f38mainSara Nasser47 s
      Deploys, none yet
      DeploySort by BranchAuthorSort by Duration
      Nothing here yet

      Import from the package

      topclassui/table
      import { Table } from 'topclassui/table'

      Install once:npm i topclassui@beta zod

      Tree view

      client - size not measured yet

      Nodes in levels that open and close. Open branches flatten into one windowed list, so 10,000 nodes stay fast.

      Install

      Try it

      visibleRows
      loadMoreThreshold
      status

      The code

      tree-view.tsx
      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 states
      app
      layout.tsx
      page.tsx
      docs
      guide.md
      faq.md
      next.config.ts
      next.config.ts
      Loading more
      Loading
      Nothing here yet

      Import from the package

      topclassui/tree-view
      import { TreeView } from 'topclassui/tree-view'

      Install once:npm i topclassui@beta zod

      Timeline

      server - 0 KB

      A 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.

      Install
      1. Done
        Project created
      2. Done
        First deploy
      3. Current
        In review
        Today
      4. Not started
        Launch
        Not scheduled

      The code

      timeline.tsx
      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
      1. Done
        Done
        A filled dot in success
      1. Current
        Current
        A dot in a ring, read as the current step
      1. Not started
        To do
        A hollow dot
      1. A tone of its own
        No status, the danger tone
      1. Done
        An icon
        Takes the tone of its status
      1. Not started
        An icon to do

      Import from the package

      topclassui/timeline
      import { Timeline } from 'topclassui/timeline'

      Install once:npm i topclassui@beta zod

      Stepper

      server - 0 KB

      Where 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.

      Install
      1. AccountCompleted
      2. Profile
      3. TeamNot started
      4. DoneNot started

      Try it

      size

      The code

      stepper.tsx
      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
      1. Cart
      2. AddressNot started
      3. PaymentNot started
      1. CartCompleted
      2. Address
      3. PaymentNot started
      1. CartCompleted
      2. AddressNeeds attention
      3. PaymentNot started
      1. CartCompleted
      2. AddressCompleted
      3. PaymentCompleted

      Import from the package

      topclassui/stepper
      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 KB

      A message that belongs to the page, in six tones. Info, success, warning and danger each draw their own icon, so it reads without colour.

      Install

      Your trial ends in three days

      Deploys pause when it does. Adding a payment method turns them back on.

      Try it

      variant
      tone

      The code

      alert.tsx
      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 states

      Soft

      A tint of the tone.

      Solid

      The full fill.

      Outline

      A line in the tone colour.

      Primary

      A new version is ready.

      Neutral

      Nothing has changed since Monday.

      Success

      The invoice was paid.

      Warning

      The trial ends in three days.

      Danger

      The last deploy failed.

      Info

      Maintenance starts at 22:00.

      Title only

      Description only, with no title above it.

      No icon

      The icon is left out.

      With actions

      Actions sit in a row under the description.

      Import from the package

      topclassui/alert
      import { Alert } from 'topclassui/alert'

      Install once:npm i topclassui@beta zod

      Toast

      client - size not measured yet

      A short notice that something happened. It shows in a corner, leaves by itself, and always has an x, because a swipe is a drag.

      Install

      Try it

      tone
      duration
      priority

      The code

      toast.tsx
      '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 states

      Import from the package

      topclassui/toast
      import { Toast } from 'topclassui/toast'

      Install once:npm i topclassui@beta zod

      Banner

      split - size not measured yet

      One 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.

      Install

      Try it

      tone
      dismissible

      The code

      banner.tsx
      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 states
      Primary
      Neutral
      Success
      Warning
      Danger
      Info
      No icon
      With action

      Import from the package

      topclassui/banner
      import { Banner } from 'topclassui/banner'

      Install once:npm i topclassui@beta zod

      Progress

      server - 0 KB

      How 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.

      Install

      Try it

      value
      tone
      size
      indeterminate

      The code

      progress.tsx
      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 states
      Empty
      Part way
      Complete
      Indeterminate
      Tones

      Import from the package

      topclassui/progress
      import { Progress } from 'topclassui/progress'

      Install once:npm i topclassui@beta zod

      Spinner

      server - 0 KB

      For 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.

      Install

      Try it

      size
      tone

      The code

      spinner.tsx
      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 states

      Decorative, in the text colour

      Import from the package

      topclassui/spinner
      import { Spinner } from 'topclassui/spinner'

      Install once:npm i topclassui@beta zod

      Empty state

      server - 0 KB

      What a list, a table or a search shows when there is nothing to show: an icon, a line, a sentence and your own action.

      Install

      Nothing here yet

      When there is something to show, it will appear here.

      The code

      empty-state.tsx
      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 states

      Nothing here yet

      When there is something to show, it will appear here.

      Title only

      With action

      The action is the caller's own Button.

      Failed

      Check your connection, then try again.

      Filtered

      Nothing matches that search.

      Import from the package

      topclassui/empty-state
      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 yet

      A modal dialog whose body scrolls between a sticky header and footer, while the page behind it stays still.

      Install

      Try it

      size
      closeButton

      The code

      dialog.tsx
      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 states

      Import from the package

      topclassui/dialog
      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 yet

      A dialog that slides in from a logical edge, so it mirrors in Arabic with no code change. A swipe or the x closes it.

      Install

      Try it

      placement
      size

      The code

      drawer.tsx
      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 states

      Import from the package

      topclassui/drawer
      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 yet

      An anchored panel for a link, a field or an action. It flips and shrinks to the room it has instead of overflowing.

      Install

      Try it

      placement
      delay
      closeDelay

      The code

      popover.tsx
      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 states

      Import from the package

      topclassui/popover
      import { PopoverRoot, PopoverClose, PopoverDescription, PopoverPopup, PopoverTitle, PopoverTrigger } from 'topclassui/popover'

      Install once:npm i topclassui@beta zod

      Tooltip

      client - size not measured yet

      One or two lines of plain text on hover or focus: the name of an icon-only control, or a label that truncates.

      Install

      Try it

      placement
      delay
      closeDelay

      The code

      tooltip.tsx
      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 states

      Import from the package

      topclassui/tooltip
      import { TooltipRoot, TooltipPopup, TooltipTrigger } from 'topclassui/tooltip'

      Install once:npm i topclassui@beta zod

      Dropdown menu

      client - size not measured yet

      A menu of actions and links behind one button. Link rows are real anchors, so open in a new tab and middle-click work.

      Install

      Try it

      placement
      tone
      delay
      closeDelay

      The code

      dropdown.tsx
      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 states

      Import from the package

      topclassui/dropdown
      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, lazy

      A searchable palette of actions, opened with Ctrl+K or Cmd+K. It loads on its first open, never with the page.

      Install

      Try it

      visibleRows
      loadMoreThreshold
      defaultOpen

      The code

      command.tsx
      '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 states

      Import from the package

      topclassui/command
      import { Command } from 'topclassui/command'

      Install once:npm i topclassui@beta zod

      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 yet

      A sticky header whose links are server HTML. One small client part publishes its height as --tc-sticky-top.

      Install

      The code

      navbar.tsx
      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.

      Import from the package

      topclassui/navbar
      import { NavbarRoot, NavbarBrand, NavbarLinks, NavbarLink, NavbarActions } from 'topclassui/navbar'

      Install once:npm i topclassui@beta zod

      Sidebar

      split - size not measured yet

      A rail of server-rendered links that collapses to a column of icons. Only the rail and its toggle run in the browser.

      Install

      Try it

      defaultOpen
      open
      current

      The code

      sidebar.tsx
      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 states

      Import from the package

      topclassui/sidebar
      import { SidebarRoot, SidebarTrigger, SidebarHeader, SidebarBody, SidebarGroup, SidebarLink } from 'topclassui/sidebar'

      Install once:npm i topclassui@beta zod

      Tabs

      split - size not measured yet

      Views 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.

      Install
      Revenue is up 12% against last quarter.

      Try it

      variant
      size
      keepMounted

      The code

      tabs.tsx
      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 states
      The active tab is semibold over a bar of the primary colour.
      The active tab sits on a raised surface.

      Import from the package

      topclassui/tabs
      import { TabsRoot, TabsList, TabsPanel, TabsTab } from 'topclassui/tabs'

      Install once:npm i topclassui@beta zod

      Accordion

      split - size not measured yet

      Sections a reader opens one at a time. Closed panels stay in the HTML, so a crawler reads them and find-in-page opens them.

      Install

      Try it

      hiddenUntilFound
      headingLevel

      The code

      accordion.tsx
      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 states

      The panel of an open item is shown under its trigger.

      Import from the package

      topclassui/accordion
      import { AccordionRoot, AccordionItem } from 'topclassui/accordion'

      Install once:npm i topclassui@beta zod

      Breadcrumb

      server - 0 KB

      Where a page sits in the site, as real links with no JavaScript. The same trail is written as BreadcrumbList JSON-LD.

      Install

      The code

      breadcrumb.tsx
      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 states

      Import from the package

      topclassui/breadcrumb
      import { Breadcrumb } from 'topclassui/breadcrumb'

      Install once:npm i topclassui@beta zod

      Pagination

      server - 0 KB

      Page numbers that are real links, not click handlers, so they work with no JavaScript and a crawler follows every page.

      Install

      Try it

      value
      pageCount
      siblingCount
      boundaryCount
      size

      The code

      pagination.tsx
      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 states

      Import from the package

      topclassui/pagination
      import { Pagination } from 'topclassui/pagination'

      Install once:npm i topclassui@beta zod