# Theming

The standard theme variables, written in oklch for light and dark, and exposed as Tailwind classes such as bg-background and text-foreground.

Source: https://ui.suiss.app/docs/theming

## How the theme works

Your stylesheet holds the whole theme. `init` from the suiss/ui CLI writes it to `app/globals.css` (Next.js) or `src/index.css` (Vite), and projects that use the registry CLI already have one. It has four parts:

- Color values under `:root` (light) and `.dark` (dark).
- The base `--radius`.
- The font, set as `--font-sans`.
- An `@theme inline` block that maps each value to a Tailwind name, such as `--color-background: var(--background);`.

It imports `tw-animate-css` for the enter and exit animations, and `shadcn/tailwind.css` for the shared variants (`data-open`, `data-checked` and the like), the accordion keyframes and the `no-scrollbar`, `scroll-fade-*` and `shimmer` utilities. Text sizes, shadows and spacing are the Tailwind defaults.

The variable names are the common theme variables used across Tailwind component libraries, so a theme written for one of them works here, and ours works there. The values are our own: a white and true-black canvas, gray text in the system style and a blue primary color.

```css title="app/globals.css"
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";

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

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.2316 0.0038 286.09);
  --primary: oklch(0.5629 0.1933 256.16);
  --primary-foreground: oklch(1 0 0);
}

.dark {
  --background: oklch(0 0 0);
  --foreground: oklch(0.9707 0.0026 286.29);
  --primary: oklch(0.669 0.1808 251.84);
  --primary-foreground: oklch(1 0 0);
}

@theme inline {
  --font-sans: -apple-system, BlinkMacSystemFont, "Inter Variable", sans-serif;
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
}
```

## The theme item

Adding components with the registry CLI keeps the colors your project already has. To use the suiss/ui values instead, add the theme item:

```bash title="Terminal"
npx shadcn@latest add @esuiss/theme-suiss
```

The theme item is the same for every style and primitive base. It writes every light and dark value under `:root` and `.dark`, including `--radius`, the chart colors and the sidebar colors, and adds the radius scale and the font stack to `@theme inline`. It also installs `@fontsource-variable/inter` and imports it from your stylesheet. Projects set up with `npx @esuiss/ui init` already have all of it.

## Using variables

Every color becomes a Tailwind color. Use it with any color utility.

```tsx
<div className="rounded-lg border border-border bg-muted p-4">
  <p className="text-foreground">Title</p>
  <p className="text-muted-foreground">Supporting text</p>
</div>
```

Colors come in pairs: a background and the text that sits on it. For example, `bg-primary` takes `text-primary-foreground`, and `bg-card` takes `text-card-foreground`. A variable without the `-foreground` suffix is the background; the one with it is the text.

## Interaction states

There is no separate variable per state. States use the same variables with an opacity, and disabled elements are dimmed:

| State | Pattern |
| --- | --- |
| Hover on a filled element | `hover:bg-primary/80`, `hover:bg-secondary/80` |
| Hover on a transparent element | `hover:bg-accent hover:text-accent-foreground` |
| Highlighted menu item | `focus:bg-accent focus:text-accent-foreground` |
| Tinted background | `bg-primary/10`, `bg-destructive/10` |
| Keyboard focus | `focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50` |
| Invalid | `aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20` |
| Disabled | `disabled:opacity-50` or `data-disabled:opacity-50` |

The `button` component uses them this way:

```tsx
"bg-primary text-primary-foreground hover:bg-primary/80 disabled:opacity-50"
```

## Variables

Values are from the default stylesheet.

### Base

| Variable | Used for | Light | Dark |
| --- | --- | --- | --- |
| `background` | The page | `oklch(1 0 0)` | `oklch(0 0 0)` |
| `foreground` | Text on the page | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |
| `card` | Cards and raised panels | `oklch(1 0 0)` | `oklch(0.2316 0.0038 286.09)` |
| `card-foreground` | Text on cards | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |
| `popover` | Menus, popovers, dialogs, sheets and toasts | `oklch(1 0 0)` | `oklch(0.2316 0.0038 286.09)` |
| `popover-foreground` | Text on popovers | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |

### Primary and secondary

| Variable | Used for | Light | Dark |
| --- | --- | --- | --- |
| `primary` | The brand color: default buttons, checked controls, links | `oklch(0.5629 0.1933 256.16)` | `oklch(0.669 0.1808 251.84)` |
| `primary-foreground` | Text on `primary` | `oklch(1 0 0)` | `oklch(1 0 0)` |
| `secondary` | Fills for secondary buttons, fields and tracks | `oklch(0 0 0 / 4%)` | `oklch(1 0 0 / 10%)` |
| `secondary-foreground` | Text on `secondary` | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |

### Muted and accent

| Variable | Used for | Light | Dark |
| --- | --- | --- | --- |
| `muted` | Quiet backgrounds: filled cards, skeletons, grouped lists | `oklch(0.9707 0.0026 286.29)` | `oklch(0.2006 0.002 286.21)` |
| `muted-foreground` | Secondary text, descriptions and placeholders | `oklch(0.5399 0.0077 286.13)` | `oklch(0.7105 0.0072 286.2)` |
| `accent` | Hovered, highlighted and selected items | `oklch(0 0 0 / 6%)` | `oklch(1 0 0 / 10%)` |
| `accent-foreground` | Text on `accent` | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |

### Destructive, borders and focus

| Variable | Used for | Light | Dark |
| --- | --- | --- | --- |
| `destructive` | Destructive actions and errors | `oklch(0.5329 0.2255 27.27)` | `oklch(0.7033 0.2236 28.29)` |
| `border` | Lines, dividers and borders | `oklch(0.8652 0.0068 286.24)` | `oklch(0.3802 0.005 286.15)` |
| `input` | Stronger borders and the unchecked switch track | `oklch(0.6217 0.0074 286.17)` | `oklch(0.5399 0.0077 286.13)` |
| `ring` | The keyboard focus outline | `oklch(0.5629 0.1933 256.16)` | `oklch(0.5629 0.1933 256.16)` |

### Charts

Series colors. Charts reference them as `var(--chart-1)` to `var(--chart-5)` in their config.

| Variable | Light | Dark |
| --- | --- | --- |
| `chart-1` | `oklch(0.6028 0.2177 257.42)` | `oklch(0.6243 0.2056 255.49)` |
| `chart-2` | `oklch(0.6998 0.1109 212.72)` | `oklch(0.7706 0.1181 211.98)` |
| `chart-3` | `oklch(0.5295 0.191 278.34)` | `oklch(0.5564 0.2032 278.15)` |
| `chart-4` | `oklch(0.7652 0.1752 62.57)` | `oklch(0.7824 0.1711 67.22)` |
| `chart-5` | `oklch(0.6483 0.0073 286.18)` | `oklch(0.6483 0.0073 286.18)` |

### Sidebar

The `sidebar` component has its own set, so it can differ from the page.

| Variable | Light | Dark |
| --- | --- | --- |
| `sidebar` | `oklch(0.9707 0.0026 286.29)` | `oklch(0.2006 0.002 286.21)` |
| `sidebar-foreground` | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |
| `sidebar-primary` | `oklch(0.5629 0.1933 256.16)` | `oklch(0.669 0.1808 251.84)` |
| `sidebar-primary-foreground` | `oklch(1 0 0)` | `oklch(1 0 0)` |
| `sidebar-accent` | `oklch(0 0 0 / 6%)` | `oklch(1 0 0 / 10%)` |
| `sidebar-accent-foreground` | `oklch(0.2316 0.0038 286.09)` | `oklch(0.9707 0.0026 286.29)` |
| `sidebar-border` | `oklch(0.8652 0.0068 286.24)` | `oklch(0.3802 0.005 286.15)` |
| `sidebar-ring` | `oklch(0.5629 0.1933 256.16)` | `oklch(0.5629 0.1933 256.16)` |

### Status and other colors

Success, warning and decorative colors use the Tailwind palette directly, with a lighter shade in dark mode for text, for example `text-green-600 dark:text-green-400` and `bg-green-500/10`. The dimmed layer behind dialogs, sheets and drawers is `bg-black/50`.

## Mobile

React Native, SwiftUI and Compose use the same variables with the same light and dark values, generated from one token file.

| Platform | Theme color | Palette color |
| --- | --- | --- |
| Uniwind and NativeWind | `bg-muted`, `text-muted-foreground` | `text-green-600 dark:text-green-400` |
| StyleSheet | `colors.muted`, `colors.mutedForeground` from `useTheme()` | `palette.green600` |
| SwiftUI | `EsuissColor.muted`, `EsuissColor.mutedForeground` | `Palette.green600` |
| Compose | `EsuissTheme.colors.muted`, `EsuissTheme.colors.mutedForeground` | `Palette.green600` |

Pressed elements fade to 80% opacity and disabled elements to 50%, on every platform.

Text sizes and shadows are the Tailwind defaults on every platform: `xs` is 12 points on a 16-point line, up to `3xl` at 30 on 36, and `shadow-md` and `shadow-lg` keep both of their layers. SwiftUI and Compose name `2xl` and `3xl` as `xl2` and `xl3`.

| Platform | Text size | Shadow |
| --- | --- | --- |
| Uniwind and NativeWind | `text-sm` | `shadow-md` |
| StyleSheet | `fontSize.sm` | `shadow.md` |
| SwiftUI | `.textStyle(.sm)` | `.shadow(.md)` |
| Compose | `EsuissTheme.typography.sm` | `Modifier.shadow(EsuissTheme.shadows.md, shape)` |

Focused fields in Uniwind and NativeWind draw the same `border-ring ring-3 ring-ring/50` ring as the web. SwiftUI and Compose keep the system focus.

## Radius

`--radius` is `0.625rem` by default. Every rounded component scales from it.

| Class | Value |
| --- | --- |
| `rounded-xs` | `calc(var(--radius) * 0.3)` |
| `rounded-sm` | `calc(var(--radius) * 0.5)` |
| `rounded-md` | `calc(var(--radius) * 0.8)` |
| `rounded-lg` | `var(--radius)` |
| `rounded-xl` | `calc(var(--radius) * 1.2)` |
| `rounded-2xl` | `calc(var(--radius) * 1.8)` |
| `rounded-3xl` | `calc(var(--radius) * 2.8)` |

## Customize

- **Change a color.** Edit its value under `:root` (light) and `.dark` (dark).
- **Use another theme.** Themes written with the same variable names drop in: replace the `:root` and `.dark` blocks with theirs and keep the `@theme inline` block.
- **Add a color.** Define it under `:root` and `.dark`, then map it in `@theme inline`, for example `--color-brand: var(--brand);`. Classes such as `bg-brand` and `text-brand` become available.
- **Change the font.** Set `--font-sans` in `@theme inline`.
- **Change the corner radius.** Set `--radius` under `:root`.

## Dark mode

Dark mode is enabled by the `dark` class on `<html>`. Every token switches with it.

For a theme switch, add the `theme` component. It is not added by `add --all`, so projects that stay in one appearance never get it.

```bash title="Terminal"
npx shadcn@latest add @esuiss/theme
```

Or with the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui add theme
```

It gives you `ThemeProvider`, `useTheme`, `ThemeToggle` and `ThemeScript`. Adding it also appends the keyframes for the animated theme switch to your stylesheet. The provider remembers the choice (`light`, `dark` or `system`) and follows the system setting when it is `system`. `ThemeScript` sets the class before the page paints, so there is no flash of the wrong theme.

```tsx title="app/layout.tsx"
import { ThemeProvider, ThemeScript } from "@/components/ui/theme"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <ThemeScript />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}
```

With Vite, wrap your app in `ThemeProvider` and put the same script in `index.html`, before your app script:

```html title="index.html"
<script>
  (()=>{try{var t=localStorage.getItem("theme")||"system";var d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme: dark)").matches);var r=document.documentElement;r.classList.toggle("dark",d);r.style.colorScheme=d?"dark":"light"}catch(e){}})()
</script>
```

`ThemeToggle` switches with an animated reveal built on the View Transitions API. Choose the `effect` (`circle`, `rectangle`, `polygon` or `circle-blur`), where it starts with `origin`, and add `blur` for a softer edge.

```tsx
<ThemeToggle effect="circle" origin="top-right" />
<ThemeToggle effect="rectangle" origin="bottom-up" blur />
```

**Example**

```tsx
import type * as React from "react"
import { ThemeProvider, ThemeToggle } from "@/components/ui/theme"

const STORAGE_KEY = "esuiss-theme-example"

function clearStoredTheme() {
  try {
    localStorage.removeItem(STORAGE_KEY)
    return true
  } catch {
    return false
  }
}

function ExampleTheme({ children }: { children?: React.ReactNode }) {
  clearStoredTheme()

  return (
    <ThemeProvider defaultTheme="light" storageKey={STORAGE_KEY}>
      {children}
    </ThemeProvider>
  )
}

export default function ThemeCircle() {
  return (
    <ExampleTheme>
      <div className="flex min-h-svh flex-col items-center justify-center gap-8 bg-background px-6 py-16 text-center text-foreground">
        <div className="flex max-w-md flex-col gap-3">
          <h1 className="text-4xl font-semibold tracking-tight">
            Light and dark.
          </h1>
          <p className="text-lg text-muted-foreground">
            Every surface, label and accent follows the theme tokens, so the
            whole page changes at once.
          </p>
        </div>
        <ThemeToggle effect="circle" origin="center" />
      </div>
    </ExampleTheme>
  )
}
```

With `blur`, the revealing edge is soft:

**Example**

```tsx
import type * as React from "react"
import { ThemeProvider, ThemeToggle } from "@/components/ui/theme"

const STORAGE_KEY = "esuiss-theme-example"

function clearStoredTheme() {
  try {
    localStorage.removeItem(STORAGE_KEY)
    return true
  } catch {
    return false
  }
}

function ExampleTheme({ children }: { children?: React.ReactNode }) {
  clearStoredTheme()

  return (
    <ThemeProvider defaultTheme="light" storageKey={STORAGE_KEY}>
      {children}
    </ThemeProvider>
  )
}

export default function ThemeBlurred() {
  return (
    <ExampleTheme>
      <div className="flex min-h-svh flex-col items-center justify-center gap-8 bg-background px-6 py-16 text-center text-foreground">
        <div className="flex max-w-md flex-col gap-3">
          <h1 className="text-4xl font-semibold tracking-tight">
            Light and dark.
          </h1>
          <p className="text-lg text-muted-foreground">
            Every surface, label and accent follows the theme tokens, so the
            whole page changes at once.
          </p>
        </div>
        <ThemeToggle effect="circle" origin="center" blur />
      </div>
    </ExampleTheme>
  )
}
```

`ThemeToggle` is an `icon-lg` icon button by default; pass any icon button `size` to change it:

**Example**

```tsx
import type * as React from "react"
import { ThemeProvider, ThemeToggle } from "@/components/ui/theme"

const STORAGE_KEY = "esuiss-theme-example"

function clearStoredTheme() {
  try {
    localStorage.removeItem(STORAGE_KEY)
    return true
  } catch {
    return false
  }
}

function ExampleTheme({ children }: { children?: React.ReactNode }) {
  clearStoredTheme()

  return (
    <ThemeProvider defaultTheme="light" storageKey={STORAGE_KEY}>
      {children}
    </ThemeProvider>
  )
}

export default function ThemeSizes() {
  return (
    <ExampleTheme>
      <div className="flex min-h-svh flex-col items-center justify-center gap-8 bg-background px-6 py-16 text-center text-foreground">
        <div className="flex max-w-md flex-col gap-3">
          <h1 className="text-4xl font-semibold tracking-tight">
            Light and dark.
          </h1>
          <p className="text-lg text-muted-foreground">
            Every surface, label and accent follows the theme tokens, so the
            whole page changes at once.
          </p>
        </div>
        <div className="flex items-center gap-4">
          <ThemeToggle size="icon" />
          <ThemeToggle />
          <ThemeToggle size="icon-xl" />
        </div>
      </div>
    </ExampleTheme>
  )
}
```

To build your own control, use `setTheme` from `useTheme`. Pass a transition as the second argument to animate it, or leave it out to switch instantly. Browsers without View Transitions, and visitors who prefer reduced motion, switch instantly either way.

```tsx
const { theme, setTheme } = useTheme()
setTheme("system", { effect: "circle", origin: "top-center" })
```

A custom button that calls `setTheme` without a transition switches instantly:

**Example**

```tsx
import type * as React from "react"
import { Button } from "@/components/ui/button"
import { ThemeProvider, useTheme } from "@/components/ui/theme"

const STORAGE_KEY = "esuiss-theme-example"

function clearStoredTheme() {
  try {
    localStorage.removeItem(STORAGE_KEY)
    return true
  } catch {
    return false
  }
}

function ExampleTheme({ children }: { children?: React.ReactNode }) {
  clearStoredTheme()

  return (
    <ThemeProvider defaultTheme="light" storageKey={STORAGE_KEY}>
      {children}
    </ThemeProvider>
  )
}

function InstantSwitch() {
  const { resolvedTheme, setTheme } = useTheme()

  return (
    <Button
      variant="secondary"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      Switch theme instantly
    </Button>
  )
}

export default function ThemeWithoutTransition() {
  return (
    <ExampleTheme>
      <div className="flex min-h-svh flex-col items-center justify-center gap-8 bg-background px-6 py-16 text-center text-foreground">
        <div className="flex max-w-md flex-col gap-3">
          <h1 className="text-4xl font-semibold tracking-tight">
            Light and dark.
          </h1>
          <p className="text-lg text-muted-foreground">
            Every surface, label and accent follows the theme tokens, so the
            whole page changes at once.
          </p>
        </div>
        <InstantSwitch />
      </div>
    </ExampleTheme>
  )
}
```

`useTheme` also returns `resolvedTheme`, the appearance actually shown when the choice is `system`:

**Example**

```tsx
import type * as React from "react"
import { ThemeProvider, ThemeToggle, useTheme } from "@/components/ui/theme"

const STORAGE_KEY = "esuiss-theme-example"

function clearStoredTheme() {
  try {
    localStorage.removeItem(STORAGE_KEY)
    return true
  } catch {
    return false
  }
}

function ExampleTheme({ children }: { children?: React.ReactNode }) {
  clearStoredTheme()

  return (
    <ThemeProvider defaultTheme="light" storageKey={STORAGE_KEY}>
      {children}
    </ThemeProvider>
  )
}

function ThemeStatus() {
  const { theme, resolvedTheme } = useTheme()

  return (
    <div className="flex flex-col items-center gap-3">
      <ThemeToggle />
      <p className="text-sm text-muted-foreground" aria-live="polite">
        Theme: {theme} · Showing: {resolvedTheme ?? "…"}
      </p>
    </div>
  )
}

export default function ThemeStatusExample() {
  return (
    <ExampleTheme>
      <div className="flex min-h-svh flex-col items-center justify-center gap-8 bg-background px-6 py-16 text-center text-foreground">
        <div className="flex max-w-md flex-col gap-3">
          <h1 className="text-4xl font-semibold tracking-tight">
            Light and dark.
          </h1>
          <p className="text-lg text-muted-foreground">
            Every surface, label and accent follows the theme tokens, so the
            whole page changes at once.
          </p>
        </div>
        <ThemeStatus />
      </div>
    </ExampleTheme>
  )
}
```
