# Beste UI

> Beautiful, accessible blocks, pieces and components for shadcn/ui and Tailwind CSS, built for React and Next.js. Install one with the shadcn CLI and the code is yours to edit: no runtime dependency, nothing to upgrade.

Markdown rendition of https://ui.beste.co/blog/anatomy-of-a-shadcn-theme. Every page on the site has one: add `.md` to any address, or send `Accept: text/markdown`.

Last updated: 2026-07-21

Whole catalog for LLMs: https://ui.beste.co/llms.txt. Registry index: https://ui.beste.co/r/registry.json

---
[All posts](/blog)

July 21, 2026·15 min read

# The Anatomy of a shadcn Theme: OKLCH, Design Tokens, and Shipping a Whole System in One Install

A shadcn theme is not a color palette, it is a small token graph. Here is the whole anatomy: the two-layer CSS variable system, why the values are OKLCH, the semantic naming that survives a redesign, one variable that drives every corner radius, dark mode without the flash, and how registry:base ships the entire thing behind a single install command.

shadcnthemingoklchtailwinddesign-tokens

![zieg](/_next/image?url=%2Fassets%2Fimages%2Fzieg.jpg&w=96&q=75&dpl=dpl_CY2dAqud5esqh3XyqpW5XSreDKej)

ziegbuilding beste.co

[zieg's website](https://zieg.beste.co)[zieg on X](https://x.com/forwardset)[zieg on LinkedIn](https://linkedin.com/in/ziegfiroyt)

Open any shadcn/ui project, look at the top of `globals.css`, and you will find something that looks like a color list and is actually a machine. Forty-odd CSS variables, a second block that looks almost identical to the first, a lone `--radius`, and a directive named `@theme inline` that most people scroll straight past.

That machine is the theme. Not the colors it happens to hold today, the machine. Change one number at the top and the corner radius updates across an entire scale. Add a class to the `<html>` element and every surface on the page inverts without a single component re-rendering. Understanding how that works is the difference between nudging hex codes until something looks right and actually owning your design system.

This post is the full anatomy. We will read the real theme this site ships, explain every layer, and finish where the payoff is: packaging the whole thing so someone installs your entire design language with one command. Because these components are [source code you own](/blog/copy-paste-vs-library), none of it is behind a package boundary. It is all right there in your CSS, editable.

### A theme is two sets of variables, not one

The first thing to internalize is that there are two variable layers, and they do different jobs. Here is the top of this site's actual theme, trimmed for length:

cssCopy

```
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.239 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.239 0 0);
  --primary: oklch(0.321 0 0);
  --primary-foreground: oklch(0.98 0 0);
  --muted: oklch(0.961 0 0);
  --muted-foreground: oklch(0.651 0 0);
  --destructive: oklch(0.702 0.216 15.859);
  --border: oklch(0.898 0 0);
  --ring: oklch(0.239 0 0);
}
```

These are the **raw tokens**. Plain CSS custom properties, no Tailwind involvement yet. If that were the whole story, you would write `style={{ background: "var(--primary)" }}` by hand everywhere, and you would get no utility classes out of it.

The second layer turns them into utilities. In [Tailwind v4](/blog/container-queries-for-blocks) you do not configure colors in a JavaScript file anymore, you declare them in CSS with the [@theme directive](https://tailwindcss.com/docs/theme), and Tailwind generates matching utility classes. shadcn uses the `inline` variant of it:

cssCopy

```
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-muted-foreground: var(--muted-foreground);
  /* ...one line per token... */
}
```

The `--color-*` namespace is what mints the utilities. `--color-primary` is why `bg-primary`, `text-primary`, and `border-primary` exist at all. `--color-muted-foreground` is why `text-muted-foreground` works. No entry in this block, no class.

#### Why `inline`, and why it matters

The `inline` keyword is not decoration. Tailwind's docs put the distinction plainly: without `inline`, a generated utility _references_ the theme variable; with `inline`, the utility is compiled with the variable's _value_ substituted in place. Compare the two compiled outputs of `bg-primary`:

cssCopy

```
/* @theme (no inline): Tailwind also emits its own --color-primary into
   :root, and the utility hops through it. */
.bg-primary { background-color: var(--color-primary); }

/* @theme inline: the utility is welded straight to your raw token. */
.bg-primary { background-color: var(--primary); }
```

The `inline` version collapses a redundant layer. Instead of Tailwind minting a second variable (`--color-primary`) that just forwards to yours (`--primary`), the utility points directly at the token you actually override under `.dark`. One source of truth, no forwarding hop.

There is also a correctness reason the shadcn setup needs it. `@theme inline` is the option Tailwind [documents specifically for the case where your theme value references another variable](https://tailwindcss.com/docs/theme) that gets its real value lower in the tree, which is exactly how fonts work here: `--font-sans: var(--font-manrope)`, where `--font-manrope` is set on an element by `next/font`. Without `inline`, the font utility resolves against `:root`, where that variable may be empty; with `inline`, it resolves at the element where the font actually lives.

The rule that follows from all this is the one to tattoo somewhere: **never put a literal color inside `@theme`.** The instant `bg-primary` compiles to a fixed `oklch(...)` value instead of `var(--primary)`, no `.dark` override can ever reach it, and your dark mode silently does nothing. Keeping the value as `var(--primary)` is what preserves a single, overridable source of truth.

### The second block: dark mode is the same tokens, different values

Scroll down in the same file and you meet the twin of `:root`. Same variable names, different numbers:

cssCopy

```
.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --border: oklch(0.275 0 0);
  --ring: oklch(0.556 0 0);
}
```

Notice what dark mode is _not_. It is not a parallel set of components, not a `dark:` prefix sprinkled across a thousand class strings, not a second stylesheet. It is the same token names holding different values, scoped under a class. Because every component reads `bg-card` and `text-foreground` (never a literal `bg-white` or `text-neutral-900`), flipping the values under `.dark` re-themes the entire application at once.

The `.dark` selector gets wired to Tailwind's `dark:` variant with one line at the top of the file:

cssCopy

```
@custom-variant dark (&:is(.dark *));
```

That reads: "the `dark:` variant applies to any element that is a descendant of something with class `dark`." Put `class="dark"` on `<html>`, and both the raw `.dark` token overrides and every `dark:` utility light up together.

### Why the values are OKLCH

You have seen `oklch(...)` in every snippet above. This is deliberate, and it is worth understanding rather than copying. [Tailwind v4 moved its entire default palette from RGB hex to OKLCH](https://tailwindcss.com/blog/tailwindcss-v4), and shadcn followed. The three channels are:

* **L, lightness**, from `0` (black) to `1` (white). Crucially this is _perceived_ lightness, calibrated to the human eye.
* **C, chroma**, saturation from `0` (gray) upward. It has no fixed ceiling, but real displays top out around `0.37`, so that is the practical max.
* **H, hue**, an angle from `0` to `360`. Roughly: red near `20`, yellow `90`, green `140`, blue `220`, purple `320`.

The reason this beats `hsl()` is [perceptual uniformity](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl). HSL pretends every hue has the same available saturation and that lightness means the same thing at every hue. Human vision does not work that way. In HSL, `hsl(60 100% 50%)` (yellow) is blinding while `hsl(240 100% 50%)` (blue) is muddy and dark, despite both claiming `50%` lightness. That inconsistency is a real accessibility hazard: derive an error red from a brand accent by rotating the hue, and the text contrast can silently collapse because the lightness moved with it.

OKLCH fixes this by keeping `L` honest. `oklch(0.55 0.18 20)` and `oklch(0.55 0.18 255)` are a red and a blue that genuinely read as the same brightness. That is why a well built token scale in OKLCH holds its contrast as you shift the hue, which matters the moment you support more than one brand color.

There is a second dividend: OKLCH can address the [P3 wide gamut](https://oklch.com), which covers roughly 30 percent more colors than sRGB, many of which plain hex literally cannot encode. Your most vivid accents are no longer clamped to 1996's color space.

#### See the machine move

This is easier to feel than to read. Below is a self-contained miniature design system, a card, a button, an input, a badge, a swatch row, themed entirely by two variables: one OKLCH `--primary` and one `--radius`. Drag the channels. Watch every element follow from a single source, and watch the button's text auto-pick dark or light ink as the primary crosses the lightness threshold where contrast would break.

Lightness (L)0.550Chroma (C)0.180Hue (H)255degRadius0.625rem

:root {
  --primary: oklch(0.550 0.180 255);
  --radius: 0.625rem;
}

PreviewNew

#### Upgrade your workspace

Every accent here reads from a single token. Move a slider, the whole surface follows.

Get startedLearn more

Two things are worth noticing while you play. First, hue is a single number, so rebranding from blue to violet is one slider, not a find-and-replace across a codebase. Second, the neutral grays in this site's theme are just OKLCH with chroma set to `0`: `oklch(0.239 0 0)` is a pure neutral, no hue at all. Nudge chroma up a hair and you get a "warm gray" or "cool gray" for free, the trick behind tinted, non-clinical dark modes.

### The semantic layer: names that survive a redesign

The single most important convention in a shadcn theme is not a color, it is a naming rule: **tokens are named for their job, not their appearance.** The pairing looks like this everywhere:

| Surface token  | Foreground token          | Meaning                      |
| -------------- | ------------------------- | ---------------------------- |
| \--background  | \--foreground             | The page itself              |
| \--card        | \--card-foreground        | Raised panels, cards         |
| \--primary     | \--primary-foreground     | The main action color        |
| \--muted       | \--muted-foreground       | De-emphasized areas and text |
| \--destructive | \--destructive-foreground | Delete, danger, errors       |

Each surface ships with the foreground meant to sit on it. You write `bg-primary text-primary-foreground` and the contrast is a decision the theme already made, not one you re-litigate at every call site. (The `-foreground` name is the convention for text-and-icon color; the surface token drops the suffix, so it is `bg-card`, not `bg-card-background`. See the [official theming reference](https://ui.shadcn.com/docs/theming).)

The rule that follows is short and strict: **never reach for a raw palette color inside a component.** A component that says `bg-neutral-900` looks fine until it lands in a light theme, a paper theme, or someone's brand fork, where it is now a black rectangle nobody asked for. The same component written `bg-card text-card-foreground` is correct in every theme that will ever exist, because it asked for a role and let the theme answer.

Here is a real registry block rendered live. Every color in it, background, text, borders, the accent, is a semantic token. That is the entire reason it can drop into your app and look like it belongs.

Service Grid With Icons

[View block](/block/feature3)

Loading preview…

Not one hardcoded color. Every surface reads a role token, so this section inherits whatever theme wraps it.

#### Adding your own token, end to end

Say your product needs a "success" color the base theme does not ship. The token pattern makes this a three-step move, and the third step is the one people forget.

cssCopy

```
/* 1. Raw token, both modes */
:root {
  --success: oklch(0.59 0.13 163);
  --success-foreground: oklch(0.985 0 0);
}
.dark {
  --success: oklch(0.69 0.15 163);
  --success-foreground: oklch(0.205 0 0);
}

/* 2. Expose it to Tailwind so utilities get generated */
@theme inline {
  --color-success: var(--success);
  --color-success-foreground: var(--success-foreground);
}
```

tsxCopy

```
// 3. Use the role, never the raw value
<span className="rounded-md bg-success px-2 py-0.5 text-success-foreground">
  Deployed
</span>
```

Skip step two and `bg-success` silently does nothing, because no utility was ever minted. That single omission is the most common "why isn't my custom color working" bug in Tailwind v4 themes, and now you know exactly where it lives.

### One variable, the whole radius scale

Color is the visible part of a theme, but the same token discipline runs through the geometry. There is a single `--radius: 0.625rem` at the root, and the entire rounded scale is derived from it with `calc()`:

cssCopy

```
@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --radius-2xl: calc(var(--radius) + 8px);
  --radius-3xl: calc(var(--radius) + 12px);
  --radius-4xl: calc(var(--radius) + 16px);
}
```

`rounded-sm`, `rounded-lg`, and `rounded-2xl` are not independent values you keep in sync by hand. They are offsets from one anchor. Set `--radius: 0` and the whole product turns sharp and architectural; set it to `1rem` and everything softens together, in proportion, in one edit. You watched this happen on the radius slider above: a single number rewriting every corner in the preview at once.

Fonts and shadows ride the same rails. The `@theme inline` block maps `--font-sans` to whatever font variable your app loaded, and the shadow scale to a set of `--shadow-*` tokens, so typography and elevation are theme-swappable by the same mechanism, not special cases.

### Dark mode without the flash

The token architecture makes dark mode almost trivial to _define_, as we saw: it is one extra block of values. The hard part is the other half, applying the right theme before the first paint so users never see a flash of the wrong one.

That flash, the [FOUC](/blog/use-client-boundary), happens because the server cannot know a returning user's chosen theme. It renders the default (usually light), ships the HTML, and only after JavaScript hydrates does the app discover the real preference and switch. For a beat, the page is the wrong color.

The fix is a tiny script that runs _before_ the browser paints, reads the saved preference, and sets the class on `<html>` synchronously. You do not have to write it. [next-themes](https://github.com/pacocoursey/next-themes) injects exactly that script, and [shadcn's Next.js dark mode guide](https://ui.shadcn.com/docs/dark-mode/next) wires it up in a few lines:

tsxCopy

```
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
```

Every prop earns its place:

* `attribute="class"` tells next-themes to toggle the `class` on `<html>`, which is precisely what our `@custom-variant dark (&:is(.dark *))` is listening for.
* `suppressHydrationWarning` is required, and only on `<html>`. The pre-paint script mutates that element before React hydrates, so the server and client markup deliberately disagree for one instant. This prop tells React not to warn about that single, intentional mismatch.
* `enableSystem` and `defaultTheme="system"` respect the OS preference until the user overrides it.
* `disableTransitionOnChange` kills CSS transitions during the switch, so toggling does not produce a half-second smear of every color animating at once.

The elegant part is what you do _not_ have to do. Because the theme is just CSS variables under a class, the toggle is a class flip on one element. Server Components never re-render. There is no theme value threaded through React context into every colored element, no `useTheme()` in your buttons. A [Server Component](/blog/use-client-boundary) written with `bg-card text-card-foreground` re-themes for free, on the server's initial HTML, with zero client JavaScript of its own. The only component that needs `"use client"` is the toggle itself.

The one class you must not hardcode

The whole system rests on components asking for roles. The instant a component hardcodes `text-white` or `bg-slate-950` "because it is a dark section anyway," it stops obeying the theme and will break in the next one. If a surface is genuinely always dark regardless of mode, that is a real case, but make it a deliberate token (a fixed panel color), not an accident. A theme is only as reliable as its least disciplined component.

### More than two modes

Nothing about this caps you at light and dark. This site ships a third theme, a warm "paper" mode, using the identical pattern: a class, a block of token overrides, and one custom variant.

cssCopy

```
@custom-variant paper (&:is(.paper *));

.paper {
  --background: oklch(0.97 0.01 85);
  --foreground: oklch(0.25 0.02 50);
  --card: oklch(0.95 0.015 80);
  --primary: oklch(0.45 0.08 45);
  --border: oklch(0.85 0.03 70);
  /* ...the rest of the same token names... */
}
```

Look at those values against the neutral theme's. The paper mode is not grayscale, its neutrals carry a little chroma (`0.01` to `0.03`) at a warm hue near `50` to `85`. That is the OKLCH tinted-neutral trick from earlier, applied to build an entire cream-and-sepia surface set without a single component change. To expose it, you list your themes on the provider and let users pick:

tsxCopy

```
<ThemeProvider attribute="class" themes={["light", "dark", "paper"]}>
```

Multi-brand products live here too. A white-label app can carry one token block per client, keyed off a `data-theme` attribute instead of a class, and swap an entire visual identity by changing one attribute on `<html>`. Same machine, more presets.

### Shipping the whole thing in one install

Here is where owning your tokens pays its largest dividend. A theme defined this cleanly, pure CSS variables in named layers, is a portable artifact. shadcn's registry can distribute it, and as of the 2026 CLI it can distribute the _entire_ design system, not just components.

A theme is a [registry item](https://ui.shadcn.com/docs/registry/registry-item-json) with `type: "registry:theme"`. Its `cssVars` object mirrors the exact structure you now understand, a shared `theme` bucket plus `light` and `dark` overrides:

jsonCopy

```
{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "my-brand",
  "type": "registry:theme",
  "cssVars": {
    "theme": {
      "font-sans": "Inter, sans-serif",
      "radius": "0.5rem"
    },
    "light": {
      "primary": "oklch(0.55 0.20 255)",
      "primary-foreground": "oklch(0.985 0 0)"
    },
    "dark": {
      "primary": "oklch(0.62 0.19 255)",
      "primary-foreground": "oklch(0.205 0 0)"
    }
  }
}
```

The registry can also inject raw CSS through a `css` property (custom `@layer` rules, `@utility` definitions), so animations and one-off utilities travel with the theme instead of being copy-paste instructions in a README.

Step up one more level and you reach `type: "registry:base"`, meant for an entire design system in a single payload: components, their dependencies, the CSS variables, the fonts, and the config, all installed at once. The consumer runs one command against your URL:

bashCopy

```
npx shadcn@latest add https://your-site.com/r/my-brand.json
```

and their `globals.css` gains your token blocks, their config gains your fonts, and their project speaks your design language from the next render. No package to version, no upgrade treadmill, no lock-in. Just source, in their repo, theirs to edit, which is [the entire point of this model](/blog/copy-paste-vs-library).

If you would rather design the token values in a UI than hand-tune OKLCH channels, tools like the excellent [tweakcn](https://tweakcn.com) give you a visual editor that emits exactly this `:root` / `.dark` / `@theme inline` structure, ready to paste or publish as a registry item.

### The one-sentence version

A shadcn theme is not a color scheme, it is a small graph of role-named tokens: raw OKLCH values in `:root` and `.dark`, promoted to utilities through `@theme inline`, consumed by components that only ever ask for roles, and portable enough to install with a single command. Master the graph and the colors become the easy part, one hue slider, one radius, one class on `<html>`, and a whole product moves in step.

Every block in [our catalog](/blocks) is built on exactly these tokens, which is why any of them drops into your theme, light, dark, paper, or your own, and looks like you made it.

## Build it with real blocks

Every section in this post is a component you can copy into your own codebase and own outright.

[Browse blocksBrowse blocks](/blocks)[Read more postsRead more posts](/blog)

## Ship the interface, keep the code.

New blocks, pieces and components every week. Install one with a command and it is yours to edit. No runtime dependency, no upgrade path to fight.

[Browse the libraryBrowse the library](/blocks)[See pricingSee pricing](/pricing)

### Library

* [Blocks](/blocks)
* [Pages](/pages)
* [Pieces](/pieces)
* [Components](/components)
* [Search](/search)

### Learn

* [Docs](/docs)
* [AI & MCP](/docs/mcp)
* [Blog](/blog)
* [Free tools](/tools)
* [What's new?](/changelog)
* [Website Builder](https://beste.co)

### More

* [Pricing](/pricing)
* [Referrals](/referrals)
* [License](/license)
* [GitHub](https://github.com/beste-co/beste-ui)

### Shadcn Blocks

* [Shadcn Hero Blocks](/blocks/hero)
* [Shadcn Feature Blocks](/blocks/feature)
* [Shadcn Pricing Blocks](/blocks/pricing)
* [Shadcn CTA Blocks](/blocks/cta)
* [Shadcn FAQ Blocks](/blocks/faq)
* [Shadcn About Blocks](/blocks/about)
* [Shadcn Stats Blocks](/blocks/stats)
* [Shadcn Footer Blocks](/blocks/footer)
* [Shadcn Navigation Blocks](/blocks/navigation)
* [Shadcn Auth Blocks](/blocks/auth)
* [Shadcn Ecommerce Blocks](/blocks/ecommerce)
* [Shadcn Portfolio Blocks](/blocks/portfolio)
* [Shadcn Showcase Blocks](/blocks/showcase)
* [Shadcn Careers Blocks](/blocks/careers)
* [Shadcn Onboarding Blocks](/blocks/onboarding)
* [Shadcn Coming Soon Blocks](/blocks/coming-soon)
* [Shadcn Post Blocks](/blocks/post)
* [Shadcn Legal Blocks](/blocks/legal)
* [Shadcn Workflow Blocks](/blocks/workflow)
* [Shadcn News Blocks](/blocks/news)

© 2026, [Beste](https://beste.co). All rights reserved.