# Colors (/docs/colors)





Color in 42UI is **orthogonal to variant**. The same eleven palettes flow through every accented component, the same six CSS slots underneath. Pick the *shape* with `variant` and the *palette* with `color` — they compose without knowing about each other.

* **`variant`** — `filled · light · outline · subtle` (owned by `cva`)
* **`color`** — `brand · neutral · red · orange · yellow · green · teal · cyan · blue · purple · pink` (owned by `data-color` on the root)

`data-color` is the entire interop surface. Resolve a palette at the CSS layer once, and every variant in every component picks it up — including dark mode and consumer-defined palettes.

```tsx
import { Button } from '@42-central/ui-react/button';

<Button variant="filled" color="brand">Primary</Button>
<Button variant="light"  color="red">Destructive</Button>
<Button variant="subtle" color="neutral">Quiet</Button>
```

## Palette [#palette]

Eleven colors, a few roles. `brand` is the default accent and `neutral` is the colorless one: a black fill with white text in light theme, a white fill with navy text in dark, and translucent washes for `light` and hover. It is the kit's own chrome (Button's zero-config default) and the color the Figma kit labels "gray" on Button. It goes through the same `--c-*` slots as every other color. The other nine carry semantic weight by convention (`red` for destructive, `green` for success, `yellow` for warning, `blue` for InfoIcon) but the kit doesn't lock you in — pick whatever reads right for the component.

<div className="not-prose my-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
  {COLORS.map((c) => (
      <div
        key={c}
        data-color={c}
        className="flex items-center gap-3 rounded-lg border border-gray-light-200 p-3 dark:border-gray-dark-700"
      >
        <div className="size-10 shrink-0 rounded-md bg-[var(--c-solid)]" />
        <div className="flex min-w-0 flex-col">
          <span className="font-medium">{c}</span>
          <code className="truncate text-xs text-gray-light-500 dark:text-gray-dark-400">
            data-color="{c}"
          </code>
        </div>
      </div>
    ))}
</div>

### Migrating from `gray` [#migrating-from-gray]

`gray` was removed: it duplicated `neutral`, the color the Figma kit already draws as "gray" on Button. `color="gray"` still renders, as `neutral`, until the next minor. Replace it with `color="neutral"`. Expect `filled` to turn black/white and `light` to get a heavier wash; `subtle`, `outline` and text keep their color.

TypeScript can't flag it, since `Color` accepts any string. The package ships a Biome plugin that warns on `color="gray"` in JSX. Enable it in your `biome.json`:

```json
{
  "plugins": ["./node_modules/@42-central/ui-react/biome/no-gray-color.grit"]
}
```

## Slot variables [#slot-variables]

Every palette resolves to six CSS variables defined in `colors.css`. Components reference *slots*, not steps — `bg-[var(--c-solid)]` does the right thing whether the palette is `brand` or `pink`, in light or dark mode.

<TypeTable
  type="{
  &#x22;--c-solid&#x22;: {
    type: &#x22;token&#x22;,
    description:
      &#x22;Filled background, outline border, tinted focus ring. (500 / 500 dark)&#x22;,
  },
  &#x22;--c-solid-hover&#x22;: {
    type: &#x22;token&#x22;,
    description: &#x22;Filled hover background. (600 / 400 dark)&#x22;,
  },
  &#x22;--c-on-solid&#x22;: {
    type: &#x22;token&#x22;,
    description: &#x22;Foreground sitting on --c-solid. (white / gray-dark-950)&#x22;,
  },
  &#x22;--c-soft&#x22;: {
    type: &#x22;token&#x22;,
    description:
      &#x22;Tinted background for light variants and hover tints. (50 / 950 dark)&#x22;,
  },
  &#x22;--c-soft-hover&#x22;: {
    type: &#x22;token&#x22;,
    description: &#x22;A step darker than --c-soft. (100 / 900 dark)&#x22;,
  },
  &#x22;--c-text&#x22;: {
    type: &#x22;token&#x22;,
    description: &#x22;Text + icon color on non-filled surfaces. (700 / 300 dark)&#x22;,
  },
}"
/>

### Disabled slots [#disabled-slots]

A disabled control should read as inactive *whatever* its palette, so the disabled slots are deliberately **palette-independent** — translucent neutral washes (black in light, white in dark) declared once on `:root` / `[data-theme="dark"]`. Being translucent rather than opaque gray, they take the tint of whatever surface they sit on. Every `data-color` inherits the same muted set, so they don't repeat per palette. Need a palette-specific disabled look? Override `[data-color="x"] { --c-solid-disabled: … }` in your own CSS.

<TypeTable
  type="{
  &#x22;--c-solid-disabled&#x22;: {
    type: &#x22;token&#x22;,
    description:
      &#x22;Muted stand-in for --c-solid on disabled fills, borders, and accents. (black 16% / white 20%)&#x22;,
  },
  &#x22;--c-on-solid-disabled&#x22;: {
    type: &#x22;token&#x22;,
    description:
      &#x22;Foreground on a disabled solid fill. (black 55% / white 50%)&#x22;,
  },
  &#x22;--c-soft-disabled&#x22;: {
    type: &#x22;token&#x22;,
    description: &#x22;Disabled tinted surface. (black 4% / white 8%)&#x22;,
  },
  &#x22;--c-text-disabled&#x22;: {
    type: &#x22;token&#x22;,
    description:
      &#x22;Disabled text + icon on non-filled surfaces. (black 38% / white 35%)&#x22;,
  },
}"
/>

Want to *see* each slot? Here is every palette laid out across all six.

<div className="not-prose my-6 overflow-x-auto">
  <div className="min-w-[44rem] flex-col gap-2">
    <div className="grid grid-cols-[5rem_repeat(6,1fr)] items-center gap-2 pb-1 text-xs text-gray-light-500 dark:text-gray-dark-400">
      <span />

      <span>
        solid
      </span>

      <span>
        solid-hover
      </span>

      <span>
        on-solid
      </span>

      <span>
        soft
      </span>

      <span>
        soft-hover
      </span>

      <span>
        text
      </span>
    </div>

    {COLORS.map((c) => (
          <div
            key={c}
            data-color={c}
            className="mt-2 grid grid-cols-[5rem_repeat(6,1fr)] items-center gap-2"
          >
            <code className="text-xs">{c}</code>
            <div className="h-8 rounded bg-[var(--c-solid)]" />
            <div className="h-8 rounded bg-[var(--c-solid-hover)]" />
            <div className="h-8 rounded bg-[var(--c-on-solid)] ring-1 ring-inset ring-gray-light-200 dark:ring-gray-dark-700" />
            <div className="h-8 rounded bg-[var(--c-soft)]" />
            <div className="h-8 rounded bg-[var(--c-soft-hover)]" />
            <div className="grid h-8 place-items-center rounded text-sm font-semibold text-[var(--c-text)] ring-1 ring-inset ring-gray-light-200 dark:ring-gray-dark-700">
              Aa
            </div>
          </div>
        ))}
  </div>
</div>

Toggle the docs theme to watch every slot rebalance — the dark map keeps fills bright and surfaces deep so contrast stays put.

## Token scales [#token-scales]

Every slot above resolves to a step on a deeper token scale — `--color-<name>-{25..950}`. The kit ships fifteen named scales (plus `white` and `black`): three gray flavors and twelve accents. The eleven palettes listed in [`COLORS`](#api) each have a `data-color` block: ten sit on an accent scale, and `neutral` is composed from `white`, `black` and the gray scales. The rest (`lime`, `rose`, `gray-true`) are wired to Tailwind and ready to be promoted — or used directly via `bg-rose-500`, `text-gray-true-700`, etc.

<div className="not-prose my-6 flex flex-col gap-5">
  <div className="flex flex-col gap-1">
    <code className="text-xs">
      core
    </code>

    <div className="flex gap-2">
      <div className="flex flex-col items-center gap-1">
        <div className="h-10 w-16 rounded ring-1 ring-inset ring-gray-light-200 dark:ring-gray-dark-700" style="{ background: 'var(--color-white)' }" />

        <span className="text-[10px] text-gray-light-500 dark:text-gray-dark-400">
          white
        </span>
      </div>

      <div className="flex flex-col items-center gap-1">
        <div className="h-10 w-16 rounded ring-1 ring-inset ring-gray-light-200 dark:ring-gray-dark-700" style="{ background: 'var(--color-black)' }" />

        <span className="text-[10px] text-gray-light-500 dark:text-gray-dark-400">
          black
        </span>
      </div>
    </div>
  </div>

  {[
    'gray-light',
    'gray-dark',
    'gray-true',
    'brand',
    'red',
    'orange',
    'yellow',
    'lime',
    'green',
    'teal',
    'cyan',
    'blue',
    'purple',
    'pink',
    'rose',
    ].map((scale) => (
    <div key={scale} className="flex flex-col gap-1">
    <code className="text-xs">{scale}</code>
    <div className="grid grid-cols-6 gap-1 sm:grid-cols-13">
    {[25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].map((step) => (
    <div key={step} className={`flex flex-col items-center gap-1`}>
    <div
    className={`w-full rounded ring-1 ring-inset h-10 ${step === 500 ? 'ring-gray-light-950 dark:ring-gray-dark-50' : 'ring-gray-light-200 dark:ring-gray-dark-700'}`}
    style={{ background: `var(--color-${scale}-${step})` }}
    />
    <span className={`text-[10px] ${step === 500 ? 'font-semibold text-fd-foreground' : 'text-gray-light-500 dark:text-gray-dark-400'}`}>
    {step}
    </span>
    </div>
    ))}
    </div>
    </div>
    ))}
</div>

Step `500` is the resting solid for most accents — `400` for `yellow`, where `500` reads too dim on white. Step `700` is the canonical text token on non-filled surfaces in light mode; `300` in dark. The slot map in `colors.css` formalises these picks per palette so components never reach past `--c-*`.

## Variants × palette [#variants--palette]

Each variant binds a small subset of slots. Swap `color`, the rest follows.

<div className="not-prose my-6 flex flex-col gap-2">
  {COLORS.map((c) => (
      <div key={c} className="flex flex-wrap items-center gap-2">
        <code className="w-16 shrink-0 text-xs text-gray-light-500 dark:text-gray-dark-400">
          {c}
        </code>
        <Button variant="filled" color={c} size="sm">
          Filled
        </Button>
        <Button variant="light" color={c} size="sm">
          Light
        </Button>
        <Button variant="outline" color={c} size="sm">
          Outline
        </Button>
        <Button variant="subtle" color={c} size="sm">
          Subtle
        </Button>
      </div>
    ))}
</div>

The same eleven palettes drop straight into [`ActionIcon`](/docs/action-icon) without a second resolver — `data-color` is component-agnostic.

<div className="not-prose my-6 flex flex-col gap-2">
  {COLORS.map((c) => (
      <div key={c} className="flex flex-wrap items-center gap-2">
        <code className="w-16 shrink-0 text-xs text-gray-light-500 dark:text-gray-dark-400">
          {c}
        </code>
        <ActionIcon
          variant="filled"
          color={c}
          size="sm"
          aria-label={`${c} filled`}
        >
          <HeartIcon />
        </ActionIcon>
        <ActionIcon variant="light" color={c} size="sm" aria-label={`${c} light`}>
          <HeartIcon />
        </ActionIcon>
        <ActionIcon
          variant="outline"
          color={c}
          size="sm"
          aria-label={`${c} outline`}
        >
          <HeartIcon />
        </ActionIcon>
        <ActionIcon
          variant="subtle"
          color={c}
          size="sm"
          aria-label={`${c} subtle`}
        >
          <HeartIcon />
        </ActionIcon>
      </div>
    ))}
</div>

## Semantic intent through `color` [#semantic-intent-through-color]

There is no `destructive`, `success`, or `warning` variant. Intent rides on `color` over the right shape — four variants × eleven palettes covers the space without a combinatorial taxonomy.

<div className="not-prose my-6 grid gap-3 sm:grid-cols-2">
  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="filled" color="neutral" startSlot="<CheckIcon />">
      Submit
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Primary | <code>filled · neutral</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="light" color="neutral" startSlot="<XIcon />">
      Cancel
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Secondary | <code>light · neutral</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="filled" color="red" startSlot="<Trash2Icon />">
      Delete
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Destructive | <code>filled · red</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="filled" color="green" startSlot="<CheckIcon />">
      Approve
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Success | <code>filled · green</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="light" color="yellow" startSlot="<TriangleAlertIcon />">
      Review
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Warning | <code>light · yellow</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="light" color="blue" startSlot="<InfoIcon />">
      Learn more
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Info | <code>light · blue</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="subtle" color="neutral" startSlot="<BellIcon />">
      Mute
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      Quiet | <code>subtle · neutral</code>
    </span>
  </div>

  <div className="flex items-center justify-between gap-3 rounded-lg border border-gray-light-200 p-4 dark:border-gray-dark-700">
    <Button variant="filled" color="purple" startSlot="<SparklesIcon />">
      Upgrade
    </Button>

    <span className="text-sm text-gray-light-500 dark:text-gray-dark-400">
      <span className="text-md pr-2">
        Marketing
      </span>

      <code>
        filled · purple
      </code>
    </span>
  </div>
</div>

## Surfaces [#surfaces]

Distinct from the accent system above: [`Card`](/docs/card) (and bordered surfaces generally) aren't meant to *pop* with a color — they're meant to sit quietly on whatever the host app's page actually looks like. Their `default`, `outline`, and `gradient` variants don't read `--c-solid` at all. Instead of a fixed hue, they use a flat, low-opacity white wash — `bg-white/4`, the *same* value in both light and dark theme. A translucent tint always reads as "a little lighter than whatever's behind it"; a hardcoded opaque fill would look right on the kit's own dark-navy chrome but clash on a host app whose page happens to already be blue, or genuinely white, or anything else the kit doesn't control.

Text and border still adapt to the app's light/dark theme — navy text on a navy-tinted border in light theme, white text on a white-tinted border in dark theme. Only the fill itself stays theme-agnostic and background-dependent.

<div className="not-prose my-6 flex flex-col gap-3 sm:flex-row">
  <Card padding="sm" className="flex-1">
    On the docs page itself
  </Card>

  <div className="flex flex-1 items-center rounded-lg bg-brand-600 p-4">
    <Card padding="sm">
      On a brand-blue panel
    </Card>
  </div>
</div>

Same `<Card padding="sm">…</Card>`, two different backgrounds behind it — the card's own fill never changes, only what shows through it does.

`filled` and `light` are the exception: those two variants exist specifically to accept a `color` and *do* read the accent slots (`--c-solid` / `--c-soft`), same as `Button`. Reach for them when a surface needs to carry semantic color; reach for `default` / `outline` / `gradient` for a plain content surface that has to work on a page background the kit doesn't control.

## Consuming the system in a new component [#consuming-the-system-in-a-new-component]

Every colored component follows the same shape. Bind a handful of slots in `cva`, then write `data-color` on the root.

```tsx
import { cva } from "class-variance-authority";
import { ark } from "@ark-ui/react";
import { cn } from "@42-central/ui-react/cn";
import { type Color } from "../../lib/colors";

const variants = cva("...", {
  variants: {
    variant: {
      filled:
        "bg-[var(--c-solid)] text-[var(--c-on-solid)] hover:bg-[var(--c-solid-hover)]",
      light:
        "bg-[var(--c-soft)]  text-[var(--c-text)]    hover:bg-[var(--c-soft-hover)]",
      outline:
        "bg-transparent text-[var(--c-text)] border border-[var(--c-solid)] hover:bg-[var(--c-soft)]",
      subtle: "bg-transparent text-[var(--c-text)] hover:bg-[var(--c-soft)]",
    },
  },
});

export const Foo = ({
  color = "var(--primary-color)",
  variant,
  ...rest
}: Props) => (
  <ark.button
    data-color={color}
    className={cn(variants({ variant }))}
    {...rest}
  />
);
```

One rule to keep the abstraction honest:

* **Never reach past the slots.** Inside a colored variant, `bg-blue-500` is a bug — it breaks dark mode and breaks consumer-defined palettes.

## Adding a new color (3 steps) [#adding-a-new-color-3-steps]

None of these touch a component file.

1. Define `--color-<name>-{25..950}` in `theme.css`.
2. Append the name to `COLORS` in `lib/colors.ts`.
3. Add a `[data-color="<name>"]` block (and a `[data-theme="dark"] [data-color="<name>"]` override) to `colors.css` with the six slots.

```css
/* colors.css */
[data-color="indigo"] {
  --c-solid: var(--color-indigo-500);
  --c-solid-hover: var(--color-indigo-600);
  --c-on-solid: var(--color-white);
  --c-soft: var(--color-indigo-50);
  --c-soft-hover: var(--color-indigo-100);
  --c-text: var(--color-indigo-700);
}
[data-theme="dark"] [data-color="indigo"] {
  --c-solid: var(--color-indigo-500);
  --c-solid-hover: var(--color-indigo-400);
  --c-on-solid: var(--color-gray-dark-950);
  --c-soft: var(--color-indigo-950);
  --c-soft-hover: var(--color-indigo-900);
  --c-text: var(--color-indigo-300);
}
```

## Custom palette without forking [#custom-palette-without-forking]

`Color` is `(typeof COLORS)[number] | (string & {})`, so autocomplete still suggests the eleven built-ins while letting consumers ship their own `data-color` block in app CSS. Same six slots, same component code, no fork of the kit.

```css
/* app's globals.css */
[data-color="lime"] {
  --c-solid: #84cc16;
  --c-solid-hover: #65a30d;
  --c-on-solid: #0a0d12;
  --c-soft: #ecfccb;
  --c-soft-hover: #d9f99d;
  --c-text: #4d7c0f;
}
```

```tsx
<Button color="lime">Custom palette</Button>
```

## API [#api]

```ts
import { COLORS, type Color } from "@42-central/ui-react";

COLORS;
// readonly ['brand', 'neutral', 'red', 'orange', 'yellow',
//           'green', 'teal', 'cyan', 'blue', 'purple', 'pink']

type Color = (typeof COLORS)[number] | (string & {});
```

`COLORS` is exported at runtime for iteration — story controls, this docs page, anywhere you need to enumerate the built-in palettes. `Color` is the type to accept on any colored prop. Both live on the package root (`@42-central/ui-react`); component barrels only re-export their own types.
