CSS Utilities
CSS Utilities
SCSS mixins and conventions beyond spacing and layout (covered in the Spacing doc).
If a CSS pattern repeats across components, extract it as a mixin in _mixins.scss. New mixins are encouraged.
Import Convention
Every SCSS partial must begin with:
@use "../utilities/" as *;Mixins
| Mixin | Output |
|---|---|
@include card |
border-radius: 8px; border: 2px solid var(--border-mono-medium); padding: 24px |
@include ulreset |
list-style: none; padding: 0; margin: 0 |
@include hidescrollbar |
Hides scrollbar cross-browser, preserves scroll |
@include hidden |
display: none !important — hard hide, not accessible-hidden |
@include light-surface($bg, $raised: null) |
Paints $bg and publishes the light surface contract (see Colours, Surfaces) |
@include dark-surface($bg, $raised: null) |
Paints $bg and publishes the dark surface contract |
@include inherit-surface($bg: transparent) |
Paints $bg and inherits the enclosing surface's contract |
Cascade Layers
application.scss declares the layer order once, the component hierarchy read inside out, and loads every partial into one with meta.load-css:
@layer vendor, base, components, patterns, panels, pages, utilities;Vendor CSS (Swiper, Trix) sits under ours; base element styles under components; patterns over components; panels over patterns; pages over panels; the utility classes (.visually-hidden, the iOS scroll lock) on top. Everything the engine ships is layered, so a host app's own stylesheet, which is unlayered, beats every engine rule by definition rather than by being imported last. The utilities/ index forwards mixins and functions only; the utilities' CSS is loaded into its layer by the entry file, so a partial's @use "../utilities/" as * emits nothing.
Two consequences. A rule in a higher layer wins regardless of specificity, so a pattern rule that catches a component (.text-card > a matching a.button) now beats the component's own rule; scope such rules to what they mean (> a:not(.button)). And !important reverses the order: an important declaration in a lower layer beats one in a higher layer, which is what visually-hidden and hidden want and nothing else does.
!important
Kept only where it is a decision, each with its reason: the visually-hidden and hidden mixins, the iOS overflow lock, the .p-* padding utilities, the combobox [hidden] enforcement, and vendor CSS. Nothing else in the tree. Each is marked with a stylelint-disable comment giving the reason. A new !important needs the same and a mention in the PR; stylelint fails it otherwise.
Declared, Not Inferred
A component that renders something declares it as a data attribute set from Ruby; the stylesheet reads the attribute and never sniffs the markup. ButtonComponent emits data-icon when it renders an icon and _buttons.scss styles .button[data-icon]; there is no :has(.material-symbols-outlined) anywhere. The same shape as the panel header's align: / variant:, the text card's leading: and the image's fit:.
Raised Surfaces and Card Shadow
A card that sits on a surface fills from --surface-raised when the surface publishes one
and publishes a light surface inside, so its text reads dark on the pale fill whatever the
panel around it is. raised-surface($fallback) encodes that rule; cards call it, panels
never do:
.my-card { @include raised-surface(var(--bg-white)); }card-shadow is the card elevation. It bundles a backdrop blur, which creates a stacking
context, so for a hover or a transition pass card-shadow($blur: false).
Button Variants
The button skin belongs to the .button class; a native <button> without it is a plain control (no background, border, padding or margin, inherits its font and colour) from styles/_forms.scss, so a hand-written control button needs no reset. A layout the button can take is a variant the button owns in _buttons.scss; style is its colour. A container asks for a variant, it never restyles the button: ButtonComponent.new(button:, variant: :bar) emits data-variant="bar", and for a button a host renders into a container's slot, the container names the variant with one line, .header__better-action-links .button { @include button-action; }. The variants are mixins in components/_button-variants.scss, a module that emits no CSS of its own, so a panel can @use it without pulling the button's stylesheet into its layer.
| variant | what it is | who asks for it |
|---|---|---|
:bar |
a tab in a control-height bar: no radius or border, full height, xs type, inset-underline hover | the header's sector buttons |
:stretch |
fills its column at the large control height | the footer's cookie-settings button |
:action |
a text-and-icon action on the dark header bar | whatever a host renders into the header's action slot |
:menu |
a full-width, left-aligned row in a menu list | sector links a host renders into the mobile nav |
.button-content is selected only in _buttons.scss. The navigation item's trigger is its own element, .navigation__menu-item__button containing .navigation__menu-item__link, not a ButtonComponent, and _menu-item.scss owns it.
Nesting Depth
Maximum 4 levels. Prefer 2–3.
Known Inconsistencies
The following exist in the codebase but are not the correct pattern:
_buttons.scsshas hard-codedrgba()values for disabled states — use CSS custom properties from_colours.scss