Theming

Every component here is painted with the standard shadcn CSS variables. Change the variables and the whole kit follows — no theme prop, no config object.

Tokens

Nothing in this registry defines a color of its own. If your project ran shadcn init, these are already in your globals.css.

Tokens the components read

--background / --foregroundcolor
Page surface and body text.
--muted / --muted-foregroundcolor
User bubbles, code surfaces, tool output, secondary text.
--primary / --primary-foregroundcolor
Send button, badges, pin markers, the accent drop zone.
--destructivecolor
Failed tool calls and the trash drop zone.
--border / --input / --ringcolor
Hairlines, field borders, and the 3px focus ring shared by every control.
--popover / --popover-foregroundcolor
Dropdown menus, the slash menu, drag ghosts.
--card / --card-foregroundcolor
Artifact cards.
--sidebar*color
--sidebar, --sidebar-foreground, --sidebar-accent, --sidebar-accent-foreground, --sidebar-border, --sidebar-ring.
--radiuslength
Drives the whole radius scale. Surfaces stay on it — rounded-md for controls, rounded-lg for cards, and rounded-2xl as the one soft step, for the composer.
--ui-scalenumber
Interface scale, 1 by default. Set it when you scale the app with CSS zoom so portalled menus scale with it — see Scaling the whole UI below.
--font-sans / --font-monofont stack
ChatGPT uses Klim’s Söhne (licensed). We ship Inter in that slot, then the same system fallbacks they use.

Dark mode

Dark styling is class based: define the same variables under .dark, and drive that class with next-themes. The two components that read the resolved theme directly — ThemeToggle and the Shiki/Mermaid renderers — pick it up from the same provider.

app/globals.css
@custom-variant dark (&:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.32 0 0);
  --primary: oklch(0.62 0.19 259.81);
  --sidebar: oklch(0.98 0.003 247.84);
  --radius: 0.375rem;
}

.dark {
  --background: oklch(0.2 0 0);
  --foreground: oklch(0.92 0 0);
  --primary: oklch(0.62 0.19 259.81);
  --sidebar: oklch(0.2 0 0);
}

Restyling a component

Three levers, in order of how far they reach. None of them require editing the installed file.

1. className merges last

Every component ends its class list with cn(…, className), so tailwind-merge lets your class replace the default it conflicts with. Several components add a second, deeper hook — contentClassName on Message, classNames on ChatSidebar.

<ChatInput className="max-w-2xl" />
<Message sender="user" content={text} contentClassName="bg-primary/10" />
<ChatSidebar classNames={{ header: "pt-4", content: "px-3" }} />

2. data-slot targeting

Internals are stamped with data-slot and, where they have state, a matching data attribute. That makes them reachable from any ancestor with an arbitrary variant.

// Square off the composer card
<ChatInput className="[&_[data-slot=chat-input-surface]]:rounded-xl" />

// Recolor every user turn from the scroll container
<MessageList
  className="[&_[data-slot=message][data-sender=user]_[data-slot=message-content]]:bg-primary/10"
  messages={messages}
/>

// Tint failed tool calls
<div className="[&_[data-slot=message-tool-call][data-status=error]]:text-destructive">
  <MessageToolCalls tools={tools} />
</div>

Each component page lists the slots it stamps under API reference → Data slots.

3. Shared variant recipes

ChatInput exports chatInputButtonVariants — the exact recipe its own attach and send buttons use — so a custom composer control lines up with the toolbar instead of approximating it.

import { ChatInput, chatInputButtonVariants } from "@/components/ui/chat-input"

<ChatInput
  tools={
    <button className={cn(chatInputButtonVariants(), "text-primary")}>
      <Globe />
      <span>Search</span>
    </button>
  }
  onSend={send}
/>

Scaling the whole UI

An app that offers a zoom control cannot simply put zoom on <html>. Radix portals render into <body>, so a menu, a popover or a context menu would land inside the scaled subtree, where Floating UI measures rects in visual pixels and writes the result into a layout-unit box — every menu drifts off screen by the scale factor.

So the scale is published as a variable and applied to a wrapper inside the body instead, and each portalled surface scales itself. Anchor, content and viewport then stay in one coordinate space, and the menu comes out the right size in the right place. Two edits do it, both in your own copies of the shadcn primitives — PopoverContent, DropdownMenuContent and ContextMenuContent, which this registry lists as dependencies rather than ships. Sub-content is not portalled, so it deliberately does not repeat the scale: it inherits its parent's.

The widths are the part that catches people out: vw is not scaled by zoom, so a clamp against the viewport has to divide it back out. Every picker in this registry already does — ModelPicker, ModePicker, FolderPicker. Radix's available-height variable has the same problem: it is measured before the portal content is zoomed, so DropdownMenuContent divides it by the scale before using it as its max-height — otherwise a long menu clips above 100% and wastes room below it.

/* On the app wrapper, not on <html> — portals must stay outside it. */
<div style={{ zoom: "var(--ui-scale, 1)" }}>{children}</div>

/* In PopoverContent / DropdownMenuContent / ContextMenuContent */
className={cn("[zoom:var(--ui-scale,1)]", …)}

/* A viewport-clamped width inside a portal: vw is not scaled by zoom */
className="w-[min(17rem,calc((100vw-1.5rem)/var(--ui-scale,1)))]"

/* DropdownMenuContent: Radix measures the available height before the
   content is zoomed, so divide it back out or the menu clips at > 100% */
className="max-h-[calc(var(--radix-dropdown-menu-content-available-height)/var(--ui-scale,1))]"

Set nothing and every one of these is a no-op — var(--ui-scale, 1) falls back to 1.

House rules

  • Focus on a control is always a 3px ring-ring/50 — keep it if you restyle one, and never trade it for outline-none alone. Large surfaces that merely contain a focused field — the composer card, the edit bubble — shift their border to border-ring instead, because a ring that wide reads as an error state.
  • UI text sits on a text-[13px]—text-[15px] scale; message bodies are 15px with a 1.65 line height.
  • Mobile textareas are text-base on purpose — anything smaller makes iOS Safari zoom on focus.
  • Icons are normalized to size-4 unless they already carry a size class.