Skip to content
Normative · v2026.1

The specification

Everything BEAM requires, in the order you will need it. Written as rules rather than suggestions, because the value of a convention is proportional to how few exceptions it has.

01

Principles

Four commitments. Every rule further down is a consequence of one of them.

PrincipleWhat it means in practice
Identity over utilityA component is named for what it is (.user_card), never for what it looks like (.bg-white.rounded.p-4). Appearance changes; identity does not.
Editor optimisedNaming is chosen so that one double-click selects a whole token and one search finds every use. Nothing may exist only after string concatenation.
Browser nativeCustom properties, native nesting, container queries. No SASS, no LESS, no CSS-in-JS runtime. PostCSS is permitted for build-time maths only.
State drivenState and variation live in HTML attributes, not in class names. data-* for application state, aria-* where a role already exists.
02

Class taxonomy

Five kinds of class exist. A class that fits none of them is a bug. This is the whole namespace, and it is deliberately small enough to recognise at a glance.

TypeSyntaxOwnsExample
Blocksnake_caseComponent or page identity.user_card
Elementblock-part_nameA dependent part of exactly one block.user_card-main_title
Layoutl_*Spatial geometry and rhythm only.l_stack
Utilityu_*Single-purpose, state-free behaviour.u_reset_button
Genericg_*Global visual objects too small to be components.g_divider
03

Naming rules

Three rules, and they are absolute. Most BEAM review comments are about this section.

1. Blocks derive from the component filename.UserCard.tsx becomes .user_card. RootLayout.astro becomes.root_layout. For framework route files with generic names — index.astro,page.tsx — use the route's semantic identity instead:.home_page,.settings_page.

2. One hyphen joins a block to its element. Underscores separate words inside either half. The hyphen therefore means exactly one thing in the entire codebase: belongs to.

3. Element names are flat. They never encode DOM depth, and selectors never nest to reach them. Depth changes; names should not.

CorrectCSS
.nav_bar { }
.nav_bar-list_item { }
.nav_bar-action_button { }
IncorrectCSS
/* hyphen now means two things */
.nav_bar-list-item { }

/* mirrors today's markup */
.nav_bar .list_item { }

/* the string does not exist */
.nav_bar {
  &-list_item { }
}
04

State and variation

Attributes replace BEM modifiers entirely. There are no state classes in BEAM — not one.

markupHTML
<article class="promo_card" data-featured="true" data-state="loading">
  <button class="promo_card-action" data-variant="primary" aria-disabled="true">
    Save
  </button>
</article>
PromoCard.cssCSS
.promo_card[data-featured='true'] {
  border-color: var(--border-focus);
}

.promo_card[data-state='loading'] {
  opacity: var(--opacity-disabled);
}

.promo_card-action[data-variant='primary'] {
  background: var(--action-primary);
}
AttributeUse for
data-stateMutually exclusive lifecycle: idle, loading, error.
data-variantA named visual treatment chosen by the caller.
data-sizeDiscrete size steps.
data-* (boolean-ish)Independent flags, written as "true" or omitted entirely.
aria-*Anything the accessibility tree already models: aria-expanded, aria-disabled, aria-current. Style those directly rather than mirroring them.
05

Modules and file layout

One block, one file, next to the thing that renders it. Five global stylesheets carry every shared responsibility, plus an optional sixth that declares fonts and nothing else. There is no seventh.

src/Text
styles/            listed in import order
  fonts.css       @font-face only — optional
  reset.css       browser normalisation, no classes
  theme.css       foundations, themes, semantics
  layout.css      l_* spatial primitives
  utils.css       u_* zero-state behaviours
  generics.css    g_* global visual objects

components/
  UserCard.tsx    renders .user_card
  UserCard.css    styles .user_card and nothing else

Those five required files are the only place global responsibilities live. A component stylesheet must never redefine a reset, a token, a layout primitive or a generic — if you find yourself wanting to, the thing you want belongs in the global layer, or it is not as global as you think.

fonts.css is the one optional member, and it is optional for a reason: a project on system fonts does not need it, and a project that has fonts should keep @font-face out oftheme.css. Loading an asset and naming a typeface are different jobs —fonts.css declares the files, --typeface-* in theme.css decides what they mean. Import it first, so the faces are known before anything asks for them.

06

The variable radar

A custom property's prefix tells you where it is declared and who is allowed to read it. Numbered tiers are banned; responsibility is the only axis.

PrefixLayerDeclared inReadable by components
--palette-*, --typeface-*Foundationstheme.cssNever
--theme-[context]-*Themestheme.cssNever
--bg-*, --ink-*, --border-*, --action-*, --space-*, --text-*, --font-*, --radius-*, --z-*, --duration-*, --ease-*Semanticstheme.cssYes — this is the public contract
--c-*ComponentLocal CSS, or inline as a propYes, within the owning block
--js-*Dynamic globalsInjected at runtimeYes, read only
UserProfile.tsxTSX
export const UserProfile = ({ themeColor }: Props) => (
  <article class="user_profile" style={{ '--c-card-bg': themeColor } as React.CSSProperties}>
    ...
  </article>
)
07

The colour firewall

Colour passes through four layers, in one direction. This is the rule that makes theming free rather than merely possible.

theme.cssCSS
/* 1. Foundations — raw materials. Never referenced by a component. */
:root {
  --palette-stone-900: oklch(21.6% 0.006 56.043);
  --palette-white: oklch(1 0 0);
}

/* 2. Themes — what each context physically means. Routed, never read. */
:root {
  --theme-light-bg-surface: var(--palette-white);
  --theme-dark-bg-surface: var(--palette-stone-900);
}

/* 3. Semantics — the only layer components may consume. */
[data-theme='light'] {
  --bg-surface: var(--theme-light-bg-surface);
}

[data-theme='dark'] {
  --bg-surface: var(--theme-dark-bg-surface);
}
HolidayPromo.cssCSS
/* 4. Component — genuine exceptions, quarantined locally. */
.holiday_promo {
  --c-magic-bg: oklch(0.55 0.24 300);

  background: var(--c-magic-bg);
  color: var(--ink-inverse);
}

The firewall has three rules and they are worth memorising:

  • Components read Layer 3 only. Reaching into Layers 1 or 2 is always a bug.
  • Semantics point down at the theme switchboard, never sideways at another semantic.--button-bg: var(--bg-surface) creates a token with no theme of its own.
  • Anything reusable enters through theme.css and gets all three layers. Anything genuinely one-off stays local as --c-*.
08

The kernel

Sixteen semantic colours cover a complete interface. Start here, extend only when a real design decision forces it.

GroupTokens
Canvas--bg-page, --bg-surface, --bg-surface-hover, --bg-overlay
Ink--ink-main, --ink-muted, --ink-faint, --ink-inverse
Chrome--border-base, --border-focus
Interactive--action-primary, --action-primary-hover, --action-neutral, --action-neutral-hover, --action-danger, --action-danger-hover

Extensions are bespoke interactive colours the design system genuinely needs — this site adds --action-contrast. Every interactive extension must ship with a matching-hover pair; a colour you can click needs a colour that says you clicked it.

Intents are static status colours, limited to three weights:base, subtle, strong. Enough for a badge, a banner and a chart. Not enough to reinvent the palette.

09

Contextual inverse

Because semantics are pointers, any subtree can be told to resolve against the opposite theme. No component participates.

theme.cssCSS
[data-theme='light'],
[data-theme='dark'] [data-theme='inverse'] {
  /* light pointers */
}

[data-theme='dark'],
:root:not([data-theme='dark']) [data-theme='inverse'],
[data-theme='light'] [data-theme='inverse'] {
  color-scheme: dark;
  /* dark pointers */
}
usageHTML
<section class="cta_section" data-theme="inverse">
  <h2 class="cta_section-heading">Same CSS. Opposite palette.</h2>
</section>
10

Mass and void

Two kinds of number that happen to share a unit. Keeping them apart is what stops a design system from drifting.

KindPropertiesSourceWhy
Voidmargin, padding, gap--space-*, alwaysNegative space is shared rhythm. It must move together across the whole product.
Masswidth, height, inset, translateRaw rem or pxAn object's shape belongs to that object. Borrowing a rhythm token couples it to unrelated changes.
WrongCSS
.avatar {
  width: var(--space-12);
  height: var(--space-12);
}
RightCSS
.avatar {
  width: 3rem;
  height: 3rem;
}

The spacing scale runs on a 4px grid where --space-4 equals 1rem. The number in the token is the grid step, not an arbitrary index, which is why the scale can be extended without renumbering anything.

11

Layout primitives

Six primitives, configured with attributes. Flex and grid utility classes are not part of BEAM.

PrimitiveBehaviourAttributes
.l_stackVertical flexdata-gap, data-align, data-justify
.l_clusterHorizontal wrapping flexdata-gap, data-align, data-justify, data-reverse, data-nowrap
.l_gridTwo-dimensional griddata-cols, data-min, data-layout, data-gap
.l_containerPage bounds and macro paddingdata-size
.l_switcherContainer-query flex, stacked until the thresholddata-threshold, data-gap
.l_spacerFlex-grow spacer
compositionHTML
<div class="l_container" data-size="page">
  <div class="l_stack" data-gap="8">
    <article class="promo_card">...</article>

    <div class="l_switcher" data-threshold="3xl" data-gap="6">
      <div class="promo_card">...</div>
      <aside class="promo_aside">...</aside>
    </div>
  </div>
</div>

.l_switcher is container-query driven, not viewport driven, so a component moved into a sidebar rearranges itself without anyone editing a media query. Thresholds follow the standard container scale from3xs to 7xl.

12

The Binary Rule

A layout class and a component class must never share a DOM element.

IllegalHTML
<div class="l_stack user_card">...</div>
LegalHTML
<div class="l_stack" data-gap="4">
  <div class="user_card">...</div>
</div>

Two classes on one element means two owners for display, gap andmargin, which is a specificity argument waiting to happen. Worse, the component now knows how it is arranged, so it cannot be moved without editing it.

One exception. A block may set position: relative on a direct child.l_container to establish a stacking anchor, provided it does not touch the container's display model.

13

Nesting rules

Native nesting is welcome. Building names with it is not.

NestingVerdict
&:hover, &:focus-visibleAllowed — a condition, not a name
&[data-state='open']Allowed — a condition, not a name
@container, @media inside a ruleAllowed
&-titleBanned — invents a string that exists nowhere
.block .elementBanned — encodes DOM depth
Two or more levels deepBanned — flatten it
14

Fluid interpolation

Point-to-point interpolation between two static tokens, resolved at build time by postcss-beam-fluid.

usageCSS
/* Two tokens, project viewport bounds */
padding: fluid(var(--space-4), var(--space-8));

/* Literals are fine */
font-size: fluid(2rem, 4rem);

/* Per-call bounds: min, max, minViewport, maxViewport */
font-size: fluid(2rem, 8rem, 20rem, 60rem);
RuleReason
Static px or rem onlyem, % and vw depend on context the build cannot see.
One unit per callfluid(16px, 2rem) is a mistake, not a shortcut.
Unresolved tokens fail the buildA silently wrong size is worse than a red build.
Output contains no * or /The slope is computed at build time, so the browser evaluates a two-term sum.

Verify it in CI. If a raw fluid( reaches your output, the plugin never ran:

ciShell
pnpm build && ! rg -q "fluid\(" dist
15

Z-index and motion

Two systems that exist purely so nobody ever types a magic number into a shared file again.

StratumValueFor
--z-sink-1Decorative layers behind content
--z-pinned100Sticky headers and rails
--z-dropdown200Menus, popovers, tooltips
--z-overlay300Modals and scrims
--z-toast400Transient notifications
--z-max9999Skip links and debug affordances

Stack inside a stratum with delta maths — calc(var(--z-overlay) + 1) — so the relationship is visible in the value rather than implied by two numbers being near each other.

Transitions must use --duration-* and--ease-*, because they respond to user input and must obey prefers-reduced-motion centrally.Named animations may use bespoke timings, because choreography is authored, not systematic. Composable chunks like --transition-pressable exist so utilities can share physics safely.

16

Utilities and generics

Two small global layers with sharply different jobs.

Utilities (u_*) are single-purpose and state-free. Zero-state contracts — the ones that strip user-agent styling — are wrapped in :where() so their specificity is zero and a component class always wins without anyone reaching for!important.

utils.cssCSS
:where(.u_reset_button) {
  appearance: none;
  background: transparent;
  border: none;
  padding: 0;
  font: inherit;
  cursor: pointer;
}

Generics (g_*) are real visual objects that are too ubiquitous to deserve a component file: .g_divider, .g_spinner, .g_tag,.g_kbd. They follow every BEAM rule and may hold state.

.g_prose is the one sanctioned exception to the no-descendant-selectors rule. It is an encapsulation zone for HTML you did not write — Markdown output, CMS bodies — where there are no classes to target by design.

17

Review checklist

What to look for in a pull request. Every item is mechanical, which is the point — none of it is a matter of taste.

CheckFails when
Class taxonomyA class is not a block, element, l_, u_ or g_.
NamingA hyphen appears inside an element name, or a selector mirrors DOM depth.
StateA state or variant is expressed as a class instead of an attribute.
Binary RuleAn l_* class shares an element with a block.
Colour firewallA component references a raw colour, a --palette-* or a --theme-*.
Voidmargin, padding or gap uses a raw length.
Masswidth, height or inset uses a --space-* token.
NestingAn ampersand builds a name, or nesting goes deeper than one level.
MotionA transition uses a literal duration or easing curve.
Z-indexA number appears where a stratum token belongs.
Inline stylesThe style attribute sets anything other than a custom property.
BuildA raw fluid( survives into the output.