-
Notifications
You must be signed in to change notification settings - Fork 3
Building a Component
This is the practical guide to building a new component fast so that it matches the design system without you having to invent anything. It's derived from how the existing components actually use the utilities — follow it and your component will look like it belongs.
Prerequisite reading: For Developers (the two-layer model and the golden rule) and Design Tokens and Utility Classes (the c-*/t-*/s-* reference).
The one rule under everything below: express the role, not the raw value. Pick a spacing step, a color role, and a type ramp slot — never a hex, a pixel, or a font size. That's what makes dark mode, brand changes, and responsiveness work for free.
Each component is a Drupal Single Directory Component — a folder under components/ containing up to four files:
components/umd-libraries-<name>/
umd-libraries-<name>.component.yml ← schema: name, variants, props, slots
umd-libraries-<name>.twig ← markup; applies utility classes
umd-libraries-<name>.css ← structural layout only
umd-libraries-<name>.js ← behavior (optional)
The .component.yml declares:
-
name/labelandgroup: UMD Libraries -
variants— design variations selected in the Layout Builder (e.g. the card'sstandard/overlay/icon) -
props— typed configuration, each with anenumof allowed values and adefault -
slots— content regions filled by the editor
A minimal schema:
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: UMD Lib Example
label: Example
group: UMD Libraries
props:
type: object
properties:
heading_level:
type: string
title: Heading Level
enum: [h2, h3, h4, h5, h6]
default: h3
componentid:
type: string
title: Component ID
slots:
example_title:
title: Title (Text String)
example_body:
title: Body-
Namespace + component + BEM element. The root element gets
umd-libplus the component name (class="umd-lib card"); internal parts use BEM-stylecomponent--element(card--content,card--title,card--eyebrow). These are the hooks your component's own CSS targets for layout. -
Utility classes carry the design tokens — applied right alongside the BEM classes in the same
class=""attribute. Read each class list as: BEM = what it is, utilities = how it looks (t-*type,c-*color,s-*spacing).
A component only ever answers four spacing questions. Each maps to exactly one utility family — never use raw margin/padding in component CSS for these.
| Job | Family | Go-to value |
|---|---|---|
| 1. Gap below the whole component | s-margin-general-* |
s-margin-general-medium |
| 2. Padding inside a filled/bordered surface | s-box-{size}-{h|v} |
s-box-medium-v + s-box-medium-h
|
| 3. Gap between stacked child elements | s-stack-* |
s-stack-small |
| 4. Gap between inline siblings | s-inline-* |
usually flex gap in CSS (see rule 4) |
Rule 1 — Component shell. Put s-margin-general-medium on the umd-lib <name> root so stacked components breathe. Gate it behind a bottom_margin prop so the last component in a region can drop it:
<div class="umd-lib card {% if bottom_margin %}s-margin-general-medium{% endif %}">Rule 2 — Inside padding, for filled surfaces only. If the component has a background or border (card content, accordion child, table cell, callout), pad both axes with medium: s-box-medium-v s-box-medium-h. A component with no surface (a plain link, a heading, a bare list) needs no box padding — it just stacks. Don't default to s-box-small-h (that's for the error chip and small badges), and reserve large for hero-scale surfaces.
Exception — decorative chips/pills. The
s-box-*utilities are responsive (they bump up at 768px), which is right for content but wrong for a tiny decoration that should stay the same size everywhere. Give a chip a fixedpadding: var(--space-xs)in its component CSS instead. This is a legitimate "the utilities can't express it" case — but still no raw px: use thevar(--space-xs)token.
Rule 3 — Vertical rhythm. s-stack-small is the default — eyebrow, headline, title, and body all use it. The size is chosen by what's being separated, not the element type:
-
s-stack-small— gap between adjacent lines within one block (reach for it first). -
s-stack-medium— gap between distinct logical groups/sections ("a new section starts here"). -
s-stack-large— rare; very large compositional gaps only. - Put the class on the element that needs space below it. The last child in a block gets no stack class, so the box padding controls the bottom edge.
Rule 4 — Horizontal rhythm. Prefer display: flex; gap: var(--space-*) in the component CSS. Use s-inline-medium only for a one-off margin-right on a sibling that isn't in a flex container.
Bonus — standalone headings. A heading not inside a padded surface uses s-margin-heading-medium for balanced top + bottom margin.
You choose what the element is for; the system decides the value and flips it for dark mode.
-
Text = a
c-content-*role. Defaultc-content-primary; drop tosecondaryfor body,tertiaryfor meta. -
A surface gets a
c-bg-*role, and its text gets the matching role — chosen together.c-bg-secondaryis the default panel. -
On a dark surface, EVERY text element carries its own explicit
c-content-dark-*class — heading, eyebrow, body, caption, all of them. Do not rely on color inheriting from the dark container; state the dark role on each element so the intent survives refactors. (c-content-dark-primaryfor primary text,c-content-dark-secondaryfor muted copy.) -
Anything clickable/branded uses the interactive pair. CTAs and the validation error chip are always
c-content-interactive-primary c-bg-interactive-primary(they travel together 100% of the time). For links, combine a text role with an underline:c-content-primary c-underline-primary ani-underline.
Never write a hex or color: literal in component CSS for these roles; if CSS must set a color (a border, say), use the matching var(--token) so dark mode still flips it.
| Role | Class |
|---|---|
| Eyebrow / kicker | t-eyebrow |
| Card / section title |
t-title-medium (default); t-title-small tighter, t-title-large bigger |
| Page h1 |
t-headline — page-level only, never inside a component
|
| In-component display (a big number) | t-display |
| Body / content text |
t-body-small (default); t-body-medium when larger |
| Dates, captions, meta | t-label |
| Links / buttons | t-interactive |
Set body type on the wrapper — t-* cascades to children, so t-body-small on a content container styles all the prose inside it (this is also how .wysiwyg-editor re-applies the ramp to author HTML). Add t-bold / t-italic only as emphasis on top of a slot.
-
No
border-radius. Ever. The brand is square-cornered;base.csshas zero radius declarations. Don't round buttons, cards, chips, or images. -
Buttons are a ready-made base class — never rebuild them. Apply
class="umd-lib button"(primary) orclass="umd-lib button secondary"(outlined). Do not compose a button from utilities, and do not create a button component. See CTA Button.
Component *.css files contain structural layout only: flexbox/grid, flex-direction, gap, alignment, positioning (the full-card-link ::after overlay trick), aspect-ratio and object-fit for media, -webkit-line-clamp truncation, and any structural reflow at 768/1024/1440px. When CSS must set a color or spacing value, use the var(--token) primitive, never a literal — e.g. border-left: 0.5rem solid var(--maryland-red).
The only intentional hex literals in the whole component layer are a few hero/hero-search colors that sit on a fixed rgba(0,0,0,0.6) image overlay — they're theme-independent on purpose, so a flipping token would break their legibility in dark mode.
Every component's Twig has a fallback branch that renders missing required-field errors using the standard tokenized error chip — white text on Maryland red:
{% if not card_title %}
<p class="c-content-interactive-primary c-bg-interactive-primary s-box-small-h">
Card Title is required.
</p>
{% endif %}Read each class left-to-right as what it is (BEM) → type (t-) → color (c-) → spacing (s-):
<div class="umd-lib card s-margin-general-medium {# S: gap below component #}
c-content-primary c-bg-secondary"> {# C: default text role + panel surface #}
<div class="card--content s-box-medium-v s-box-medium-h">{# S: inset padding, both axes #}
<p class="card--eyebrow t-eyebrow s-stack-small">…</p> {# T eyebrow · S line gap #}
<h3 class="card--headline t-title-medium s-stack-small">…</h3> {# T title · S line gap #}
<div class="card--text t-body-small c-content-secondary
s-stack-small wysiwyg-editor">…</div> {# T body · C secondary · S gap #}
<div class="card--date t-label c-content-tertiary">…</div> {# T meta · C tertiary · last: no stack #}
</div>
</div>Notice how roles cascade: c-content-primary on the root is the default text color for the whole card, and each child overrides the role only when it differs.
Spacing (s-)
- Root =
umd-lib <name>→ adds-margin-general-medium(behind abottom_marginprop). - Has a background/border? → wrap content in
s-box-medium-v s-box-medium-h. No surface? → skip box. Decorative chip? → fixedvar(--space-xs)in CSS. - Each stacked child gets
s-stack-small; bump tos-stack-mediumwhere a new logical group begins; the last child gets none. - Horizontal row? → flex +
gapin CSS. Only uses-inline-mediumfor a stray non-flex sibling.
Color (c-)
- Root surface →
c-bg-secondary+c-content-primary. - Per child, override the text role only when it differs: body →
c-content-secondary, meta →c-content-tertiary. On a dark surface, give EVERY text element its ownc-content-dark-*class — don't rely on inheritance. - CTAs / error chip → the pair
c-content-interactive-primary c-bg-interactive-primary. Links → text role +c-underline-primary ani-underline. Buttons → the base classumd-lib button(orumd-lib button secondary), never composed from utilities.
Type (t-)
- One ramp slot per element by role: eyebrow→
t-eyebrow, title→t-title-medium, body→t-body-small, meta→t-label, link→t-interactive. Big in-component display →t-display.t-headlineis the page h1 only. - Set body type on the wrapper (it cascades); add
t-bold/t-italiconly as emphasis.
Always
- Never write raw
px/rem, hex colors, orfont-sizefor these jobs, and never add spacing/type media queries — the utilities are already role-based and responsive. -
No
border-radiusanywhere — the brand is square-cornered.