Custom Color System
Overview
Every Midwest component is themed through two 13-step CSS custom property scales:
--theme-{0–12}-channels— the primary brand color--complement-{0–12}-channels— an accent or secondary color
Each step stores space-separated RGB channels (R G B) so Tailwind can compose
them with opacity modifiers (bg-theme-6/50). Tailwind utilities like
bg-theme-6, text-complement-3, and border-theme-7 all resolve through
these variables, meaning every component in the tree inherits any override you
make on an ancestor element.
Named themes
The simplest way to change the theme is a CSS class. Thirteen named palettes are
built in: red, orange, gold, yellow, lime, green, teal, cyan,
blue, indigo, violet, magenta, and pink.
Apply a theme-{color} class to any ancestor element — typically a layout
wrapper or <body> — and all components inside it will use that palette:
<body class="theme-indigo complement-violet"> <%# All buttons, badges, inputs etc. use indigo / violet %></body><%# Scope a different theme to a single section %><section class="theme-teal"> <%= midwest_button("Book now") %></section>User-selectable themes with color_theme_field
The color_theme_field form builder helper renders a ColorPickerComponent
pre-wired to a model attribute. When the user picks a color the hidden input is
updated; on submission the value (a color name or a hex string) is stored on the
record.
<%= form.color_theme_field :theme_color, label: "Brand color" %>Persist the value, then apply it at render time (see Applying a saved color below).
Enabling the custom color picker
A Default swatch (the diagonal-stripe tile) is shown by default and
represents an empty / unset value. Pass notransparent: true to hide it.
Pass custom: true to add a rainbow swatch that opens the native browser color
picker. The selected hex is stored in the hidden input and the component
dispatches a midwest-color-picker:update Stimulus event whose detail includes
a pre-computed scale object:
<%= form.color_theme_field :theme_color, label: "Brand color", custom: true %>// Listen for live updates to apply the scale elsewhere on the pagedocument.addEventListener("midwest-color-picker:update", ({ detail }) => { const { color, scale, scaleTarget } = detail if (scale) { Object.entries(scale).forEach(([step, channels]) => { document.documentElement.style.setProperty( `--${scaleTarget}-${step}-channels`, channels ) }) }})Targeting theme vs complement
Pass target: :complement to make a second picker control the complement scale
independently:
<%= form.color_theme_field :theme_color, label: "Primary color", custom: true, target: :theme %><%= form.color_theme_field :complement_color, label: "Accent color", custom: true, target: :complement %>Automatic color contrast
Buttons, badges, and other themed components automatically flip their text color between black and white depending on the perceptual luminance of their background. This means any named theme or custom hex color just works — you never have to specify a foreground color manually.
The formula uses relative oklch color syntax and is expressed as a CSS custom
property so every component in the system inherits it consistently:
--tolerance: 0.65;color: oklch(from var(--btn-bg) clamp(0, (var(--tolerance) - l) * 1000, 1) 0 0);lis the perceptual lightness of the background in the oklch space (0 = black, 1 = white).- When
l < 0.65the formula resolves tooklch(1 0 0)— white text. - When
l ≥ 0.65the formula resolves tooklch(0 0 0)— black text. --tolerancecan be overridden per-element if a specific hue needs a different threshold.
This applies to all 13 built-in named palettes and to any custom hex scale
generated by the color picker or Midwest::ColorScale.
Generating a scale server-side
Midwest::ColorScale.from_hex converts any hex color into a 13-step hash of
RGB channel strings — the same algorithm used by the JavaScript color picker:
scale = Midwest::ColorScale.from_hex("#7c3aed")# => {# 0 => "248 244 255",# 1 => "230 213 255",# ...# 6 => "109 40 217",# ...# 12 => "10 2 20"# }The keys are Integer step indexes (0–12); the values are ready to drop directly
into --theme-{n}-channels or --complement-{n}-channels.
Reading a single color value with Midwest::Theme
ColorScale.from_hex only understands hex. But a stored value (an account's
theme_color, say) is often a named theme or a hex string — and feeding a
name like "green" to a hex parser silently produces garbage such as
rgb(0, 238, ).
Midwest::Theme resolves either form to a real color value. Named themes come
straight from color-system.css (the same literals the .theme-* classes
render, so they can never drift); custom hex colors resolve through
ColorScale. Every accessor takes a shade (0 = lightest … 12 = darkest,
default 6 — the primary shade), and returns nil when the value can't be
resolved, so the caller picks the fallback:
Midwest::Theme.hex("green") # => "#16a34a"Midwest::Theme.rgb("green") # => "rgb(22, 163, 74)"Midwest::Theme.channels("green") # => "22 163 74" (drop into a --*-channels var)Midwest::Theme.hex("green", 8) # => a darker shadeMidwest::Theme.hex("#1c7ed6") # => custom hex, via ColorScaleMidwest::Theme.hex("nope") # => nilMidwest::Theme.scale("green") # => { 0 => [r, g, b], ..., 12 => [r, g, b] }Midwest::Theme.named?("green") # => true (built-in named theme?)Midwest::Theme.names # => ["gray", "red", ..., "pink"]The same three in a view
Midwest::ViewHelper exposes the value accessors so you can resolve a color
right where a CSS class can't reach — a <meta name="theme-color"> tag, an
inline SVG fill, a chart color, or an HTML email:
<%# Browser chrome color from the account's saved theme %><meta name="theme-color" content="<%= midwest_theme_hex(current_account.theme_color) %>"><%# Assign channels to a custom property inline %><div style="--accent-channels: <%= midwest_theme_channels(current_account.theme_color) %>">midwest_theme_hex(color, shade = 6)→"#rrggbb"ornilmidwest_theme_rgb(color, shade = 6)→"rgb(r, g, b)"ornilmidwest_theme_channels(color, shade = 6)→"r g b"ornil
Prefer a CSS class (midwest_theme_class, or a theme-{color} Tailwind
utility) whenever one will do — a class keeps opacity modifiers (bg-theme-6/50)
and dark-mode inheritance. Reach for Midwest::Theme only when you need the
literal value.
Applying a saved color
Three view helpers are included in Midwest::ViewHelper (available after
running the install generator):
midwest_theme_class(color)— returns"theme-{color}"for a named color, or""for a hex value or blank (hex colors use CSS variables instead).midwest_complement_class(color)— same for the complement scale.midwest_color_scale_tag(theme:, complement:, selector: ':root')— emits a<style>tag containing the CSS custom-property declarations for any hex colors. Named colors and blank values are silently ignored, so it is safe to call unconditionally.
In a layout
All three helpers handle named colors, hex colors, and blank values correctly, so the full layout pattern is unconditional:
<body class="<%= midwest_theme_class(current_account.theme_color) %> <%= midwest_complement_class(current_account.complement_color) %>"> <%= midwest_color_scale_tag( theme: current_account.theme_color, complement: current_account.complement_color ) %>- Named colors (
"indigo") produce a CSS class; the style tag emits nothing. - Hex colors (
"#7c3aed") produce no class; the style tag emits the 13-step CSS variable declarations. - Blank/nil values are silently ignored by all three helpers.
Server-side with ColorScale.to_css
For non-view contexts (mailers, rake tasks, API responses), use
Midwest::ColorScale.to_css directly. Pass target: (:theme or
:complement) and an optional selector: (defaults to :root):
Midwest::ColorScale.to_css('#7c3aed')# => ":root {\n --theme-0-channels: 248 244 255;\n ...\n}"Midwest::ColorScale.to_css('#7c3aed', target: :complement, selector: '.brand')# => ".brand {\n --complement-0-channels: 248 244 255;\n ...\n}"Live preview
The example below uses Midwest::ColorScale.from_hex to apply a full scale
inline — no JavaScript required. Change the hex parameter to see a different
palette instantly:
For the interactive client-side version (with the color picker and a live updating strip), see the Color Selector pattern: