Brik Design System
Foundation

Naming Conventions

The vocabulary that keeps the system tight — title vs heading, label family, action vs button-label, axes, and the no-unclassed-wrappers rule.

The words we use have to be tight or the system drifts. This page names the pieces of a BDS component and the rules that govern each word. Every term here is load-bearing — it picks a token scale, picks a BEM class, or picks an HTML element. Reach for this list before you invent a name.

The principle — intent, not shape

Every word below describes a role or a slot. It does not dictate the visual shape and, critically, it does not dictate the HTML element. The HTML element is chosen by semantic intent at the call site — is this thing part of the document outline? Is it interactive? Is it prose? The BEM class names the role; semantics name the element.

This distinction is especially load-bearing for titles and headings (below) — get it wrong and you render outline-invisible content that looks correct visually.

Closed allowlist — single source of truth

Per ADR-008, the system runs on a closed allowlist of slot names. The allowlist lives in docs/SLOT-ALLOWLIST.md — a single file, organized by category, that enumerates every legitimate __slot suffix.

The rule

Every bds-{block}__{slot} class uses a slot name that appears in SLOT-ALLOWLIST.md. Anything else fails the lint.

The sections below (Title, Subtitle, Description, the label family, …) explain what each canonical slot means and when to use it. They are reference; the allowlist is the contract. If the allowlist and these sections disagree, the allowlist wins — open a PR to align the prose.

Why closed

The old open-banlist canon enumerated banned terms; agents invented new ones (__lead, __quote-text, __plan-name, __hamburger-bar) faster than the banlist grew. Closing the canon inverts the burden: the default is reject, new vocabulary requires a deliberate PR. See ADR-008 for the full reasoning and the 2026-05-11 inventory data that prompted the change.

Adding a slot

  1. Identify the role the slot expresses; pick the most generic word that fits.
  2. Grep the codebase for existing allowlist slots that already cover the role — reuse before invent.
  3. If no existing slot fits, PR an entry into SLOT-ALLOWLIST.md with justification.
  4. Reviewer checks: would this slot work on more than one block? Is it generic (not layout-specific)? Is it structural (not visual)?

Composition layers

A page in BDS is composed in five conceptual layers. Each layer has a single responsibility, a single vocabulary, and only knows about the layer below it.

The layered model is pedagogical — it explains how to think about a page — and is intentionally non-binding on the BEM canon. Implementation continues to follow ADR-008 (one block per blueprint family with structural modifiers, closed slot allowlist); the layers below describe how to reason about what those blocks express, not a new naming taxonomy.

The five layers

LayerResponsibilityVocabularyExamples
SectionA page role with surrounding structure (vertical rhythm, container, background surface).Page-level semanticHero, Content, CTA
LayoutPure composition primitive — how children are arranged. No styling beyond structure.CompositionStack, Cluster, Grid, Split, Row
Container (Display)A styled holder that composes blocks into a self-contained unit. Carries border / padding / elevation / radius.Bounded unitCard, List, Form, Accordion, Tabs
BlockA composed content unit — a fixed slot shape filled with atoms. Reused both standalone and inside containers.Slot + atomsContentBlock, MediaBlock, ListItem, FormField, AccordionItem, Stat
ComponentA single primitive atom.One primitiveButton, Input, Image, Badge, Icon, Heading

Where each layer lives in the codebase

The layer model is conceptual; the directory layout follows ADR-008's primitive-vs-blueprint split.

LayerDirectoryNotes
Sectioncontent-system/blueprints/{react,astro}/The blueprint families per ADR-008 (bds-hero, bds-cta, bds-services, bds-features, bds-about, bds-support-plan) play the Section role.
Layoutcomponents/ui/Layout primitives (Stack, Cluster, Grid, Split, Row) ship as components; the "Layout" label is conceptual, not a separate directory.
Containercomponents/ui/Card, Accordion, List, etc. — alongside other primitives.
Blockcomponents/ui/Field, CardSummary, etc. that fill container slots.
Componentcomponents/ui/The atomic primitives.

Decision rule — block vs container

When you're not sure whether a new pattern is a block or a container, ask:

If the thing is described primarily by how its children are styled and bounded (border, elevation, padding, max-width), it's a container. If it's described by what slots it offers and which atoms fill them (heading, body, kicker, action), it's a block.

Card passes "styled and bounded" → container. ContentBlock passes "slots and atoms" → block. The same test resolves new patterns as the system grows.

Card is a styled container; orientation comes from its layout child

A Card encodes only its container styling (border, radius, padding, elevation). Orientation — stacked vs horizontal vs grid — comes from the layout primitive placed inside it, not from a Card variant.

// ✅ Stacked card — Stack layout inside Card
<Card>
  <Stack>
    <MediaBlock />
    <ContentBlock />
  </Stack>
</Card>

// ✅ Horizontal card — Split layout inside Card
<Card>
  <Split>
    <MediaBlock />
    <ContentBlock />
  </Split>
</Card>

// ❌ Don't bake orientation into Card
<Card variant="horizontal">...</Card>
<Card layout="stacked">...</Card>

The same principle keeps you from inventing CardImageLeft, CardStacked, CardHorizontal, CardImageRight — these are all "Card + a layout child," not separate components.

Stat is a block

Stat (value + label, two slots, no border styling of its own) is a block, not a container. The familiar "stat tile" look — a metric in a bordered box — is a Card containing a Stat:

// Bordered stat tile — Card (container) holds Stat (block)
<Card>
  <Stat value="75%" label="of website credibility comes from design" />
</Card>

// Card with content + metric
<Card>
  <ContentBlock title="First impressions" body="..." />
  <Stat value="0.05s" label="to make a first impression" />
</Card>

// Vertical list of stats with no container
<Stack>
  <Stat value="75%" label="..." />
  <Stat value="42%" label="..." />
  <Stat value="50%" label="..." />
</Stack>

If you find yourself wanting a StatCard component, you're conflating block and container layers — write it as a Card containing a Stat instead.

Title vs heading

title and heading refer to the same typographic role at different layers. They are not substitutes for each other.

LayerWordExample
Typography tokens (scale)heading--heading-md, --font-family-heading
BEM (role/position)title.bds-page-header__title, .bds-data-section__title, .bds-card-display__title
HTML elementh1h3, or div/pChosen by outline position, not by class name

The rule

Name the role __title in BEM. Scale its typography with --heading-* tokens. Pick the HTML element at the call site based on whether it is a document outline node.

When a title IS an outline node (most cases on a page — <DataSection title="Identity"> inside the Overview tab is an outline sibling of the page's <h1>) → render as <h2> (or <h3> when nested). Screen readers walk these.

When a title is NOT an outline node (decorative card in a grid of many cards, metric tile, repeating marker) → render as <div> or <p>. It still uses __title BEM and heading-tier tokens.

Never pick the HTML tag by guessing from the BEM name. <div class="bds-card__title"> and <h3 class="bds-card__title"> are both valid — choose by outline intent.

Banned title aliases

Translate these synonyms to __title before writing code — same discipline as the eyebrowsubtitle rule below.

BannedUse instead
__heading__titleheading is a token scale, not a BEM role
__headline__title
__header (as text)__title for the text; __header is acceptable only as a container slot (e.g. a <header> element holding title + actions)
__hero-title, __page-heading__title — the parent BEM block already says "hero" or "page"

Legacy exception — sheet-section__heading

.bds-sheet-section__heading predates this rule. It renders an uppercase label <h3> — a different visual role from a page/section title, even though the BEM name reads like a synonym. Do not generalize from this exception. New BEM names always use __title for the titled slot. The legacy class will be renamed under a separate PR; no consumer should rely on the __heading form.

Subtitle

The secondary line paired with a title. Always subtitle. Never eyebrow.

  • BEM: .bds-*__subtitle
  • Usage: PageHeader, Sheet, SheetSection, BoardCard, DataSection.
  • Rendered as: <p> by default.

"Eyebrow" is a common term in marketing-site design systems. It is not a term in BDS. Same applies to kicker, overline, and pre-title — all translate to subtitle. If a design spec uses one of these, translate before writing code.

Description

Explanatory prose under a title or subtitle — longer and more neutral than a subtitle.

  • BEM: .bds-*__description
  • Usage: Form, Banner, SheetSection.
  • Rendered as: <p>.

Use when a component needs a short paragraph of context below its title. Not a legal equivalent to subtitle — subtitle is visual chrome, description is content.

Never __body

body is a token scale (--body-md, --font-family-body) — not a BEM role. Long prose under a title uses __description. Labeling a slot __body because the typography scale is body-md is the same category mistake as labeling a title slot __heading because the scale is --heading-md.

Pull-quotes, callouts, and banners — components, not BEM

When a design has a quoted testimonial, an inline alert, or a highlighted aside, the slot is a component, not a new BEM name. Reach for the existing primitive before inventing __callout / __pull-quote / __aside.

Design elementBDS componentWhat you'd be inventing if you skipped it
Pull-quote with attributionCardTestimonial<aside><blockquote><cite> with bespoke __quote / __quote-cite / __quote-text BEM
Inline alert / status calloutBanner tone="warning|error|information"__alert / __notice BEM
Decorative full-bleed calloutBanner__banner / __callout-strip BEM
Highlighted plan or pricing tileCard, PricingCard__plan-callout / __pricing-aside BEM
Aspect-locked imageFrame (customRatio or preset)hardcoded aspect-ratio in CSS + bare <img>

There is no Callout component in BDS. If a design spec says "callout," translate to one of the rows above before writing markup.

The label family

label is the umbrella suffix for any text that names or identifies a discrete thing. When you're adding a new named text element, reach for a -label name before inventing a term.

TermWhat it namesCurrent implementation
field-labelThe name on a data pair (<Field label="Business Name">).bds-field__label, .bds-sheet-field-label
card-summary__labelLabel on a metric or stat.bds-card-summary__label
chip__labelText inside a Chip.bds-chip__label
meter__label, progress-stepper__labelLabel for progress / measurement markersexisting BEM
button-labelThe text on a <Button>passed as children today — concept-only
tab-labelThe text on a <TabBar> tablabel: string prop on Sheet tabs
paragraph-labelScoped label above a paragraph or bullet list (e.g. "Care Philosophy")not a separate component — use <Field> with a paragraph or <BulletList> as its value

Rules

  • New named text element? Use a -label term before inventing one.
  • button-label and tab-label are concepts, not classes. Don't rush to add label props to <Button> or <TabBar>; children is fine. Name them in specs and docs.
  • paragraph-label describes a usage of Field (label + paragraph or list value). There is no ParagraphField or ListFieldField accepts any ReactNode as its value and covers both cases.

Action vs button-label (different layers)

One category mistake we avoid: calling a button "an action."

  • action / actions — the slot inside a component that holds one or more buttons. .bds-page-header__actions, .bds-card-display__action, .bds-data-section__actions. It is a <div> that receives a <Button> or <ButtonGroup>.
  • button-label — the text on each button inside that slot. Passed as children today.

The slot is not the text. Don't conflate them. When documenting a section header with a [View] / [Edit] toggle:

  • The slot holding the toggle is actions.
  • The component rendering the two buttons is ButtonGroup.
  • The text on each button is a button-label.

Value

The datum paired with a label.

  • BEM: .bds-*__value (e.g. .bds-field__value, .bds-card-summary__value).
  • Rendered as: <div> or <span> depending on whether the value is block-level content (paragraph, list) or inline text.

Field handles the inline vs block distinction internally — put any ReactNode as children. CardSummary renders values as heading-tier typography (it's a stat tile, not a form field) — don't confuse the two.

Section vs card vs board

These are containers, not text. Different words because they have different responsibilities. See Composition layers § Card is a styled container for the model these names sit inside; this section is the implementation-level reference for which container to reach for.

ContainerPurposeWhen
DataSectionTitled block of read-mode data on a pageOverview / profile tabs, client-detail pages
SheetSectionTitled block inside a sheet body (uppercase label heading)Any block grouping inside <Sheet>
Card (and variants — CardSummary, CardControl, CardTestimonial, CollapsibleCard, PricingCard)Bordered, self-contained unit of contentGrids of comparable items, dashboards, marketing
Board (BoardColumn, BoardCard)Kanban-style containerTask boards
Dialog / Modal / SheetOverlay containersFocused interactions

Don't reach for a Card when a DataSection is right. Cards are self-contained units in a grid; DataSection is one region of a larger page.

Blueprints — composition, not redefinition

Blueprints (content-system/blueprints/*) are page-level layouts assembled from BDS primitives — the Section layer of the composition model. Per ADR-008, blueprints use the same bds- namespace as primitives — the bp- prefix is deprecated.

The rules

  1. Single namespace. Every class uses bds-. The bp- prefix is deprecated; no new bp-* classes ship after ADR-008. Existing bp-* classes migrate during Phase D blueprint consolidation (one family per PR).
  2. One block per blueprint family. bds-hero, bds-cta, bds-services, bds-features, bds-about, bds-support-plan. Each family is one block with structural modifiers (bds-hero--split-image), not N separate blueprints.
  3. Slot names come from the closed allowlist. Every bds-{block}__{slot} must use a slot listed in docs/SLOT-ALLOWLIST.md. Anything else fails the lint (Phase C). To add a new slot, edit the allowlist file in its own PR.
  4. Structural-only modifiers. Modifier names describe structure (--split-image, --two-column, --with-pricing-card) — never appearance (--dark, --centered) or theme (--inverse, --light). A "dark centered" CTA themed light makes the name a lie; that's the failure mode the rule prevents.
  5. Compose, don't reimplement. If a blueprint draws raw <blockquote> / <img> / <aside> markup that duplicates a primitive, import the primitive instead. A blueprint .tsx that imports zero from components/ui/* is almost always drift.
  6. Use Frame for any aspect-locked image. Never hardcode aspect-ratio in blueprint CSS — pass the ratio to <Frame customRatio="..." /> or use a named preset (square, portrait, landscape, wide, ultrawide).
  7. Variant + appearance values come from the component's declared type. Pass values that exist on the component's prop union (e.g. ButtonVariant, ServiceTagVariant). The hierarchy axis in Axesvariant ∈ {primary, secondary} — applies where canon explicitly enumerates it (Chip base case); individual components legitimately extend their variant union beyond hierarchy (Button has outline, ghost, inverse, danger, etc.; ServiceTag has text, icon, icon-text). Don't invent values the component doesn't declare; TypeScript is the authoritative gate.

Migration status

StatusWhat
Locked in canonRules 1–7 above (this section)
Allowlistdocs/SLOT-ALLOWLIST.md — canonical slot list
Lintscripts/lint-blueprint-naming.mjs runs in pre-commit on staged blueprint files. Phase C flips it to allowlist mode (fails on any slot not in the allowlist).
CodeExisting blueprints still ship bp-* classes — being migrated Phase D, one family per PR (hero → cta → services → features → about → support-plan).
Consumersbp-* overrides in consumer repos audited + migrated Phase E, coordinated with Phase D rollout.
bp- deletionAfter all consumers migrate, the deprecated prefix is dropped — Phase F. Until then, both prefixes coexist in the codebase.

Stable ids and aria-labelledby

id values are part of the accessible name — they belong to the role of the element, not the layout that contains it. A title id baked with the layout name (bp-about-story-split-default-h) leaks layout into a11y plumbing and breaks when the layout is renamed.

The rule

Generate id from the BEM role plus a content-derived stable key — never the layout/blueprint name, and never a shape-suffix like -h.

Good:

<h2 id={`title-${section.sectionKey}`}>...</h2>
<h2 id={`${section.sectionKey}-title`}>...</h2>
<h2 id={useId()}>...</h2>   {/* when the role is unambiguous on the page */}

Bad:

<h2 id={`bp-about-story-split-${section.sectionKey}-h`}>...</h2>
{/* bakes both the layout name and a `-h` shape suffix that tells the reader nothing about role */}

Elements that render <h*> vs elements that don't

Quick reference. HTML element shown first — it is the load-bearing fact.

ElementTypical useBEM role
<h1>Page title (one per page).bds-page-header__title
<h2>Section title on a page.bds-data-section__title
<h3>Sheet section heading (uppercase label).bds-sheet-section__heading — different role, different style
<p>Subtitle, description, field-label (when standalone), body prose.bds-*__subtitle, .bds-*__description
<span>Inline label (e.g. metadata pair inside a page header).bds-*__label when inline
<label>Form-control label when htmlFor is set.bds-sheet-field-label for form inputs
<div>Slots, containers, wrappers__actions, __content, __header, __titles

Screen readers walk <h*> elements to build the outline. Every <h*> decision is an a11y decision. Don't reach for <h3> because it "looks right" — use outline position.

Axes — shared prop vocabulary across components

Components with overlapping visual concerns share the same prop names and values. A prop is an axis when more than one component exposes it — the axis names belong to the system, not any single component.

AxisValuesMeaningComponents
sizexs · sm · md · lg (indicators); tiny · sm · md · lg · xl (buttons)Physical dimensions on a shared scale. Values differ by surface — see coverage matrix.Badge, Tag, Chip, Button, IconButton, ServiceTag, Field, Sheet, most form controls
statuspositive · warning · error · info · progress · brandSemantic value judgment — which system color tier applies. Indicator-only.Badge, Banner, Dot
variantcomponent-specific — see coverage matrixShared prop name; not a shared value set. For Chip it expresses hierarchy (primary · secondary); for Button it covers style intent (12 values); for ServiceTag it expresses layout (text · icon-text · icon). TypeScript is the gate — never pass a value the component's declared union doesn't include.Chip, Button, IconButton, LinkButton, Card, ServiceTag
appearance (fill)solid · subtle · outlineHow the shape is filled vs. bordered. Each component supports the subset that has a real visual meaning. Read-only/indicator components only.Badge (solid / subtle), Tag (solid / subtle)

The rule

appearance describes fill. variant is the shared prop name — its meaning varies by component. For Chip it expresses hierarchy (primary · secondary). For Button it expresses style intent (12 values). Never cross the indicator / interactive boundary: interactive components (Chip, Button) use variant only; read-only indicators (Badge, Tag) use appearance — they have no hierarchy to express, so fill form is the only lever. Check the coverage matrix, not this rule summary, for the valid values for any given component.

Why solid | subtle | outline (not dark | light)

The older dark | light vocabulary was ambiguous with dark-mode theming — --dark could mean either "saturated fill" or "variant used on dark themes." solid | subtle | outline names the fill form explicitly and never collides with the theme axis:

  • solid — filled background, typically saturated or neutral.
  • subtle — filled with a pastel or tinted background, lower emphasis.
  • outline — transparent background, visible border.

Axis coverage matrix

The machine-readable source of truth for every component's variant, appearance, size, and status values is manifest/component-axes.json — auto-generated from exported TypeScript types via npm run typegen:axes (see ADR-009). The table below is derived from that manifest; when they disagree, the manifest wins.

Componentvariant valuesappearancesize valuesstatusSource
Buttonprimary · outline · secondary · ghost · inverse · on-color · danger · danger-outline · danger-ghost · destructive · positive · selectedtiny · sm · md · lg · xlButton.tsx
LinkButtonshares ButtonVariant (all 12)shares ButtonSizeLinkButton.tsx
IconButtonprimary · outline · secondary · ghost (default) · inverse · danger · danger-outline · danger-ghost · destructive · positive · selectedshares ButtonSizeIconButton.tsx
Chipprimary · secondarysm · md · lgChip.tsx
Cardoutlined (default) · brand · elevatedCard.tsx
ServiceTagtext (default) · icon-text · iconsm · md · lgServiceTag.tsx
Tagsolid (default) · subtlexs · sm · md · lgTag.tsx
Badgesolid (default) · subtlexs · sm · md · lgpositive · warning · error · info · progress · brandBadge.tsx

Notes:

  • Chip appearance was removed in v0.66 — Chip is interactive, emphasis lives on variant only.
  • IconButton variant is an inline union (not re-exported as a named type); it aligns with ButtonVariant minus on-color.
  • Card variant encodes the border/elevation style of the container — not a hierarchy or fill axis. CardPreset (control · summary · display) governs layout configuration and is orthogonal to variant.
  • Badge outline appearance was excluded — low contrast for status indicators.

Adding a new pill component? Reuse the axis names above. Don't invent kind, style, flavor, etc. — the axes exist so a reader knows what to expect.

Token name anatomy

CSS custom properties in BDS follow one of three formulas depending on what kind of token they are. The token kind determines which formula applies, not the property family (--text-* vs --surface-*).

Three token kinds

KindPurposeFormulaExamples
PrimitiveA raw palette value. The atomic source for everything else.--<foundation>-<palette>-<shade>--color-grayscale-white, --services--green-dark, --space-md
IntentA semantic role. The default vocabulary for a generic theme.--<property>-<role>[-<modifier>][-<state>]--text-primary, --surface-brand-primary, --surface-brand-primary-hover
Scoped intentA themed semantic — intent within a named scope (service line, industry, etc.).--<property>-<scope>-<scope-value>[-<modifier>][-<state>]--text-service-marketing, --surface-service-marketing, --text-service-marketing-on-dark

Token files emit all three kinds. Most consumer code reaches for intent by default; scoped intent is for cases where a named scope (a service line, an industry pack, a partner-branded surface) overrides the default.

The two parallel formulas

Intent and scoped-intent tokens use the same formula shape, with a scope segment + scope-value segment inserted for scoped intent:

Intent:        --<property>-<role>[-<modifier>][-<state>]
               --text-primary
               --surface-brand-primary-hover
               --text-muted

Scoped intent: --<property>-<scope>-<scope-value>[-<modifier>][-<state>]
               --text-service-marketing
               --surface-service-marketing-on-dark
               --text-service-marketing-on-light

Two consequences:

  1. Required segments are property + role (intent) or property + scope + scope-value (scoped intent). Everything in [ ] is optional and added only when needed.
  2. scope names a category of theming (service, industry, season); scope-value names the instance (marketing, healthcare, q4). Scoped tokens always carry both segments so future scopes don't collide (service-marketingindustry-marketing).

Modifier order — context replaces hierarchy

Tokens carry up to two axes of modifier — hierarchy and context — but scoped tokens never stack them.

  • Hierarchy (-primary, -secondary, -muted, -brand-primary) answers "how much attention does this command?"
  • Context (-on-dark, -on-light) answers "what surface is this sitting on?"

For generic intent tokens, the two are separate scales: --text-primary / --text-secondary / --text-muted are one family; --text-on-color-dark / --text-on-color-light are another. They don't combine in canon — you reach for one or the other.

For scoped tokens, when a context variant is needed, the (text, surface) pairing carries emphasis implicitly — there's no need to also express hierarchy:

✅ --text-service-marketing                  paired with marketing surface; emphasis implicit
✅ --text-service-marketing-on-dark          context only
❌ --text-service-marketing-primary-on-dark  stacks hierarchy + context — don't
❌ --text-service-marketing-secondary        preemptive hierarchy — see next section

If a scoped token needs to express both hierarchy and context, you've usually conflated two concerns. Reach for a generic intent token (--text-muted, --text-secondary) for the lower-emphasis cases — they work across themed surfaces.

When to add -primary / -secondary (and when not to)

Never preemptively. Add hierarchy modifiers only when a second emphasis level exists in the design, distinct from generic --text-secondary / --text-muted.

This is the single-level token set rule. If --text-service-marketing is the only marketing-tinted text token, don't suffix it -primary. Adding -primary implies a -secondary exists; if it doesn't, the modifier is a lie and a future contributor will invent --text-service-marketing-secondary to fill the gap.

The same rule applies to surfaces: --surface-service-marketing is the only marketing surface today — no -primary suffix. Add -secondary only when a deliberately designed alternate marketing surface exists.

Why scoped variants drop -color-

Generic intent has --text-on-color-dark / --text-on-color-light. The -color- segment disambiguates "text used on a colored surface" from default text used on the neutral default surface.

Scoped intent has --text-service-marketing-on-dark / --text-service-marketing-on-lightno -color-. The service-marketing segment already implies a colored context (you're inside a service-line theme), so -color- becomes redundant.

✅ --text-on-color-dark                    generic — needs -color- to disambiguate
✅ --text-service-marketing-on-dark        scoped — service-marketing implies color
❌ --text-service-marketing-on-color-dark  redundant

This keeps scoped names short and reinforces that scopes always operate on colored surfaces.

This section codifies the formula. The canonical token implementations are emitted by Style Dictionary from the design-tokens source — see Design Tokens overview and the brik-bds reconciliation issue #563 for the current rollout state of the service-line token set.

Elements — no unclassed wrappers

The element / BEM table above lists which HTML elements each role maps to. The implicit rule is that every element in a BDS component tree carries a role name — either a bds-* class (when the element belongs to the system) or a data-* attribute (when the consumer needs to override something). A <div> with no class and no role is drift: it tells the next reader nothing about why the element exists.

The rule

Every element in a BDS component tree names its role. Bare <div> with no class, no role, no reason to exist is drift — replace it with the correct BEM slot (__content, __actions, __header, etc.) or remove it.

Acceptable unclassed elements:

  • Short-lived rendering helpers inside a Storybook story file (they don't ship).
  • Children passed in by the consumer ({children}) — the consumer owns those names.
  • Fragments (<>...</>) — no wrapper exists.

Not acceptable:

  • <div> wrapping a component's output because "it needs a flex container" — name the slot.
  • <span> around an icon + label pair — use __icon / __label.
  • <div className={classes}> where classes is empty or conditionally empty — ship the class or don't ship the wrapper.

This is a discipline rule, not a build-time lint (yet). If a wrapper is ambiguous about its role, add a BEM class with the role name before you add any CSS.

Tag vs Badge — nouns vs verbs

A common confusion: when to use a Tag component vs a Badge. The distinction is what the element labels, not how it looks.

ElementShapeToken sourcePurposeExamples
TagSoft rounded (--border-radius-sm)Service-line / domain tokens (--background-service-marketing, etc.)Nouns — categories, labels, classifications"Clinical", "Front Desk", "OSHA", "Marketing"
BadgePill (--border-radius-pill)Status tokens (--background-positive, --background-warning, etc.)Verbs / states — statuses, outcomes, transient states"Active", "New", "Completed", "Pending"

The grammatical test resolves edge cases: if the label answers what kind is this thing? → Tag. If it answers what state is this thing in? → Badge.

Don't mix the token sources. A Tag that uses status tokens (--background-positive) reads as a state when it should read as a category. A Badge that uses service-line tokens (--background-service-marketing) reads as a category when it should read as a state. The wrong combination obscures meaning even if the colors "look fine."

On this page