Skip to content
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 optimizedNaming 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 math 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 recognize 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 behavior.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.

Correct CSS
.nav_bar { }
.nav_bar-list_item { }
.nav_bar-action_button { }
Incorrect CSS
/* 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.

markup HTML
<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.css CSS
.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 a sixth that declares fonts and nothing else. Skip that sixth only when the faces come from the system, or are already loaded in the document head. There is no seventh.

src/ Text
styles/            listed in import order
  fonts.css       @font-face only – skip on system fonts, or when faces load in the head
  reset.css       browser normalization, no classes
  theme.css       foundations, themes, semantics
  layout.css      l_* spatial primitives
  utils.css       u_* zero-state behaviors
  generics.css    g_* global visual objects

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

Those five files – reset, theme, layout, utils, generics – are the only place shared visual responsibility lives. 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 sixth file, and it is skipped only when there is nothing for it to declare: a project on system fonts, or one that already loads faces outside CSS – a Google Fonts <link> in the document head, or a bundler import in an SPA. A project that self-hosts must keep @font-face out of theme.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.tsx TSX
export const UserProfile = ({ themeColor }: Props) => (
  <article class="user_profile" style={{ '--c-card-bg': themeColor } as React.CSSProperties}>
    ...
  </article>
)
07

The color firewall

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

theme.css CSS
/* 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.css CSS
/* 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 memorizing:

  • 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 colors 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 colors the design system genuinely needs – this site adds --action-contrast. Every interactive extension must ship with a matching -hover pair; a color you can click needs a color that says you clicked it.

Intents are static status colors, 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.css CSS
[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 */
}
usage HTML
<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.
Wrong CSS
.avatar {
  width: var(--space-12);
  height: var(--space-12);
}
Right CSS
.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.

PrimitiveBehaviorAttributes
.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
composition HTML
<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 from 3xs to 7xl.

12

The Binary Rule

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

Illegal HTML
<div class="l_stack user_card">...</div>
Legal HTML
<div class="l_stack" data-gap="4">
  <div class="user_card">...</div>
</div>

Two classes on one element means two owners for display, gap and margin, 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 – conditions, min-width only
&-titleBanned – invents a string that exists nowhere
.block .elementBanned – encodes DOM depth
Two or more levels deepBanned – flatten it
14

Selectors and queries

Classes are the only identity CSS may use. The cascade only ever adds as the canvas grows.

Never select by ID. An ID selector is a second identity system with specificity 1,0,0 – no class combination can override it without !important or another ID, and the name cannot be reused. HTML id remains legal for skip links, fragment URLs and JavaScript. Do not style those nodes by ID; give them a class. :target is a condition, not an ID selector, and stays legal.

Incorrect CSS
#site_header { }
[id='site_header'] { }
Correct CSS
.site_header { }

Width queries are mobile-first. The unqueried rule is the small canvas. Every @media and @container query adds as the canvas grows, which means min-width only. max-width queries – and ceiling ranges like width < 64rem – invert the cascade: you authored the large canvas and then undid it, so the small-screen stylesheet is a pile of exceptions and the 63.999rem off-by-one is waiting for you.

Incorrect – then undo CSS
.toolbar {
  flex-direction: row;
}

@media (max-width: 47.999rem) {
  .toolbar {
    flex-direction: column;
  }
}
Correct – then add CSS
.toolbar {
  flex-direction: column;
}

@media (min-width: 48rem) {
  .toolbar {
    flex-direction: row;
  }
}
QueryVerdict
@media (min-width: 48rem)Allowed – the canvas grew
@container (min-width: 36rem)Allowed – same rule, local canvas
@media (width >= 48rem)Allowed – a floor range is still min-width
@media (max-width: 47.999rem)Banned – the large canvas as default
@container (width < 36rem)Banned – a ceiling range wearing other clothes
max-width: 32rem as a propertyAllowed – a size constraint, not a query
prefers-reduced-motion, hover, pointerAllowed – not width queries
15

Fluid interpolation

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

usage CSS
/* 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:

ci Shell
pnpm build && ! rg -q "fluid\(" dist
16

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 math – 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.

17

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.css CSS
: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.

18

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.
Color firewallA component references a raw color, 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.
IDsA # selector or [id=…] styles a node.
QueriesA @media or @container uses max-width or a ceiling range.
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.