Home /Color guides /Your brand color works as a button and fails as a link

Your brand color works as a button and fails as a link

Buttons and links usually share one color, and they answer to two different thresholds. A button fill is a non-text element and needs 3:1. A link inside body copy is small text and needs 4.5:1. That single gap disqualifies nearly half of the primaries here from doing the link job.

Last updated:2026-11-03

Half the primaries miss the body-link threshold

Across 34 web palettes, the primary color measures a median 5.02:1 against its own background, with a low of 3.09:1. Judged at 4.5:1, 16 of them fall short, close to half. Not one sits below 3:1.

That distribution is not an accident. 3:1 is the floor applied when the palettes were built, because a primary below it disappears on a button, so those never shipped. The interesting band is 3:1 to 4.5:1: enough for a button, not enough for a link in running text. The label "primary" makes it easy to assume the color is cleared for every job.

UseRequirementFailing here
Button fill, icon, control boundary3:10
Link text inside body copy4.5:116

The tightest cases all sit in the middle of the lightness range: #a88a6b at 3.09:1, with #b8860b and #2698ba both near 3.2. They feel comfortable as buttons because button labels are usually white, bold and large. Demote the same value to a line of link text and the type gets smaller and thinner while the color stays put.

The fix is lightness, not a different color

Link colors here are not hand-picked. They are computed from the primary: same hue, saturation plus 6, lightness minus 15, clamped between 20 and 60 so nothing collapses to black.

Measured: all 34 links came out darker than their primary, with a median lightness drop of 14.9 points, ranging from 4.7 to 16.3. The small drops are palettes whose primary was already deep enough to hit the clamp at 20. Median hue drift is 0.2 degrees, which nobody can see.

The result: palettes failing 4.5:1 drop from 16 to 1, and the median rises from 5.02:1 to 8.15:1. The one that still falls short measures 3.23:1, not because the rule is wrong but because its primary was already dark enough that the clamp at 20 stopped the drop.

PrimaryDerived linkContrast
#a88a6b#8364433.09:1 to 5.20:1
#b8860b#7353033.20:1 to 6.96:1
#2698ba#15667f3.20:1 to 6.20:1

Why not simply choose a deeper blue or a darker gray instead? Because the moment the hue moves, the link color and the brand color become two colors. Users see two blues in one interface while the spec says "links use a darkened primary". Holding the hue and dropping lightness is the cheapest way to keep the idea of one brand color intact on screen.

Only web has a link role

All three platforms carry ten roles, and link appears on only one of them. 2 platforms have no link color: app and mini program.

That is not an oversight. Inline links are rare on a phone, where the job belongs to tappable list rows and buttons, so neither platform needs a separate link token. The cost is that the primary there has no derived stand-in: it either clears the threshold or it does not.

Do not reach for a neighbor role to cover the gap. The mini program's price carries price semantics, deliberately warm and pulled away from the theme color, and the app's accent exists to draw attention. Both are built to be noticed, not to be tapped, and using either as a link color gets both the semantics and the affordance wrong.

So mobile has two honest options. If the primary clears 4.5:1 against the background, use it on tappable text. If it does not, keep the primary for fills and decoration, and render text links in the main text color with an underline rather than forcing it.

A second threshold: can the link be told apart from the text

Everything so far measures the link against the background. A second threshold runs at the same time and is checked far less often: the link color against the body text color. WCAG 1.4.1 covers information conveyed by color alone, and it is specific about this case. If color is the only cue separating a link from surrounding text, the two must reach 3:1, or another cue such as an underline has to be present.

Measured here: 33 of 34 links fall under 3:1, with a median of 1.79:1 and a low of 1.00:1. Every one of them passed the background test in the previous section, and every one of them still sits close enough to the body copy that most readers will not separate them by color.

The reason is structural rather than careless. The link is a darkened primary and the body text is near-black, so both live in the dark end of the range where luminance differences compress. Leaning on color to pull a link out of running text works less well the darker the text gets.

That makes an underline close to mandatory for inline links, not a style preference. It also sets where color alone is still acceptable: nav items and card titles, where position and shape already say "tappable" before color is considered.

Putting it to work

  1. Measure the primary against the background instead of trusting it to look dark enough. 4.5:1 governs body links, 3:1 governs buttons, and the two are routinely treated as one number.
  2. When it falls short, drop the lightness and leave the hue alone. The working value here is 14.9 points with a small saturation bump. Stop at 4.5:1 rather than applying a fixed 15 every time.
  3. Keep a floor under the drop. Below lightness 20 the color starts turning black, the hue stops reading, and the link becomes a heavier shape than the body text, which is its own failure.
  4. Invert it for dark themes. The same derivation darkens on a white page and has to lighten on a dark one, or the link sinks into the background.

The third item is the one people skip. Chasing contrast all the way down produces something that looks like a heading sitting inside a sentence. Hitting a ratio and being readable are different properties: the first one is arithmetic, the second one still needs eyes.

Where the numbers come from

Every ratio above is computed at build time in src/lib/guide-stats.mjs with the WCAG 2.1 relative luminance formula, comparing primary and link against each palette's own bg across 34 web palettes. Lightness and hue differences come from HSL subtraction, with the hue gap folded to 180 degrees.

The derivation is not a rule invented for this article. It is what refineRoles() in src/lib/generate.mjs actually does (same hue, saturation plus 6, lightness minus 15, clamped to 20 through 60), so the guidance here and the shipped values come from one implementation.

Related: secondary text covers the two-level contrast inside body copy, cards and dividers covers the roles that sit below 3:1, and the editorial standard records where these thresholds come from.