Principles
Four commitments. Every rule further down is a consequence of one of them.
| Principle | What it means in practice |
|---|---|
| Identity over utility | A 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 optimized | Naming 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 native | Custom properties, native nesting, container queries. No SASS, no LESS, no CSS-in-JS runtime. PostCSS is permitted for build-time math only. |
| State driven | State and variation live in HTML attributes, not in class names. data-* for application state, aria-* where a role already exists. |
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.
| Type | Syntax | Owns | Example |
|---|---|---|---|
| Block | snake_case | Component or page identity | .user_card |
| Element | block-part_name | A dependent part of exactly one block | .user_card-main_title |
| Layout | l_* | Spatial geometry and rhythm only | .l_stack |
| Utility | u_* | Single-purpose, state-free behavior | .u_reset_button |
| Generic | g_* | Global visual objects too small to be components | .g_divider |
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.
.nav_bar { }
.nav_bar-list_item { }
.nav_bar-action_button { } /* 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 { }
} State and variation
Attributes replace BEM modifiers entirely. There are no state classes in BEAM – not one.
<article class="promo_card" data-featured="true" data-state="loading">
<button class="promo_card-action" data-variant="primary" aria-disabled="true">
Save
</button>
</article> .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);
} | Attribute | Use for |
|---|---|
data-state | Mutually exclusive lifecycle: idle, loading, error. |
data-variant | A named visual treatment chosen by the caller. |
data-size | Discrete 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. |
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.
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.
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.
| Prefix | Layer | Declared in | Readable by components |
|---|---|---|---|
--palette-*, --typeface-* | Foundations | theme.css | Never |
--theme-[context]-* | Themes | theme.css | Never |
--bg-*, --ink-*, --border-*, --action-*, --space-*, --text-*, --font-*, --radius-*, --z-*, --duration-*, --ease-* | Semantics | theme.css | Yes – this is the public contract |
--c-* | Component | Local CSS, or inline as a prop | Yes, within the owning block |
--js-* | Dynamic globals | Injected at runtime | Yes, read only |
export const UserProfile = ({ themeColor }: Props) => (
<article class="user_profile" style={{ '--c-card-bg': themeColor } as React.CSSProperties}>
...
</article>
) The color firewall
Color passes through four layers, in one direction. This is the rule that makes theming free rather than merely possible.
/* 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);
} /* 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.cssand gets all three layers. Anything genuinely one-off stays local as--c-*.
The kernel
Sixteen semantic colors cover a complete interface. Start here, extend only when a real design decision forces it.
| Group | Tokens |
|---|---|
| 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.
Contextual inverse
Because semantics are pointers, any subtree can be told to resolve against the opposite theme. No component participates.
[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 */
} <section class="cta_section" data-theme="inverse">
<h2 class="cta_section-heading">Same CSS. Opposite palette.</h2>
</section> Mass and void
Two kinds of number that happen to share a unit. Keeping them apart is what stops a design system from drifting.
| Kind | Properties | Source | Why |
|---|---|---|---|
| Void | margin, padding, gap | --space-*, always | Negative space is shared rhythm. It must move together across the whole product. |
| Mass | width, height, inset, translate | Raw rem or px | An object's shape belongs to that object. Borrowing a rhythm token couples it to unrelated changes. |
.avatar {
width: var(--space-12);
height: var(--space-12);
} .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.
Layout primitives
Six primitives, configured with attributes. Flex and grid utility classes are not part of BEAM.
| Primitive | Behavior | Attributes |
|---|---|---|
.l_stack | Vertical flex | data-gap, data-align, data-justify |
.l_cluster | Horizontal wrapping flex | data-gap, data-align, data-justify, data-reverse, data-nowrap |
.l_grid | Two-dimensional grid | data-cols, data-min, data-layout, data-gap |
.l_container | Page bounds and macro padding | data-size |
.l_switcher | Container-query flex, stacked until the threshold | data-threshold, data-gap |
.l_spacer | Flex-grow spacer | – |
<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.
The Binary Rule
A layout class and a component class must never share a DOM element.
<div class="l_stack user_card">...</div> <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.
Nesting rules
Native nesting is welcome. Building names with it is not.
| Nesting | Verdict |
|---|---|
&:hover, &:focus-visible | Allowed – a condition, not a name |
&[data-state='open'] | Allowed – a condition, not a name |
@container, @media inside a rule | Allowed – conditions, min-width only |
&-title | Banned – invents a string that exists nowhere |
.block .element | Banned – encodes DOM depth |
| Two or more levels deep | Banned – flatten it |
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.
#site_header { }
[id='site_header'] { } .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.
.toolbar {
flex-direction: row;
}
@media (max-width: 47.999rem) {
.toolbar {
flex-direction: column;
}
} .toolbar {
flex-direction: column;
}
@media (min-width: 48rem) {
.toolbar {
flex-direction: row;
}
} | Query | Verdict |
|---|---|
@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 property | Allowed – a size constraint, not a query |
prefers-reduced-motion, hover, pointer | Allowed – not width queries |
Fluid interpolation
Point-to-point interpolation between two static tokens, resolved at build time by @beam-css/postcss-fluid.
/* 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); | Rule | Reason |
|---|---|
Static px or rem only | em, % and vw depend on context the build cannot see. |
| One unit per call | fluid(16px, 2rem) is a mistake, not a shortcut. |
| Unresolved tokens fail the build | A 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:
pnpm build && ! rg -q "fluid\(" dist Z-index and motion
Two systems that exist purely so nobody ever types a magic number into a shared file again.
| Stratum | Value | For |
|---|---|---|
--z-sink | -1 | Decorative layers behind content |
--z-pinned | 100 | Sticky headers and rails |
--z-dropdown | 200 | Menus, popovers, tooltips |
--z-overlay | 300 | Modals and scrims |
--z-toast | 400 | Transient notifications |
--z-max | 9999 | Skip 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.
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.
: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.
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.
| Check | Fails when |
|---|---|
| Class taxonomy | A class is not a block, element, l_, u_ or g_. |
| Naming | A hyphen appears inside an element name, or a selector mirrors DOM depth. |
| State | A state or variant is expressed as a class instead of an attribute. |
| Binary Rule | An l_* class shares an element with a block. |
| Color firewall | A component references a raw color, a --palette-* or a --theme-*. |
| Void | margin, padding or gap uses a raw length. |
| Mass | width, height or inset uses a --space-* token. |
| Nesting | An ampersand builds a name, or nesting goes deeper than one level. |
| IDs | A # selector or [id=…] styles a node. |
| Queries | A @media or @container uses max-width or a ceiling range. |
| Motion | A transition uses a literal duration or easing curve. |
| Z-index | A number appears where a stratum token belongs. |
| Inline styles | The style attribute sets anything other than a custom property. |
| Build | A raw fluid( survives into the output. |