01

Brand

One mark: the NiN monogram. Monochrome ink, hairline seams, folded bends. Do not redraw it, recolor it, or set it in a typeface.

import { Mark } from '@nin/ui'

Symbol

Where the full mark does not fit — favicons, avatars, app icons — use the square symbol: the monogram on an ink square. Never crop the monogram itself.

Clear space

Keep a safety area around the mark equal to the height of the diamond dot. Nothing enters it — no type, no borders, no badges.

Spelling

Written, the name is always lowercase `nin` — headings, links, URLs, and social tags included. `NiN` exists only inside the drawn mark. The domain is `nin.dev`.

  • Do render the mark in a single ink: foreground on page, background on inverted wells.
  • Do use the static exports in `packages/ui/assets/` for surfaces outside this codebase.
  • Don't recolor, outline, shadow, rotate, or stretch the mark.
  • Don't typeset NiN in a font, and don't rebuild the monogram from letters.
  • Don't place the mark on the field wash without an ink or paper well behind it.

02

Colors

Tailwind v4 scales, 50–950, absolute. Unused hues are tree-shaken. Light lifts by a 12% edge, dark lifts by a raised fill. One gray ladder runs muted → secondary → accent. Light is `:root`, dark is `html.dark`.

Palette

Eight hues, 50–950, absolute. Dark remaps aliases, not the ramp. Click a cell to copy `var(--color-gray-400)`.

50

100

200

300

400

500

600

700

800

900

950

gray

blue

red

amber

green

teal

purple

pink

03

Typography

Geist for the voice. Google Sans Code for indexes, code, and controls.

Geist · Display

text-5xl tracking-tight

Hard surfaces.

Geist · Title

text-3xl tracking-tight

Hard surfaces.

Geist · Body

text-base leading-relaxed

The quick brown fox jumps over the lazy dog.

Google Sans Code · Label

text-[11px] uppercase tracking-wider

api / nin / ui

Google Sans Code · Code

font-mono text-[13px]

import { Button } from '@nin/ui'

04

Materials

A material is a recipe: radius, fill, stroke, shadow. Surface sits on the page; Floating sits above it. Every corner is 0 and nothing casts a shadow, so light lifts by a 1px edge and dark lifts by a raised fill.

Surface · on the page

page

The page, cut out of a well.

background

The canvas. No fill of its own, no stroke — visible only against another material.

well

pnpm dev start

secondary

Inset rest fill: code, wells, filled controls. Hover washes are muted, one step lighter.

frame

email@nin.dev

background + border

Specimen, table, field. Fill stays page; the 1px edge does the work.

invert

Deploy

primary

Inverted ink. One high-contrast action per view.

muted

gray-100 / 900

Hover wash: ghost and outline hover, avatar shell.

secondary

gray-200 / 800

Control at rest. The well.

accent

gray-300 / 700

Selected: menu item, expanded trigger.

One gray axis carries state: rest wash → control fill → selected. The well is secondary — one step firmer than a muted hover, so rest never reads as hover.

Floating · above the page

Delete Project

Real copy sits here so the overlay has ink to press on.

frame · still on the page

modal

popover + 1px ring over overlay — black 50% in both themes.

page

popover

Light: popover keeps the page fill — the halves merge and the 1px ring carries all lift. Dark: popover rises to 0.19, so the fill itself is the lift.

menu

Open Project

Copy Link

Delete Project

Same recipe as modal, no overlay. The held row is accent.

tooltip

Inverted ink. The only floating surface that flips.

radius

Always 0. Geist rounds 6 / 12 / 16 — we do not. Icon buttons stay square.

fill

page at rest, secondary inset, popover floating. muted → secondary → accent is state, not depth.

stroke

`border` is the only edge. `ring` is focus, never a second border. `overlay` dims — it is not a stroke.

shadow

None. Light lifts by the 1px edge; dark lifts by the raised fill.

05

Motion

Motion is not a material. It explains state; it does not fake depth. Three springs on a doubling ladder — 150 / 300 / 600 — all bounce 0, from `@nin/motion`. Click a track to play. Apps wrap `MotionRoot` once.

import { snap, ui, gentle, theme } from '@nin/motion'

snap

150ms

Taps and toggles.

ui

300ms

Panels, dialogs, in-page jumps.

gentle

600ms

Fold and long travel.

stagger.base

40ms

Incoming groups.

travel.enter

8px

fadeUp. Two steps on the 4px grid.

travel.section

16px

Section in-view. Four grid steps.

reducedMotion

user / calm

MotionRoot follows the OS. Theme calm drops travel.

  • Each app wraps `MotionRoot` on the document root — `MotionConfig reducedMotion="user"` plus the theme.
  • Stagger incoming groups at 40ms. Entrances travel 8px, two steps on the 4px grid.
  • CSS transitions default to 150ms — hover and `snap` share one clock. `duration-ui` / `duration-gentle` when CSS must match a spring.
  • Motion UI sections install with `shadcn add @motion/name`. Theme stays `@nin/motion`.
  • Do not turn `Button` into `motion.button`. Animate a wrapper, or leave the primitive alone.

06

Button

import { Button } from '@nin/ui'

Variants

Default mutates. Outline and secondary support. Destructive confirms.

<Button>Deploy</Button>
<Button variant="outline">Cancel</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Delete Project</Button>
<Button variant="link">Docs</Button>

Size

Default is 32px. Icon-only needs an aria-label.

<Button size="xs">Save</Button>
<Button size="sm">Save</Button>
<Button>Save</Button>
<Button size="lg">Save</Button>
<Button size="icon" aria-label="More" />

Disabled

Only when the action is impossible right now. Busy keeps the same label.

<Button disabled>Save</Button>
<Button variant="outline" disabled>Cancel</Button>
<Button variant="destructive" disabled>Delete Project</Button>

Props

NameTypeDefaultNotes
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""default"One default per view. Destructive is confirm only.
size"xs" | "sm" | "default" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"Default is 32px. Icon sizes need aria-label.
disabledbooleanfalseBusy keeps the same label. Do not swap in Spinner.
  • Title Case the result: Deploy Project — not Submit or OK.
  • Destructive actions are Verb + Noun.
  • Icon-only buttons need an aria-label that names the action.

07

Button Group

import { ButtonGroup, Button } from '@nin/ui'

Default

Related actions that share a row.

<ButtonGroup>
  <Button variant="outline">Preview</Button>
  <Button variant="outline">Copy</Button>
  <Button>Deploy</Button>
</ButtonGroup>

Props

NameTypeDefaultNotes
childrenButton[]Related actions on one row.

08

Toggle

import { Toggle, ToggleGroup, ToggleGroupItem } from '@nin/ui'

Toggle

One pressed state. Toolbar, not a setting.

<Toggle>Bold</Toggle>
<Toggle variant="outline" defaultPressed>Italic</Toggle>

Toggle Group

Two or three exclusive views. This is Geist Switch (Source / Output).

<ToggleGroup defaultValue={['source']}>
  <ToggleGroupItem value="source">Source</ToggleGroupItem>
  <ToggleGroupItem value="output">Output</ToggleGroupItem>
</ToggleGroup>

Props

NameTypeDefaultNotes
Toggle.variant"default" | "outline""default"
Toggle.pressedbooleanControlled. Uncontrolled uses defaultPressed.
ToggleGroup.valuestring[]Exclusive views. Past three, use Tabs.
  • Past three options, use Tabs or Select.
  • Boolean settings use Switch, not a one-item Toggle Group.

09

Input

import { Input } from '@nin/ui'

Default

One line. 32px. font-mono. Pair with Field.

<Input placeholder="Email Address" />

Disabled

<Input disabled placeholder="Email Address" />

Invalid

Mark the control with aria-invalid. Copy lives on FieldError.

<Input aria-invalid defaultValue="not-an-email" />

Props

NameTypeDefaultNotes
typestring"text"Native input type.
disabledbooleanfalse
aria-invalidbooleanField copy stays on FieldError.

10

Field

import { Field, FieldLabel, FieldDescription, FieldError, Input } from '@nin/ui'

Default

Label, control, help. Do not use Label on its own.

We will never share your email.

<Field>
  <FieldLabel htmlFor="email">Email Address</FieldLabel>
  <Input id="email" placeholder="you@example.com" />
  <FieldDescription>We will never share your email.</FieldDescription>
</Field>

Disabled

Account email cannot be changed.

<Field data-disabled="true">
  <FieldLabel htmlFor="email">Email Address</FieldLabel>
  <Input id="email" disabled />
</Field>

Invalid

<Field data-invalid="true">
  <FieldLabel htmlFor="email">Email Address</FieldLabel>
  <Input id="email" aria-invalid defaultValue="not-an-email" />
  <FieldError>Enter a valid email address.</FieldError>
</Field>

Props

NameTypeDefaultNotes
orientation"vertical" | "horizontal" | "responsive""vertical"Checkbox / Radio / Switch use horizontal.
data-disabled"true"Dims the label with the control.
data-invalid"true"Pairs with FieldError.

11

Textarea

import { Textarea } from '@nin/ui'

Default

Use the moment the value can wrap.

<Textarea placeholder="Describe Your Project" />

Disabled

<Textarea disabled placeholder="Describe Your Project" />

Invalid

<Textarea aria-invalid defaultValue="Too short." />

Props

NameTypeDefaultNotes
disabledbooleanfalse
aria-invalidbooleanCopy lives on FieldError.

12

Input Group

import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@nin/ui'

Prefix

Units, search, clear. The field is still one Input.

https://
<InputGroup>
  <InputGroupAddon>
    <InputGroupText>https://</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput placeholder="nin.dev" />
</InputGroup>

Suffix

ms
<InputGroup>
  <InputGroupInput placeholder="80" />
  <InputGroupAddon align="inline-end">
    <InputGroupText>ms</InputGroupText>
  </InputGroupAddon>
</InputGroup>

Props

NameTypeDefaultNotes
InputGroupAddon.align"inline-start" | "inline-end""inline-start"
InputGroupInputpartThe field is still one Input.

13

Checkbox

import { Checkbox, Field, FieldContent, FieldDescription, FieldLabel } from '@nin/ui'

Default

Square. Pair with Field orientation=horizontal.

Read the agreement before continuing.

<Field orientation="horizontal">
  <Checkbox id="terms" />
  <FieldContent>
    <FieldLabel htmlFor="terms">Accept Terms</FieldLabel>
    <FieldDescription>Read the agreement before continuing.</FieldDescription>
  </FieldContent>
</Field>

Disabled

This option is unavailable.

<Field orientation="horizontal" data-disabled="true">
  <Checkbox id="terms" disabled />
  <FieldLabel htmlFor="terms">Accept Terms</FieldLabel>
</Field>

Invalid

You must accept before continuing.

<Field orientation="horizontal" data-invalid="true">
  <Checkbox id="terms" aria-invalid />
  <FieldLabel htmlFor="terms">Accept Terms</FieldLabel>
</Field>

Props

NameTypeDefaultNotes
checkedbooleanControlled. Uncontrolled uses defaultChecked.
disabledbooleanfalse
aria-invalidboolean

14

Radio Group

import { RadioGroup, RadioGroupItem, Field, FieldLabel } from '@nin/ui'

Default

Two to five exclusive options. Square, like Checkbox.

<RadioGroup defaultValue="email">
  <Field orientation="horizontal">
    <RadioGroupItem value="email" id="email" />
    <FieldLabel htmlFor="email">Email</FieldLabel>
  </Field>
</RadioGroup>

Disabled

<RadioGroup defaultValue="email" disabled></RadioGroup>

Props

NameTypeDefaultNotes
valuestringControlled. Uncontrolled uses defaultValue.
disabledbooleanfalse
RadioGroupItem.valuestringRequired on each option.

15

Switch

import { Switch, Field, FieldLabel } from '@nin/ui'

Default

Boolean, immediate. This is Geist Toggle. Segmented views use Toggle Group.

Send a digest when a deploy finishes.

<Field orientation="horizontal">
  <Switch id="mail" defaultChecked />
  <FieldContent>
    <FieldLabel htmlFor="mail">Email Notifications</FieldLabel>
  </FieldContent>
</Field>

Size

<Switch size="sm" defaultChecked />
<Switch defaultChecked />

Disabled

<Switch disabled defaultChecked />

Props

NameTypeDefaultNotes
size"sm" | "default""default"
checkedbooleanControlled. Uncontrolled uses defaultChecked.
disabledbooleanfalse
  • Square track. Do not restore a pill.
  • Label the on state, not the word Switch.

16

Select

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nin/ui'

Default

Fixed lists under about ten items. Longer or searchable lists use Combobox.

<Select defaultValue="prod">
  <SelectTrigger>
    <SelectValue placeholder="Environment" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="prod">Production</SelectItem>
    <SelectItem value="preview">Preview</SelectItem>
  </SelectContent>
</Select>

Disabled

<Select defaultValue="prod" disabled></Select>

Props

NameTypeDefaultNotes
valuestringControlled. Uncontrolled uses defaultValue.
disabledbooleanfalse
SelectTrigger.size"sm" | "default""default"28px or 32px.

17

Native Select

import { NativeSelect, NativeSelectOption } from '@nin/ui'

Default

The platform select. Styled lists use Select. Searchable lists use Combobox.

<NativeSelect defaultValue="prod">
  <NativeSelectOption value="prod">Production</NativeSelectOption>
  <NativeSelectOption value="preview">Preview</NativeSelectOption>
</NativeSelect>

Disabled

<NativeSelect defaultValue="prod" disabled></NativeSelect>

Props

NameTypeDefaultNotes
size"sm" | "default""default"
disabledbooleanfalse

18

Combobox

import { Combobox, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem } from '@nin/ui'

Default

Type to filter a known list. Free-form search is still Input.

<Combobox>
  <ComboboxInput placeholder="Region" />
  <ComboboxContent>
    <ComboboxList>
      <ComboboxItem value="iad">Washington, D.C.</ComboboxItem>
      <ComboboxItem value="sfo">San Francisco</ComboboxItem>
    </ComboboxList>
  </ComboboxContent>
</Combobox>

Props

NameTypeDefaultNotes
valuestringControlled. Pair with onValueChange.
ComboboxItem.valuestringThe stored value, not the label.

19

Input OTP

import { InputOTP, InputOTPGroup, InputOTPSlot } from '@nin/ui'

Default

Six slots. Do not fake this with six Inputs.

<InputOTP maxLength={6}>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    {/* … index 1–5 */}
  </InputOTPGroup>
</InputOTP>

Disabled

<InputOTP maxLength={6} disabled></InputOTP>

Invalid

<InputOTP maxLength={6} aria-invalid></InputOTP>

Props

NameTypeDefaultNotes
maxLengthnumber6Always six for email codes.
disabledbooleanfalse
aria-invalidboolean

20

Slider

import { Slider } from '@nin/ui'

Default

Continuous values. Discrete choices stay on Radio or Select.

<Slider defaultValue={[40]} />

Disabled

<Slider defaultValue={[40]} disabled />

Props

NameTypeDefaultNotes
valuenumber[]Controlled. Uncontrolled uses defaultValue.
disabledbooleanfalse

21

Calendar

import { Calendar } from '@nin/ui'

Default

Square days. A field that picks a date uses Date Picker.

August 2026
<Calendar mode="single" selected={date} onSelect={setDate} />

Props

NameTypeDefaultNotes
mode"single" | "multiple" | "range""single"
selectedDateControlled. Pair with onSelect.
captionLayout"label" | "dropdown""label"

22

Date Picker

import { DatePicker } from '@nin/ui'

Default

Calendar inside Popover. Not a registry item — the composition is the component.

<DatePicker value={date} onValueChange={setDate} placeholder="Pick a date" />

Props

NameTypeDefaultNotes
valueDateControlled. Uncontrolled uses defaultValue.
onValueChange(date?: Date) => void
placeholderstring"Pick a date"
disabledbooleanfalse

24

Context Menu

import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from '@nin/ui'

Default

Right-click a row. A visible trigger still uses Dropdown Menu.

Right click here
<ContextMenu>
  <ContextMenuTrigger>Right click here</ContextMenuTrigger>
  <ContextMenuContent>
    <ContextMenuItem>Open</ContextMenuItem>
    <ContextMenuItem variant="destructive">Delete</ContextMenuItem>
  </ContextMenuContent>
</ContextMenu>

Props

NameTypeDefaultNotes
ContextMenuItem.variant"default" | "destructive""default"

26

Tooltip

import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@nin/ui'

Default

Explain an icon button. Inverted ink — the only floating surface that flips.

<TooltipProvider>
  <Tooltip>
    <TooltipTrigger render={<Button size="icon" aria-label="Copy Link" />} />
    <TooltipContent>Copy Link</TooltipContent>
  </Tooltip>
</TooltipProvider>

Props

NameTypeDefaultNotes
TooltipContent.side"top" | "right" | "bottom" | "left""top"
TooltipProvider.delaynumber0Milliseconds before the tip opens.

27

Hover Card

import { HoverCard, HoverCardTrigger, HoverCardContent } from '@nin/ui'

Default

Hover preview. A click still uses Popover.

<HoverCard>
  <HoverCardTrigger render={<Button variant="ghost" />}>@nin</HoverCardTrigger>
  <HoverCardContent>Product UI for nin.dev</HoverCardContent>
</HoverCard>

Props

NameTypeDefaultNotes
HoverCardContent.side"top" | "right" | "bottom" | "left""bottom"
HoverCardContent.align"start" | "center" | "end""center"

28

Popover

import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle } from '@nin/ui'

Default

A small panel from a visible trigger. Confirmations stay in Dialog.

<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>Open Popover</PopoverTrigger>
  <PopoverContent>
    <PopoverHeader>
      <PopoverTitle>Deploy Hook</PopoverTitle>
    </PopoverHeader>
  </PopoverContent>
</Popover>

Props

NameTypeDefaultNotes
openbooleanControlled. Pair with onOpenChange.
PopoverContent.side"top" | "right" | "bottom" | "left""bottom"
PopoverContent.align"start" | "center" | "end""center"

29

Dialog

import { Button, Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@nin/ui'

Confirm

1px ring. Overlay is overlay, not inverted ink.

<Dialog>
  <DialogTrigger render={<Button variant="outline" />}>Open Dialog</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Delete Project</DialogTitle>
      <DialogDescription>This action cannot be undone.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
      <Button variant="destructive">Delete Project</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Email code

Preview only. Six digits. No password.

Props

NameTypeDefaultNotes
openbooleanControlled. Pair with onOpenChange.
overlaytokenoverlayBuilt in. Do not stack your own dim.

30

Alert Dialog

import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogAction } from '@nin/ui'

Confirm

Must answer. Overlay is overlay.

<AlertDialog>
  <AlertDialogTrigger render={<Button variant="destructive" />}>Delete Project</AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Delete Project</AlertDialogTitle>
      <AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction variant="destructive">Delete Project</AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

Props

NameTypeDefaultNotes
openbooleanControlled. Pair with onOpenChange.
AlertDialogAction.variantButton variant"default"Destructive confirms.

31

Sheet

import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from '@nin/ui'

Default

A panel from the edge. Centered confirmations stay Dialog.

<Sheet>
  <SheetTrigger render={<Button variant="outline" />}>Open Sheet</SheetTrigger>
  <SheetContent side="right">
    <SheetHeader>
      <SheetTitle>Project</SheetTitle>
    </SheetHeader>
    <SheetFooter>
      <Button>Save</Button>
    </SheetFooter>
  </SheetContent>
</Sheet>

Props

NameTypeDefaultNotes
openbooleanControlled. Pair with onOpenChange.
SheetContent.side"top" | "right" | "bottom" | "left""right"

32

Drawer

import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle } from '@nin/ui'

Default

A panel you can swipe. Overlay is overlay. Everyday side panels still use Sheet.

<Drawer>
  <DrawerTrigger render={<Button variant="outline" />}>Open Drawer</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Filters</DrawerTitle>
    </DrawerHeader>
  </DrawerContent>
</Drawer>

Props

NameTypeDefaultNotes
openbooleanControlled. Pair with onOpenChange.
swipeDirection"up" | "down" | "left" | "right""down"

33

Command

import { CommandDialog, CommandInput, CommandList, CommandItem } from '@nin/ui'

Palette

Global ⌘K. A menu on one resource is still Dropdown Menu.

<CommandDialog open={open} onOpenChange={setOpen}>
  <CommandInput placeholder="Search projects…" />
  <CommandList>
    <CommandEmpty>No results.</CommandEmpty>
    <CommandGroup heading="Projects">
      <CommandItem>nin</CommandItem>
    </CommandGroup>
  </CommandList>
</CommandDialog>

Props

NameTypeDefaultNotes
openbooleanOn CommandDialog. Pair with onOpenChange.
CommandGroup.headingstringSection label in the list.

34

Alert

import { Alert, AlertDescription, AlertTitle } from '@nin/ui'

Default

Page-level message. Field errors stay on FieldError.

<Alert>
  <AlertTitle>Update Available</AlertTitle>
  <AlertDescription>A new version is ready to install.</AlertDescription>
</Alert>

Destructive

<Alert variant="destructive">
  <AlertTitle>Delete Failed</AlertTitle>
  <AlertDescription>The project could not be removed. Try again.</AlertDescription>
</Alert>

Props

NameTypeDefaultNotes
variant"default" | "destructive""default"Destructive is a failure, not a confirm.

35

Empty

import { Button, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@nin/ui'

Default

No data. Loading a known layout is Skeleton, not Empty.

No Projects Yet
Push a repository to create your first project.
<Empty>
  <EmptyHeader>
    <EmptyTitle>No Projects Yet</EmptyTitle>
    <EmptyDescription>Push a repository to create your first project.</EmptyDescription>
  </EmptyHeader>
  <EmptyContent>
    <Button>Create Project</Button>
  </EmptyContent>
</Empty>

Props

NameTypeDefaultNotes
EmptyTitlepartName the missing thing.
EmptyContentpartOne action, or nothing.

36

Toast

import { toast, Toaster } from '@nin/ui'

Default

Async receipt. The page root already mounts Toaster.

<Button variant="outline" onClick={() => toast('Deploy queued')}>
  Show Toast
</Button>

Props

NameTypeDefaultNotes
toast(message: string) => voidImport toast from @nin/ui. Mount Toaster once at the root.

37

Skeleton

import { Skeleton } from '@nin/ui'

Default

Known layout, data still coming. No data is Empty. A tap in flight stays on the Button.

<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-8 w-full" />

Props

NameTypeDefaultNotes
classNamestringSet the final width and height so the reveal does not reflow.
  • Match the final width and height so the reveal does not reflow.
  • Square only. No pills.

38

Progress

import { Progress, ProgressLabel, ProgressValue } from '@nin/ui'

Default

Known percent. Do not invent a standalone spinner.

Upload
x
<Progress value={64}>
  <ProgressLabel>Upload</ProgressLabel>
  <ProgressValue />
</Progress>

Props

NameTypeDefaultNotes
valuenumber0–100. Known percent only.

39

Spinner

import { Spinner } from '@nin/ui'

Default

An independent wait. A tap in flight stays on the Button — do not swap the button for this.

<Spinner />
<Spinner className="size-6" />

Props

NameTypeDefaultNotes
classNamestringSize only. Do not restyle the icon into a pill.
  • Known layout uses Skeleton. Known percent uses Progress.

41

Pagination

import { Pagination, PaginationContent, PaginationItem, PaginationLink } from '@nin/ui'

Default

<Pagination>
  <PaginationContent>
    <PaginationItem>
      <PaginationPrevious href="?page=1" />
    </PaginationItem>
    <PaginationItem>
      <PaginationLink href="?page=1" isActive>1</PaginationLink>
    </PaginationItem>
    <PaginationItem>
      <PaginationNext href="?page=2" />
    </PaginationItem>
  </PaginationContent>
</Pagination>

Props

NameTypeDefaultNotes
PaginationLink.isActivebooleanfalseThe current page.

42

Tabs

import { Tabs, TabsList, TabsTrigger, TabsContent } from '@nin/ui'

Default

Sibling views on one page. Unrelated pages are not tabs.

Deploy 14 finished in 38s.
<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="logs">Logs</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">Deploy 14 finished in 38s.</TabsContent>
</Tabs>

Line

Same data. Different cut.
<Tabs defaultValue="overview">
  <TabsList variant="line">
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="logs">Logs</TabsTrigger>
  </TabsList>
</Tabs>

Props

NameTypeDefaultNotes
defaultValuestringOr value + onValueChange.
TabsList.variant"default" | "line""default"

44

Avatar

import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from '@nin/ui'

Letter

Image first, then 1–2 uppercase letters. Placeholder is only the loading shell.

ALJDBO
<Avatar>
  <AvatarFallback>AL</AvatarFallback>
</Avatar>

Placeholder

Reserve the empty shell for loading. Never as a permanent fallback.

<Avatar>
  <AvatarFallback className="animate-pulse" />
</Avatar>

Size

24 next to compact type. 32 default. 40 in headers.

SMMDLG
<Avatar size="sm"></Avatar>
<Avatar></Avatar>
<Avatar size="lg"></Avatar>

Group

Stack members. Overflow is AvatarGroupCount, not a fourth face.

ALJDBO
ALJDBOCK
+3
<AvatarGroup>
  <Avatar><AvatarFallback>AL</AvatarFallback></Avatar>
  <Avatar><AvatarFallback>JD</AvatarFallback></Avatar>
  <AvatarGroupCount>+3</AvatarGroupCount>
</AvatarGroup>

Badge

Status only. Not a decorative icon tile.

AL
<Avatar>
  <AvatarFallback>AL</AvatarFallback>
  <AvatarBadge />
</Avatar>

Props

NameTypeDefaultNotes
size"sm" | "default" | "lg""default"24 / 32 / 40.
AvatarImagepartPrefer a photo when you have one.
AvatarFallbackpart1–2 letters, or the loading shell.
  • Keep letters uppercase and derived from the name. No emoji, no punctuation.
  • Do not use GitHub or other third-party avatar URLs as the default.
  • Square. Do not add rounded-full.

45

Badge

import { Badge } from '@nin/ui'

Variants

Indexes, counts, plugin chips. Not decoration.

12Previewapi / ninFailed
<Badge>12</Badge>
<Badge variant="secondary">Preview</Badge>
<Badge variant="outline">api / nin</Badge>
<Badge variant="destructive">Failed</Badge>

Props

NameTypeDefaultNotes
variant"default" | "secondary" | "outline" | "destructive""default"

46

Card

import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@nin/ui'

Frame

One bordered surface. Not a wall of cards, and not a card inside a card.

Production
iad1 · ready
Deploy 14 finished in 38s.
<Card>
  <CardHeader>
    <CardTitle>Production</CardTitle>
    <CardDescription>iad1 · ready</CardDescription>
  </CardHeader>
  <CardContent>Deploy 14 finished in 38s.</CardContent>
  <CardFooter>
    <Button>Open</Button>
  </CardFooter>
</Card>

Props

NameTypeDefaultNotes
size"default" | "sm""default"Tightens spacing.
CardActionpartSits on the header’s right.
  • The page is already a surface. Card is a frame, not a background.
  • Lists stay Table or Item.

47

Table

import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@nin/ui'

Default

A frame of rows. Not a nested card.

ProjectStatusUpdated
ninReady2m
designBuilding14s
<Table>
  <TableHeader>
    <TableRow>
      <TableHead>Project</TableHead>
      <TableHead>Status</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>nin</TableCell>
      <TableCell>Ready</TableCell>
    </TableRow>
  </TableBody>
</Table>

Props

NameTypeDefaultNotes
TableHeadpartColumn title.
TableCellpartA cell. Numbers can be font-mono.

48

Item

import { Item, ItemContent, ItemTitle, ItemDescription } from '@nin/ui'

Default

One row in a list. Tables stay tables.

Production

iad1 · ready

Preview

sfo1 · building

<Item>
  <ItemContent>
    <ItemTitle>Production</ItemTitle>
    <ItemDescription>iad1 · ready</ItemDescription>
  </ItemContent>
</Item>

Props

NameTypeDefaultNotes
ItemTitlepart
ItemDescriptionpartSecondary line.

49

Chart

import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@nin/ui'

Bar

Recharts inside ChartContainer. Color is --chart-1, not a hex.

<ChartContainer config={{ deploy: { label: 'Deploys', color: 'var(--chart-1)' } }}>
  <BarChart data={data}>
    <XAxis dataKey="month" />
    <ChartTooltip content={<ChartTooltipContent />} />
    <Bar dataKey="deploy" fill="var(--color-deploy)" />
  </BarChart>
</ChartContainer>

Props

NameTypeDefaultNotes
configChartConfiglabel + color per series. Color is --chart-*.
childrenRechartsBarChart, LineChart, and so on.

50

Kbd

import { Kbd, KbdGroup } from '@nin/ui'

Combination

Shortcut hints in menus and prose. font-mono.

K
<KbdGroup>
  <Kbd></Kbd>
  <Kbd>K</Kbd>
</KbdGroup>

Props

NameTypeDefaultNotes
KbdpartOne key per Kbd.
KbdGrouppartWraps a combination.

51

Separator

import { Separator } from '@nin/ui'

Default

Full-width hairline. Do not fake a rule with an empty div.

Overview

Logs

<p>Overview</p>
<Separator />
<p>Logs</p>

Props

NameTypeDefaultNotes
orientation"horizontal" | "vertical""horizontal"

52

Accordion

import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@nin/ui'

Default

A stack of headings that reveal a section.

Invoices close on the first of the month.

<Accordion defaultValue={['billing']}>
  <AccordionItem value="billing">
    <AccordionTrigger>Billing</AccordionTrigger>
    <AccordionContent>Invoices close on the first of the month.</AccordionContent>
  </AccordionItem>
</Accordion>

Props

NameTypeDefaultNotes
defaultValuestring[]Open items. Or value + onValueChange.
AccordionItem.valuestringRequired.

53

Collapsible

import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@nin/ui'

Default

One block. A list of headings is Accordion.

<Collapsible>
  <CollapsibleTrigger render={<Button variant="outline" />}>Show Details</CollapsibleTrigger>
  <CollapsibleContent>Commit 4530d29. Ready in 38s.</CollapsibleContent>
</Collapsible>

Props

NameTypeDefaultNotes
openbooleanControlled. Pair with onOpenChange.

54

Scroll Area

import { ScrollArea } from '@nin/ui'

Default

<ScrollArea className="h-32">
  {/* overflowing content */}
</ScrollArea>

Props

NameTypeDefaultNotes
classNamestringGive it a height. The bar is built in.

55

Resizable

import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@nin/ui'

Default

Adjacent panes. The handle is a 1px rule.

Source
Output
<ResizablePanelGroup>
  <ResizablePanel defaultSize={50}>Source</ResizablePanel>
  <ResizableHandle withHandle />
  <ResizablePanel defaultSize={50}>Output</ResizablePanel>
</ResizablePanelGroup>

Props

NameTypeDefaultNotes
orientation"horizontal" | "vertical""horizontal"On the group.
ResizablePanel.defaultSizenumberPercent.
ResizableHandle.withHandlebooleanfalse

56

Aspect Ratio

import { AspectRatio } from '@nin/ui'

16 / 9

16 / 9
<AspectRatio ratio={16 / 9}>
  <img src="/cover.png" alt="" className="absolute inset-0 size-full object-cover" />
</AspectRatio>

Props

NameTypeDefaultNotes
rationumber16 / 9, 1, 4 / 3. Children are absolutely filled.