Skip to main content Skip to docs navigation

Component Anatomy

How Chassis CSS components are built — CSS variables, placeholder extends, and a small set of mixins that turn design tokens into ready-to-use classes.

Every Chassis CSS component follows the same recipe: a base class declares the component's CSS variables (sourced from design tokens), extends a few placeholder selectors to inherit shared behaviour, and calls a handful of mixins to apply those variables to actual properties. The pattern keeps the source short, the output consistent, and the result fully theme-aware.

This page walks through the recipe using two real components as worked examples — .notification (a self-contained context-aware container) and .list-action (which layers an interactive state onto an existing component) — then summarises the building blocks you can reuse.

The recipe

A typical component is built from four moving parts.

1. CSS variables, sourced from tokens. The class declares its own CSS variables and points each one at a design-token value (with the corresponding token-side fallback). This is what makes the component customizable: any consumer can override the variable on an ancestor or on the element itself without touching the source. See Design Tokens for the token side of this.

2. Placeholder extends. Most components extend %component to inherit typography, color, border, and border-radius assignments. Context-aware components also extend %context so they can react to the Context Class on themselves or on an ancestor. Interactive items extend %interactive to pick up hover, focus, active, and disabled states.

3. Mixins. A small set of mixins in scss/mixins/_component.scss wires the CSS variables onto actual properties. They handle the parts that don't fit cleanly into a placeholder — for instance, padding that varies per component, or the stroke-exclusion calculation.

4. The component's own rules. Layout, positioning, gaps, and any structural CSS unique to the component round out the class.

Worked example: .notification

.notification is a banner with optional title, icon, and dismiss button. It needs full theming, contextual color variants, and pixel-perfect padding regardless of border width — so it's a fair representative of the pattern.

.notification {
  @extend %context, %component;
  --#{$prefix}bg-color: var(--#{$prefix}notification-bg-color, #{$notification-bg-color});
  --#{$prefix}padding-y: var(--#{$prefix}notification-padding-y, #{$notification-padding-y});
  --#{$prefix}padding-x: var(--#{$prefix}notification-padding-x, #{$notification-padding-x});
  --#{$prefix}gap: var(--#{$prefix}notification-gap, #{$notification-gap});
  --#{$prefix}border-width: var(--#{$prefix}notification-border-width, #{$notification-border-width});
  --#{$prefix}border-radius: var(--#{$prefix}notification-border-radius, #{$notification-border-radius});

  position: relative;
  gap: var(--#{$prefix}gap);
  @include exclude-strokes(notification);
  @include last-mb-0();

  &.solid { @extend %solid-context; }
}

Three things to notice:

  • Variables are namespaced first, then aliased to the canonical names. --cx-padding-y reads from --cx-notification-padding-y, which itself falls back to the $notification-padding-y Sass token. A consumer can therefore override --cx-notification-padding-y to retheme one notification, or --cx-padding-y to retheme this single instance, without ever touching the source. The same pattern applies to color, gap, and border properties.
  • %component and %context carry the heavy lifting. %component includes the typography, color, border, and border-radius mixins; %context exposes every context-scoped variable so the notification reacts to a .context primary (or any other) ancestor. Adding .solid extends %solid-context to flip the palette to the filled variant.
  • exclude-strokes(notification) and last-mb-0() are the only direct mixin calls. The first keeps the visual padding pixel-aligned with Figma regardless of border width (see Box Model). The second extends the .last-mb-0 utility so a trailing paragraph inside the notification doesn't add unwanted margin.

Worked example: .list-action

.list-action is a different shape of customization: it doesn't define a new component — it adds an interaction layer to an existing one (.list-item).

.list-group.list-action > .list-item,
.list-group > .list-item.list-action {
  @extend %interactive;

  &.active,
  &.active:hover {
    @extend %context, %solid-context;
    --#{$prefix}fg-color: var(--#{$prefix}fg-active);
    --#{$prefix}bg-color: var(--#{$prefix}bg-active);
    z-index: 2; // Place active items above their siblings for proper border styling
  }
}

The pattern here is composition rather than authorship:

  • %interactive adds the state behaviour. Hover, focus, active, and disabled — all driven by CSS variables, so the colours adapt automatically to whatever palette is active on the parent .list-group.
  • The active state opts in to a context palette on demand. Extending %context, %solid-context inside &.active means a selected item adopts a filled, context-coloured treatment without needing a permanent .context class on every list item.
  • No new variables are declared. .list-action rides on the variables that .list-item already exposes, so customizations to the parent component flow through automatically.

Use this approach whenever you need a behaviour modifier that should compose with — rather than replace — an existing component.

Building blocks

All the pieces the recipe relies on live in three folders. Pick the one that fits and @extend or @include it.

Component placeholders

scss/placeholders/_component.scss provides the foundational placeholders:

  • %component — typography + colors + border + border-radius. The default extend for any component that needs the full Chassis treatment.
  • %typography — font properties only.
  • %colors — color, background-color, and border-color only.
  • %border — border shorthand only.

Other placeholder files cover specialised behaviour: %context and the four style placeholders (%basic-context, %solid-context, %smooth-context, %outline-context) live in _context.scss; %interactive in _interactive.scss.

Component mixins

scss/mixins/_component.scss exposes four mixins for direct use, each accepting an optional component namespace string:

/// Apply standard color properties using CSS custom properties.
/// Optionally namespaces variables under a component prefix.
///
/// @param {String|null} $comp [null] - Component namespace (e.g., "button"); prepends `{comp}-` to variable names
@mixin colors($comp: null) {
  color: var(--#{$prefix}#{if($comp, "#{$comp}-", "")}fg-color);
  background-color: var(--#{$prefix}#{if($comp, "#{$comp}-", "")}bg-color);
  border-color: var(--#{$prefix}#{if($comp, "#{$comp}-", "")}border-color);
  // fill: var(--#{$prefix}#{if($comp, "#{$comp}-", "")}icon-color);
}
/// Apply standard padding using CSS custom properties.
/// Optionally namespaces variables under a component prefix.
///
/// @param {String|null} $comp [null] - Component namespace; prepends `{comp}-` to variable names
@mixin padding($comp: null) {
  padding: var(--#{$prefix}#{if($comp, "#{$comp}-", "")}padding-y) var(--#{$prefix}#{if($comp, "#{$comp}-", "")}padding-x);
}
/// Apply a full border shorthand using CSS custom properties.
/// Falls back to `0 solid transparent` when the variables are not set.
///
/// @param {String|null} $comp [null] - Component namespace; prepends `{comp}-` to variable names
@mixin border($comp: null) {
  border: var(--#{$prefix}#{if($comp, "#{$comp}-", "")}border-width, 0) var(--#{$prefix}#{if($comp, "#{$comp}-", "")}border-style, solid) var(--#{$prefix}#{if($comp, "#{$comp}-", "")}border-color, transparent);
}
/// Extend the `.last-mb-0` utility class to remove `margin-bottom` from the
/// last `<p>` child of the element. Useful in card bodies, popovers, and
/// other containers where trailing paragraph spacing would break layout.
@mixin last-mb-0 {
  @extend .last-mb-0;
}

Plus the stroke-exclusion mixin, which subtracts the border width from the padding so visual spacing matches the design token. It's documented in detail under Box Model → Stroke exclusion:

/// Apply padding while optionally subtracting the border width, so the visual
/// padding matches design specs regardless of border width.
///
/// Controlled by `$enable-exclude-strokes`:
/// - `true`  — Always subtract border from padding
/// - `false` — Never subtract; emit standard padding
/// - `List`  — Only subtract when `$comp` is in the list
///
/// @param {String} $comp - Component name to look up in `$enable-exclude-strokes`
@mixin exclude-strokes($comp) {
  @if $enable-exclude-strokes == true or index($enable-exclude-strokes, $comp) {
    $border-width: var(--#{$prefix}border-width);
    $padding-y: #{calc(var(--#{$prefix}padding-y) - #{$border-width})};
    $padding-x: #{calc(var(--#{$prefix}padding-x) - #{$border-width})};
    padding: $padding-y $padding-x;
  } @else {
    padding: var(--#{$prefix}padding-y) var(--#{$prefix}padding-x);
  }
}

When $comp is supplied to the namespaced mixins, they read from --cx-{comp}-{property} instead of --cx-{property} — useful if a component needs to keep its variables namespaced rather than aliased onto the canonical CSS variables.

Building your own component

Putting it together, a custom component that wants the same theming and contextual behaviour as the built-ins follows this skeleton:

.my-component {
  // 1. Inherit shared behaviour
  @extend %component;             // typography + colors + border + border-radius
  @extend %context;               // optional: react to .context.* on ancestors

  // 2. Declare component-scoped CSS variables, with token fallbacks
  --#{$prefix}padding-y: var(--#{$prefix}my-component-padding-y, #{$my-component-padding-y});
  --#{$prefix}padding-x: var(--#{$prefix}my-component-padding-x, #{$my-component-padding-x});
  --#{$prefix}border-radius: var(--#{$prefix}my-component-border-radius, #{$my-component-border-radius});

  // 3. Wire the variables onto properties via mixins
  @include exclude-strokes(my-component);   // or @include padding();

  // 4. Component-specific layout
  display: inline-flex;
  align-items: center;

  // 5. Optional: opt in to context style modifiers
  &.solid   { @extend %solid-context; }
  &.smooth  { @extend %smooth-context; }
  &.outline { @extend %outline-context; }
}

A few practical tips:

  • Keep component-specific values namespaced (--cx-my-component-*) and alias them onto the canonical names (--cx-padding-y, --cx-bg-color, …) inside the component. That gives consumers two override surfaces — one per component, one per element — without you doing extra work.
  • Reach for a placeholder before a mixin. Placeholders compile down to grouped selectors and shrink the output; mixins inline the same rules into every callsite.
  • If you need responsive variants, pair @each $breakpoint in map-keys($grid-breakpoints) with media-breakpoint-up — the same pattern Chassis uses for the dropdown alignment classes and the horizontal list group:
// We deliberately hardcode the `cx-` prefix because we check
// this custom property in JS to determine Popper's positioning

@each $breakpoint in map-keys($grid-breakpoints) {
  @include media-breakpoint-up($breakpoint) {
    $infix: breakpoint-prefix($breakpoint, $grid-breakpoints);

    .#{$infix}dropdown-menu-start {
      --cx-position: start;

      &[data-cx-popper] {
        right: auto;
        left: 0;
      }
    }

    .#{$infix}dropdown-menu-end {
      --cx-position: end;

      &[data-cx-popper] {
        right: 0;
        left: auto;
      }
    }
  }
}

$grid-breakpoints is itself just a Sass map; modify it and every loop that iterates over it adapts on the next compile:

$grid-breakpoints: (
  xsmall:       0,
  small:        $breakpoint-small,
  medium:       $breakpoint-medium,
  large:        $breakpoint-large,
  xlarge:       $breakpoint-xlarge,
  2xlarge:      $breakpoint-2xlarge
);

For the wider Sass-map and loop pattern — including how to add, modify, or remove entries from these maps — see Sass maps and loops.