Horizon's Server-Side Contrast Colors: Deriving Text and Derived Colors with Liquid Color Filters
This article is based on Horizon 4.2.0, with source pinned to 5acd1b6 (verified 2026-10-07). Conclusions are limited to that commit; this is a static source analysis and nothing was tested in a store.
As soon as merchants can give a block its own background color, someone will pick a dark background without changing the text color and end up with black text on black.
The common fix is color schemes: merchants predefine a few foreground and background combinations, and a block can only pick one. This Horizon baseline has no color_scheme setting and takes another route: a block can use any background color, and Liquid computes the text color automatically on the server. Involved:
- snippets/color-palette.liquid: the global palette and button hover colors
- snippets/contrast-override.liquid: block-level contrast overrides
- snippets/util-palette-hover-shift.liquid: hover color derivation
- snippets/brightness-opacities.liquid: opacity adjusted for background brightness
The Liquid color filters involved
| Filter | Purpose |
|---|---|
color_brightness | Returns perceived brightness from 0 to 255 |
color_contrast: other | Returns the contrast ratio between two colors (WCAG definition, 1–21) |
color_lighten / color_darken | Adjusts lightness |
color_modify: 'alpha', x | Changes opacity |
.rgb / .alpha | Components of a color object |
These are all built-in Shopify filters. The computation happens on the server and the output is a fixed color value, with no runtime cost on the client.
Step 1: find the palette's two extremes
Theme settings include a palette (a setting of type color_palette in settings_schema.json), and other color settings reference it in their defaults; for example page_text_color defaults to {{ settings.color_palette.foreground }}.
color-palette.liquid iterates over the palette to find the lightest and darkest colors, counting only fully opaque ones (alpha == 1); the starting values are #ffffff and #000000:
for palette_color in settings.color_palette
if palette_color != blank and palette_color.alpha == 1
assign b = palette_color | color_brightness
# update palette_lightest / palette_darkest
endif
endfor
The results are output as CSS variables:
--palette-lightest: ...; --palette-lightest-rgb: ...;
--palette-darkest: ...; --palette-darkest-rgb: ...;
Why not just use pure white and pure black? The "darkest" color in a brand palette is often a deep navy or dark brown rather than #000, so the automatically chosen contrast color stays within the brand's palette.
Step 2: a three-step fallback at block level
When a block or section sets a background color, it renders contrast-override:
{% render 'contrast-override',
background_color: block.settings.background_color,
text_color: block.settings.text_color,
section_id: block.id %}
When the background is blank or fully transparent (alpha == 0), styles are only output if an explicit text color is given. With a background, the text color is decided in this order:
- The merchant set an explicit text color → use it;
- Otherwise, compute the contrast between the global page text color
settings.page_text_colorand this background; if it's ≥ 4.5 (WCAG AA for body text) → keep the global text color; - Otherwise, use
var(--palette-darkest)if the background brightness is > 128, elsevar(--palette-lightest).
assign page_text_contrast = background_color | color_contrast: settings.page_text_color
if page_text_contrast >= 4.5
assign effective_text_color = settings.page_text_color
else
assign effective_text_color = contrast_var # 'var(--palette-darkest)' or 'var(--palette-lightest)'
endif
Step 2 matters: don't change it if you don't have to. Global black text on a light gray background is already readable and shouldn't be replaced with some dark palette color.
Step 3 outputs a CSS variable reference, not a concrete color. When the global palette changes, every block's fallback color follows automatically.
The border color has a fallback too: an explicit border_color wins; otherwise preset: 'ui' follows the effective text color, while the default content preset uses the same brightness-split palette extreme.
The final output is a scoped style block:
.color-custom-{{ section_id }},
.color-custom-{{ section_id }} .text-block {
--color-background: ...;
--color-foreground: ...;
--color-foreground-muted: rgb(... / var(--opacity-muted-text));
--color-border: ...;
}
Tokens for disabled inputs and checkbox borders are only written on .color-custom-{{ section_id }}, not on .text-block; the comment explains that .text-block is a prose container that never holds form controls. A block's HTML just needs the color-custom-{{ block.id }} class. Across the repository, 59 section, block and snippet files call this snippet (not counting its own doc examples).
Step 3: derived colors must follow the background too
Changing the text color alone isn't enough. Semi-transparent dividers, disabled inputs and hover backgrounds can all "disappear" on a dark background.
Opacity tokens switch with brightness. brightness-opacities.liquid raises a set of opacities when the background brightness is < 64. An excerpt:
| Token | Light background | Dark background |
|---|---|---|
--opacity-5-15 | 0.05 | 0.15 |
--opacity-10-25 | 0.1 | 0.25 |
--opacity-35-55 | 0.35 | 0.55 |
The token names spell out both values (5-15 means 5% on light, 15% on dark), so someone reading the CSS knows the value changes without looking up the definition.
Hover colors are derived by brightness band. util-palette-hover-shift.liquid:
if alpha < 0.4
# too transparent for a lightness change to show → add 0.15 opacity
echo color | color_modify: 'alpha', new_alpha
else
# ≤40 lighten 15; ≤128 lighten 5; ≤190 darken 10; >190 darken 5
endif
The darkest band gets the largest lightening: on colors close to pure black, a small change is barely visible.
Button hover text colors are recomputed as well: for each of the four groups (primary button, secondary button, variant and selected variant), the palette's darkest color is used when the hover background's brightness is > 128, otherwise the lightest; when the original background isn't fully opaque, the original text color is kept.
When the background is an image or video
snippets/group.liquid and the _card block pass skip_contrast: true when background media is set, while sections such as hero and layered-slideshow always skip. Only the background color and opacity tokens are output then, with no automatic text contrast. The source gives no reason; a reasonable reading is that the server can't know how bright the media is.
Things worth questioning
- The brightness threshold is a hard-coded 128. For mid-brightness backgrounds (a saturated brand red, say), a brightness check isn't necessarily accurate. Step 2 uses a real contrast calculation, yet step 3 falls back to a brightness split. A sturdier approach would compute the contrast of both the lightest and darkest colors against the background and take the higher. The snippet's doc comment says it picks "the palette extreme that best contrasts the background"; the code only approximates that by brightness.
- The preset description doesn't match the code. The doc comment says the
contentpreset emits accent tokens on top ofui, but the snippet contains no accent variables at all; with a background, the only difference between the two presets is the border fallback. - Every block outputs its own
<style>. 59 calling files mean a page can carry dozens of inline style blocks. They're usually small, but they add HTML parsing work. color-palette.liquidis highly repetitive. The hover calculations for primary buttons, secondary buttons, variants and selected variants are almost identical and written out four times. Liquid has no functions;captureplusrenderis a clumsy way to reuse code, a limitation of the language itself.
Summary
Horizon's color system can be summed up as: merchants only make choices; the theme backstops readability.
- The global palette provides two extremes of the brand's colors as fallback contrast colors;
- At block level, keep the global text color where possible and fall back only when it isn't enough;
- Derived opacities and hover colors follow the background brightness;
- Everything is computed on the server and output as scoped CSS variables.
Compared with color schemes, this gives merchants far more freedom. But the threshold checks are approximations and this article didn't measure actual contrast, so check computed styles in a browser before launch.
Horizon's LICENSE.md forbids distributing themes derived from its code; this article only analyzes the source, so implement any borrowed ideas yourself.