Dustin Johnson ← Design Primitives
Design Primitive Reference · Part II · April 2026

Contrast & Construction

How to define the tonal relationships between button fill, border, and text — and the CSS techniques for drawing the border itself.

A1. The Contrast Problem

Part I stripped away color to isolate structure. Now we add it back — but only one channel of it. Working in grayscale (or a single hue) reveals the real question beneath every button color decision: how much contrast does each element need, relative to what?

A button has up to four tonal surfaces that need to relate to each other: the page background, the button fill, the button border, and the button text. The contrast ratios between these surfaces determine both the button's visual weight and its accessibility. WCAG 2.x requires a minimum 4.5:1 contrast ratio between text and its background for normal-sized text, and 3:1 for large text or non-text UI components (including button boundaries against their surroundings).

The challenge is that these four surfaces need to maintain their contrast relationships across themes (light, dark, high-contrast) and across every color you'll eventually apply. If you hard-code your button colors, you'll be re-tuning every value for dark mode. If you build the system correctly, a single base-color change will cascade through every button tier while preserving the contrast pattern.

A2. How Many Tones Do You Actually Need?

A complete set of primary, secondary, and tertiary button styles — with hover and disabled states — can be expressed with surprisingly few tonal values. The key insight is that most of these values are not independent colors. They are the base color at different opacities.

The Minimum Token Set

For a monotone button system on a known background, you need:

TokenRoleTypical value
--surface Page background. The canvas everything sits on. hsl(H, S%, 97%)
--solid Primary button fill. Text on outlined/ghost buttons. Maximum contrast element. hsl(H, S%, 13%)
--solid-text Text on the solid fill. Typically white or near-white. hsl(H, S%, 97%)
--solid / 8% Tinted button fill. A breath of the solid color over the surface. hsl(H, S%, 13% / 0.08)
--solid / 20–25% Outlined button border. Visible but subordinate to solid fill. hsl(H, S%, 13% / 0.22)
--solid / 55–65% Ghost button text (de-emphasized). Disabled text. hsl(H, S%, 13% / 0.6)

That's effectively two colors (a dark and a light) plus three to four opacity stops of the dark applied to different surfaces. This is the entire palette needed for a full three-tier button hierarchy.

Three-tier hierarchy from a single base color + opacity
100% fill
22% border
8% fill
60% text

A3. Opacity vs. Explicit Color

There are two approaches to defining these contrast relationships, and the choice between them has real consequences for theming, compositing, and rendering.

The Opacity Approach

Define one base color in HSL, then derive every variant using the alpha channel. This is the strategy used (in spirit) by shadcn/ui, Radix Colors, and Tailwind's opacity modifier syntax.

/* Define one base as an HSL channel list */ :root { --base: 220, 8%, 15%; --surface: hsl(220, 8%, 97%); } /* Derive every button tone from that single base */ .btn-primary { background: hsl(var(--base)); color: white; } .btn-secondary { background: hsl(var(--base) / 0.08); color: hsl(var(--base)); } .btn-secondary-outlined { border: 1.5px solid hsl(var(--base) / 0.22); color: hsl(var(--base)); } .btn-ghost { color: hsl(var(--base) / 0.6); }

Advantages: Changing --base recolors your entire button system. The contrast ratios are preserved because the opacity relationships don't change. This makes theme switching trivial — swap the base, and light/dark mode largely takes care of itself (with the surface color also swapped). It also means fewer tokens: one base color generates the full hierarchy.

Disadvantages: Alpha compositing is context-dependent. An 8% tinted button over a white surface produces a different computed color than 8% over a dark surface. This is usually fine (and often desirable — tinted buttons naturally adapt to their background), but it means the final rendered color isn't fully predictable in every context. There can also be subtle compositing artifacts when semi-transparent elements overlap.

The Explicit Color Approach

Define every button tone as a fully opaque, pre-computed color. This is the approach of larger design systems like Carbon and Material, where every shade is a specific hex or HSL value in a numbered scale (gray-100 through gray-900).

:root { --btn-primary-bg: hsl(220, 8%, 15%); --btn-primary-text: hsl(220, 8%, 97%); --btn-secondary-bg: hsl(220, 8%, 91%); /* pre-mixed "8% over white" */ --btn-secondary-text: hsl(220, 8%, 15%); --btn-border: hsl(220, 8%, 78%); /* pre-mixed "22% over white" */ --btn-ghost-text: hsl(220, 8%, 45%); /* pre-mixed "60% over white" */ } /* Dark mode: every value must be manually re-specified */ .dark { --btn-primary-bg: hsl(220, 8%, 90%); --btn-primary-text: hsl(220, 8%, 10%); --btn-secondary-bg: hsl(220, 8%, 20%); /* ... and so on for every token */ }

Advantages: Total predictability. What you specify is what renders, regardless of background. No compositing surprises. Works well in enterprise systems where tokens are the contract between design and engineering.

Disadvantages: Token proliferation. A three-tier button system with hover, active, disabled, and focus states across light and dark themes can easily require 20–30 explicit color tokens just for buttons. Every theme switch is a manual re-mapping. And the underlying contrast logic — that the border is "the base at 22%" — is invisible; it's baked into the hex values rather than expressed in the code.

The modern consensus leans toward the opacity approach for most teams. Shadcn/ui, Radix Colors, and Tailwind all use it as their foundational strategy. It trades a small amount of compositing predictability for a dramatic reduction in token count and theme maintenance. The explicit approach makes more sense when you need pixel-exact rendering or when your tokens are consumed by non-CSS platforms (iOS, Android) where alpha compositing behaves differently.

A4. The Contrast Relationships, Visualized

Here are the approximate contrast ratios between the tonal surfaces in a properly constructed monotone button system. These ratios hold whether the underlying tone is neutral gray, warm gray, or cool gray — because the opacity relationships are hue-independent.

Surface
97% L
1.3:1 Tint fill
91% L
2.1:1 Border
78% L
5.5:1 Ghost text
45% L
13.5:1 Solid fill
13% L
Approximate contrast ratios against the 97% lightness surface

The critical ratios to verify: text on the solid fill must exceed 4.5:1 (white text on 13% lightness achieves ~13.5:1, comfortably passing). Ghost/tertiary text must exceed 4.5:1 against the surface (45% lightness on 97% lightness achieves ~5.5:1, passing AA). The outlined button border must exceed 3:1 against the surface to meet the WCAG non-text UI component criterion (78% lightness on 97% achieves only ~2.1:1 — this is a common failure point, and why many systems use a slightly darker border or pair the outline with text that itself passes contrast).

This border contrast shortfall is one of the most common accessibility gaps in button systems. A "subtle" outlined button with a light gray border often fails the 3:1 non-text contrast requirement. The fix is either to darken the border (at the cost of visual subtlety) or to ensure the button text alone provides enough cue for the user to identify the interactive element.

A5. Warm and Cool: The Same System, Tinted

A monotone button system becomes a mono-tonal system by introducing a slight hue bias. This is how you get warm grays (Notion, Linear) or cool grays (Vercel, GitHub). The opacity-based approach makes this trivial: change the hue and saturation of the base, and every derived tone shifts in unison.

Warm base: hsl(30, 5%, 15%)
Solid
Outlined
Tinted
Cool base: hsl(220, 8%, 18%)
Solid
Outlined
Tinted

The contrast relationships are identical between the two sets. Only the color temperature changed. This is the power of building contrast from opacity rather than from independent color picks: the system is hue-portable.

B1. Four Ways to Draw a Border

CSS offers four distinct techniques for drawing a visible edge around a button. They look similar but behave differently in the box model, in sub-pixel rendering, in how they interact with border-radius, and in how they affect layout when mixed with solid (borderless) buttons.

The four techniques are: the border property, box-shadow with zero blur and spread (an "inset shadow border"), the outline property, and background-clip with a gradient border. Each has a specific set of strengths that make it preferable in different contexts.

B2. The Techniques, Compared

CSS Border

border: 1.5px solid #1a1a1a

Part of the box model. Adds to the element's computed dimensions unless box-sizing: border-box is set. Respects border-radius. Supports all style keywords (solid, dashed, dotted, double). Most predictable rendering.

Inset Box-Shadow

box-shadow: inset 0 0 0 1.5px #1a1a1a

Not part of the box model. Does not affect computed dimensions or layout. Respects border-radius. Cannot produce dashed or dotted styles. Can be layered with other shadows. Animates smoothly via transition.

CSS Outline

outline: 1.5px solid #1a1a1a;
outline-offset: -1.5px

Not part of the box model. Does not affect dimensions. Now follows border-radius in Chrome, Firefox, and Edge (since ~2021–2022). Still rectangular in Safari. Cannot differ per side. Supports outline-offset for spacing. Visible in Windows High Contrast Mode.

Background-Clip

border: 1.5px solid transparent;
background-clip: padding-box, border-box

Uses a transparent border with layered background-image gradients. Enables gradient or multi-color borders that follow border-radius. More complex to set up. Part of the box model (the transparent border still occupies space).

Propertyborderbox-shadowoutlinebackground-clip
Affects layoutYesNoNoYes (transparent border)
Follows radiusYesYesYes (not Safari)Yes
Dashed / dottedYesNoYesNo
Per-side controlYesNoNoYes
High Contrast ModeVisibleNot visibleVisibleNot visible
AnimatableColor onlyFully (smooth)Color onlyVia gradient
StackableNoYes (multiple)NoYes (multiple)

B3. The Alignment Problem

The most common practical issue with button borders is alignment. When a solid (borderless) primary button sits next to an outlined secondary button, and the border technique affects the box model, the two buttons will have different dimensions unless you compensate.

The Problem

Naive approach: border adds 3px total width/height
Solid · 10px pad
Outlined · 10px pad + 1.5px border
The outlined button is 3px taller and wider ↑

Four Solutions

1. Use box-shadow instead of border. This is the cleanest structural fix. The inset box-shadow draws the border visually but does not alter dimensions. Both buttons use the same padding, same computed size, and align perfectly. This is the approach used by many modern component libraries including parts of the shadcn/ui ecosystem.

Box-shadow border: identical dimensions
Solid
Inset shadow

2. Put a transparent border on the solid button. Give the solid button the same border width as the outlined button, but make it transparent. Both buttons now have the same box model. With box-sizing: border-box, the padding absorbs the border width and the content area shrinks slightly, but the outer dimensions match.

Transparent border: both buttons share box model
Solid + transparent border
Outlined

3. Reduce the outlined button's padding by the border width. If the solid button has 10px padding and the border is 1.5px, set the outlined button's padding to 8.5px. This is the approach Carbon Design System documents. It works but requires manual coordination between padding and border tokens.

4. Use box-sizing: border-box universally. With border-box, the border is drawn inside the element's declared dimensions rather than outside. If both buttons are set to the same height/width or min-height, the border eats into the padding rather than expanding the box. This is the most common baseline — virtually all modern CSS resets include *, *::before, *::after { box-sizing: border-box; }. Combined with approach 2 (transparent borders on solid buttons), this produces perfect alignment.

The transparent-border approach (solution 2) is the most widely recommended because it solves the alignment problem while preserving CSS border's full feature set — dashed and dotted styles, per-side control, and visibility in Windows High Contrast Mode. The box-shadow approach (solution 1) is simpler but sacrifices WHCM visibility and style variety.

B4. Sub-Pixel Rendering

Thin borders — especially 0.5px and 1px — render differently depending on the technique used and the display's pixel density. On a 2x Retina display, a 1px CSS border maps to 2 device pixels, and a 0.5px border maps to 1 device pixel. On a 1x display, 0.5px values are rounded up to 1px in most browsers, making them indistinguishable from 1px.

How the Techniques Differ

CSS border at sub-pixel values renders with the browser's native stroke rasterizer. At 0.5px on a 2x display, this produces a true hairline — one device pixel wide. This is the crispest possible border. On 1x displays, browsers typically round 0.5px to 1px for borders.

Inset box-shadow at sub-pixel values renders using the shadow compositing engine, which can produce slightly different anti-aliasing. At 0.5px spread, the shadow may appear softer or more diffuse than an equivalent 0.5px border, because the shadow system applies its own anti-aliasing pass. On Retina displays, this difference is negligible. On 1x displays, it can be visible.

Outline renders with the same crispness as border in most cases, but historically had inconsistent sub-pixel behavior across browsers. In 2026, Chrome, Firefox, and Edge handle it well. Safari still has occasional rendering quirks with outlines on rounded elements.

Border vs. box-shadow at thin widths (compare on a Retina display)
border: 1px
box-shadow: 1px
border: 0.5px
box-shadow: 0.5px
border: 1px rgba(…, 0.2)
box-shadow: 1px rgba(…, 0.2)

A practical note: low-opacity borders and low-opacity inset shadows can look identical at 1px on high-DPI screens. The rendering differences between the techniques become more visible at hairline widths (0.5px) or at wider widths (2px+) where the shadow's anti-aliasing edge becomes noticeable. For most button design work at 1–1.5px widths, the choice between border and box-shadow should be made on layout and feature grounds, not rendering grounds.

B5. What Changed Since 2015

A decade ago, the choice between these techniques was more constrained. Several significant improvements have landed in browsers since then:

Outline now follows border-radius in Chrome (since 94, late 2021), Firefox (since 88, early 2021), and Edge (same engine as Chrome). This was the single biggest limitation of the outline property for years — it produced rectangular focus rings on rounded buttons. Safari still renders rectangular outlines on some elements, which is why many systems still use box-shadow for custom focus rings and pair it with a outline: 2px solid transparent to preserve Windows High Contrast Mode visibility.

0.5px borders are now reliable on Retina. In 2015, sub-pixel border widths were inconsistent across browsers. Today, all major engines render 0.5px borders as true hairlines on 2x+ displays. This has enabled the "hairline border" aesthetic (seen in Linear, Vercel, Apple) that wasn't safely achievable a decade ago.

The hsl() / oklch() syntax with slash-alphahsl(220 8% 15% / 0.2) — is universally supported. This makes the opacity-based token approach described in Part A practical to write in native CSS without a preprocessor. In 2015, you needed Sass or PostCSS to generate alpha variants. Now it's a one-liner.

CSS color-mix() (supported in all major browsers since 2023) offers a third approach to deriving button tones: mix your base color with white or black to get predictable, fully opaque shades. color-mix(in oklch, var(--base) 22%, white) gives you the equivalent of a 22% overlay — but as an opaque color, avoiding compositing ambiguity. This is a meaningful new capability for the explicit-color approach, reducing the manual effort that was its main drawback.

/* color-mix: the best of both worlds? */ :root { --base: hsl(220, 8%, 15%); --surface: hsl(220, 8%, 97%); /* Derived tones — opaque, but auto-computed */ --btn-border: color-mix(in oklch, var(--base) 22%, var(--surface)); --btn-tint: color-mix(in oklch, var(--base) 8%, var(--surface)); --btn-muted: color-mix(in oklch, var(--base) 60%, var(--surface)); }

This color-mix() approach is arguably the most forward-looking technique in 2026. It gives you the opacity approach's simplicity (derive from one base) with the explicit approach's predictability (fully opaque output, no compositing surprises). The tradeoff is that it's mixing against a specific surface, so you still need to redefine for dark mode — but that redefinition is just swapping --base and --surface, not hand-tuning 20 tokens.

B6. A Recommendation

For a new design system in 2026, the practical advice cascades like this:

For contrast tokens: Use the opacity approach as your primary strategy, with color-mix() as a fallback for cases where you need opaque values. Define your base color as an HSL channel list, derive everything else through alpha. This gives you the smallest token surface and the best theme portability.

For border construction: Use border (CSS border property) as your default, with a transparent border on solid buttons to solve the alignment problem. This gives you the broadest feature set, the best accessibility story (WHCM visibility), and the crispest sub-pixel rendering. Use box-shadow for focus rings (where you need to layer it with an existing border) and for animation-heavy interactions where the shadow's smooth interpolation is valuable. Avoid outline for visual button borders — reserve it for focus indicators, where its non-layout-affecting behavior and WHCM visibility are genuine strengths.

/* The recommended pattern */ .btn { border: 1.5px solid transparent; /* always present */ border-radius: 8px; padding: 9px 21px; box-sizing: border-box; } .btn-primary { background: hsl(var(--base)); color: white; /* border remains transparent — same box model as outlined */ } .btn-secondary { background: transparent; color: hsl(var(--base)); border-color: hsl(var(--base) / 0.22); } .btn:focus-visible { outline: 2px solid transparent; /* WHCM fallback */ box-shadow: 0 0 0 3px hsl(var(--base) / 0.4); }

This pattern gives you: perfectly aligned solid and outlined buttons, a single base color driving the entire system, border-radius support everywhere, smooth focus ring animation via box-shadow, WHCM visibility via the transparent outline, and sub-pixel-crisp borders via the native border property. It's not the only correct answer — but it's the one with the fewest tradeoffs in 2026.

Part II of the Button Taxonomy. Part I covers shape, depth, fill, typography, and proportion.
Built for desktop web. Color is next.