How to define the tonal relationships between button fill, border, and text — and the CSS techniques for drawing the border itself.
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.
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.
For a monotone button system on a known background, you need:
| Token | Role | Typical 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.
There are two approaches to defining these contrast relationships, and the choice between them has real consequences for theming, compositing, and rendering.
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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).
| Property | border | box-shadow | outline | background-clip |
|---|---|---|---|---|
| Affects layout | Yes | No | No | Yes (transparent border) |
| Follows radius | Yes | Yes | Yes (not Safari) | Yes |
| Dashed / dotted | Yes | No | Yes | No |
| Per-side control | Yes | No | No | Yes |
| High Contrast Mode | Visible | Not visible | Visible | Not visible |
| Animatable | Color only | Fully (smooth) | Color only | Via gradient |
| Stackable | No | Yes (multiple) | No | Yes (multiple) |
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.
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.
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.
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.
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.
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.
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.
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-alpha — hsl(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.
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.
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.
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.