CSS Relative Colors: Build UI Palettes Without a Color Picker

- 9 min read

Why relative colors feel like cheating

If you ever built a tiny design system, you already know the loop: pick a brand color, then hand craft a hover state, a border, a shadow, a muted background, and a highlight. Five minutes later you have a handful of unrelated hex values that look correct but do not scale.

The problem gets worse as soon as you add more contexts. Dark mode, marketing pages, seasonal themes, or a new product line all require a new set of tweaks. Manual palettes start to drift, and every refactor becomes a color audit.

Relative colors break that loop. They let you reference a source color and adjust its channels directly in CSS. One base becomes a small palette, and that palette stays consistent as you change the base later.

I like to think of relative colors as relationships, not values. The system says, “hover is 12% darker than the base” instead of “hover is #3d56d6.” That mental shift removes a lot of tiny decisions from day to day UI work.

Quick support note: global support for relative color syntax is (about 89.64%)[https://caniuse.com/css-relative-colors] as of February 2026, so check your audience if you need broader coverage.

The old workflow: manual palette drift

Here is the classic setup. You define a base color, then you create a new value for every variation you need:

:root {
	--brand: #4b6bff;
	--brand-hover: #3d56d6;
	--brand-active: #2f45b2;
	--brand-border: rgba(75, 107, 255, 0.2);
	--brand-soft: #e9edff;
}

.button {
	background-color: var(--brand);
	border-color: var(--brand-border);
}

.button:hover {
	background-color: var(--brand-hover);
}

.button:active {
	background-color: var(--brand-active);
}

This works, but each value is hand picked. If you ever change --brand, you also have to re-pick the hover, active, border, and soft background. That is why palettes drift over time.

The subtle issue is that every token becomes a frozen decision, not a relationship. Once you lose the relationship, you lose the system: changing the base color means re‑discovering the entire palette from scratch, and a new page or theme multiplies that work.

Meet relative colors

Relative colors let you extract channels from an existing color and modify them in place. You can do that in hsl(), rgb(), lch(), or oklch().

Here is the core idea:

.button {
	--brand: #4b6bff;
	--shift: -12;
	--hover: hsl(from var(--brand) h s calc(l + var(--shift)));
	--border: rgb(from var(--brand) r g b / 0.2);

	background-color: var(--brand);
	border-color: var(--border);
}

.button:hover {
	background-color: var(--hover);
}

The hover and border are now derived from the same base value. Update --brand and the rest updates automatically.

The syntax looks strange at first, but it follows a simple pattern:

hsl(from <color> h s l / <alpha>)
  • from <color> is the source. It can be a hex value, a CSS variable, currentColor, or another color function.
  • h, s, and l are placeholders for the original channels.
  • You can replace any channel with a number or a calc() expression.
Hover or focus a piece to see how it maps
( / )
( / )

Examples:

/* Shift hue by 30deg */
--accent: hsl(from var(--brand) calc(h + 30) s l);

/* Desaturate and brighten */
--soft: hsl(from var(--brand) h calc(s - 20) calc(l + 20));

/* Keep RGB but lower alpha */
--shadow: rgb(from var(--brand) r g b / 0.25);

The mental model is simple: pick a source, then nudge the channel you care about.

Choose the right color space

Relative colors work in the space you choose. That choice affects how the results look:

  • RGB is great for alpha tweaks and direct channel edits. It is not perceptual, so changing channels can feel uneven.
  • HSL is quick for hue and saturation shifts. It is intuitive, but the lightness channel does not match human perception.
  • OKLCH is the most consistent for lightness changes. It is a better default when you want palettes that feel balanced across hues.

Relative colors always convert the source into the output color space before doing any math. The result is expressed in that output space, which is why hsl() and oklch() can yield different shifts even with the same numeric offsets.

A good rule of thumb is to use rgb() for transparency and oklch() for lightness. Reach for hsl() when you want fast adjustments or you need wide support.

Build a mini palette from one variable

A practical way to start is to define a handful of tokens. You can keep the palette small and still cover most UI needs.

This recipe builds seven tokens from one base color and keeps everything in sync.

OKLCH uses different numeric ranges than HSL. Lightness resolves to 0–1, chroma resolves to 0–0.4 in relative syntax, and hue resolves to 0–360. That is why these offsets are written as small decimals instead of percentages.

Here is the token recipe that powers the palette:

:root {
	--brand: #4b6bff;
	--strength: 0.12;
	--strong-color: oklch(from var(--brand) calc(l - 0.12) c h);
	--soft-color: oklch(from var(--brand) calc(l + 0.4) c h);
	--accent-color: oklch(from var(--brand) l c calc(h + 20));
	--brand-border: rgb(from var(--brand) r g b / 0.18);
	--brand-shadow: rgb(from var(--brand) r g b / 0.3);
}

This small set is usually enough for a button, a badge, a soft background, and a border. If you need more depth, add one extra step for a darker and lighter variation.

Relative palette builder
  • Base Primary
  • Strong Hover or active
  • Soft Surface
  • Accent Detail
  • Border Low alpha
  • Shadow Soft depth

Relative colors are not supported in this browser yet.

A component recipe you can copy

Relative colors shine when a component needs multiple accents. Here is a compact pattern for a callout card:

Keep derived tokens in one space so the relationships stay coherent. Mixing spaces is valid, but remember the source is converted to the output space for every calculation, which can subtly change the result.

.callout {
	--tone: #4b6bff;
	--tone-strong: hsl(from var(--tone) h s calc(l - 12));
	--tone-soft: hsl(from var(--tone) h calc(s - 20) calc(l + 28));
	--tone-border: rgb(from var(--tone) r g b / 0.2);
	--tone-shadow: rgb(from var(--tone) r g b / 0.25);

	border: 1px solid var(--tone-border);
	background-color: var(--tone-soft);
	box-shadow: 0 12px 24px var(--tone-shadow);
}

.callout strong {
	color: var(--tone-strong);
}

The component only needs one input (--tone). Everything else follows.

Callout card

Design note

Tone-aware callout

This card uses one base color, then derives border, shadow, and emphasis text from it.

Tip: Keep all accents tied to the same hue for a clean system.

You don't need JavaScript ebook cover

I wrote a guide about building modern UI with HTML and CSS first. It covers practical patterns you can use before reaching for JavaScript.

Learn more

Altering the lightness channel

If you want a quick set of tints and shades, you can keep the hue and saturation and only shift the lightness. The slider below changes a single --step value, and every swatch is derived from the same formula:

.swatch {
	--offset: calc(var(--step) * var(--i) / 100); // i is the swatch index
	oklch(from var(--brand) calc(l + var(--offset)) c h);
}

Here --step is a plain number. Because HSL lightness runs from 0 to 100, a step of 5 means “five lightness points.” Values are clamped to the channel range, so going past the ends just pins at 0 or 100.

HSL lightness threshold
Threshold 5
  • l - 10
  • l - 5
  • l
  • l + 5
  • l + 10

HSL vs OKLCH (why the difference matters)

HSL is fast and familiar, but its lightness channel is not perceptual. The same +10% shift can feel dramatic on one hue and barely visible on another.

OKLCH is much more consistent. It keeps lightness changes feeling even across different hues, which is why it is great for UI palettes.

Also note the unit differences: HSL uses a 0–100 scale for lightness and saturation, while OKLCH lightness is 0–1 and chroma values are much smaller. That means a +0.1 OKLCH lightness shift is already significant.

Here is a side by side comparison using the same lightness shift in HSL and OKLCH.

HSL vs OKLCH comparison
90deg

HSL

Base
Shift
hsl(from var(--brand) h s l) hsl(from var(--brand) calc(h + var(--shift)) s l)

OKLCH

Base
Shift
oklch(from var(--brand) l c h) oklch(from var(--brand) l c calc(h + var(--shift)))

If you want predictable results across colors, OKLCH is usually the better space for lightness adjustments.

Use relative colors with currentColor

A neat trick is using currentColor as the source. That lets icons, borders, and highlights follow the text color automatically.

.badge {
	color: #1d4ed8;
	border: 1px solid rgb(from currentColor r g b / 0.2);
	background-color: oklch(from currentColor clamp(0%, calc(l + 40%), 100%) c h);
}

currentColor resolves to the computed text color of the element. That means any change that affects the actual computed color (inheritance, state styles, media queries) will flow through the derived colors automatically.

CurrentColor badge
New feature

One change to color updates the border and background without touching any other variables.

The payoff

Once you start using relative colors, you stop thinking in isolated hex values. You think in relationships: hover is a darker brand, borders are the same brand with lower alpha… and your palette stays coherent by default.

If you need older browser support, consider a fallback palette.

If you are building a design system or cleaning up a few components, this is one of the CSS features that makes everything feel simpler.

Support My Work

Feel free to leave your comments or questions via email at hey@theosoti.com.
If you found this article helpful, please share it with your peers and join my newsletter below to not miss anything on CSS.

Happy coding!

    No strings attached, unsubscribe anytime!

    Other articles you might like

    Explore more articles on front-end development, covering HTML, CSS and JavaScript for building high-performance websites.