Home / Snippets / Color & Theming /

OKLCH border

Six hues at the same lightness and chroma — oklch() keeps borders perceptually even as hue rotates.

Purple
H 265
Blue
H 230
Cyan
H 200
Green
H 145
Amber
H 75
Red
H 20
Widely Supported
colorno-js

Quick implementation

/* OKLCH border color system */
:root {
  --border-l: 0.72;
  --border-c: 0.19;
}

.border-purple { border: 2px solid oklch(var(--border-l) var(--border-c) 265); }
.border-blue   { border: 2px solid oklch(var(--border-l) var(--border-c) 230); }
.border-cyan   { border: 2px solid oklch(var(--border-l) var(--border-c) 200); }
.border-green  { border: 2px solid oklch(var(--border-l) var(--border-c) 145); }
.border-amber  { border: 2px solid oklch(var(--border-l) var(--border-c)  75); }
.border-red    { border: 2px solid oklch(var(--border-l) var(--border-c)  20); }

/* Data-attribute variant for arbitrary hues */
[data-border-hue] {
  --h: attr(data-border-hue number, 265);
  border: 2px solid oklch(var(--border-l) var(--border-c) var(--h));
}

Prompt this to your LLM

Includes role, constraints, two framework variants, and edge cases to handle.

You are a senior frontend engineer building a perceptually consistent
border color system for a dark-mode UI component library.

Goal: A reusable CSS border system using oklch() that rotates hue while
keeping lightness and chroma constant — so all border colors read as
equally vivid and equally bright regardless of hue.

Technical constraints:
- Define --border-l (lightness) and --border-c (chroma) as custom
  properties on :root so the entire palette scales from two values.
- Use oklch(var(--border-l) var(--border-c) <hue>) for every border
  color — no hex, no hsl, no rgba.
- Apply borders as: border: 2px solid oklch(...); — not outline, not
  box-shadow, unless noted otherwise.
- Provide utility classes for at least 6 hues covering the full wheel
  (purple ~265, blue ~230, cyan ~200, green ~145, amber ~75, red ~20).
- Card backgrounds should use var(--card) so borders stand out against
  the surface.

Framework variant (pick one):
A) Vanilla CSS utility classes (.border-purple, .border-blue, etc.)
   with --border-l and --border-c overridable per-component.
B) CSS Modules for a React card component — accept a `hue` prop (number)
   and apply border: 2px solid oklch(0.72 0.19 hue) via inline styles.

Edge cases to handle:
- In wide-gamut displays (P3, Rec2020) oklch() saturations above 0.25
  may exceed sRGB — clamp chroma to 0.22 or use @media (color-gamut: p3)
  to offer a richer variant without breaking sRGB fallback.
- When borders are used for focus rings, pair with outline-offset: 2px
  and ensure 3:1 contrast ratio against the background at all hues.
- Avoid setting border-color alone without border-width and border-style
  — shorthand border: 2px solid oklch(...) is safer for initial values.

Return CSS only (or a React component if variant B is chosen).

Why oklch makes border colors perceptually consistent

The traditional approach to multi-hue border palettes in hsl() looks uneven: a green border at hsl(145 70% 55%) appears visually brighter than a blue border at the same lightness and saturation values. That mismatch happens because hsl uses a cylindrical model derived from the RGB cube — its "lightness" axis is a geometric average, not a perceptual one. Yellow and cyan appear much brighter than blue and purple at identical hsl lightness values.

oklch() uses the OKLCH color space, which is built on the perceptually uniform OKLAB model. Its lightness axis (L) is calibrated to human vision: when you hold L and chroma (C) constant and rotate only the hue (H), the resulting colors appear equally bright and equally saturated to the eye. The six border cards in the demo above — purple at H 265, blue at H 230, cyan at H 200, green at H 145, amber at H 75, and red at H 20 — are all defined with oklch(0.72 0.19 <hue>). The visual evenness is not a coincidence; it is the direct result of working in a perceptually uniform space.

This matters most when borders carry semantic meaning. In a data dashboard with color-coded status cards (info, success, warning, error), you need each border to read with equal visual weight. With hsl, you'd spend time manually tuning each lightness value. With oklch, one pair of values — --border-l and --border-c — does the work for every hue on the wheel.

Rotating hue at constant lightness and chroma

The system is built around two custom properties on :root: --border-l for lightness and --border-c for chroma. Every border color in the palette shares these values and only differs in its hue angle. This makes global tweaks trivial — increase --border-c from 0.19 to 0.22 and every border in the system becomes more saturated at once, without touching individual color declarations.

Hue angles in OKLCH map roughly (but not exactly) to the familiar color wheel: 0–30 is red-orange, 60–90 is yellow-amber, 130–160 is green, 190–210 is cyan, 220–250 is blue, and 255–280 is purple-violet. Spacing hues evenly around the wheel (every 35–45°) produces a palette where adjacent colors are clearly distinguishable without any two looking too similar.

For wide-gamut displays, chroma values above roughly 0.22 can exceed the sRGB gamut for some hues. Browsers will clamp out-of-gamut values to the nearest sRGB color automatically, but if you want to explicitly serve richer borders to P3-capable screens you can add a @media (color-gamut: p3) block that increases --border-c to 0.26 or higher.

Border vs outline — when to use which

border and outline are both capable of drawing a colored ring around an element, but they behave differently in layout. border participates in the box model — it occupies space, and changing its width shifts surrounding elements. outline is drawn outside the border box and does not affect layout; it also supports outline-offset to push the ring away from the element's edge.

For decorative colored borders on cards and containers, border is the right choice. The 2px width is part of the card's designed size, and the color communicates category or status. Because the card background is a solid var(--card) color and the border sits flush against it, the full 2px ring reads clearly.

For focus indicators, prefer outline. Browser default focus rings use outline for good reason: it stacks on top of borders without affecting layout, and outline-offset: 2px gives the ring breathing room so it doesn't merge visually with the border. Using oklch() for focus outlines is equally valid — a hue-rotated focus ring can match a component's semantic color while still meeting the 3:1 contrast ratio required by WCAG 2.1 SC 1.4.11.

One edge case: border-radius applies to both border and outline in modern browsers, so rounded cards will have rounded focus rings without any extra CSS.