Skip to content

Building a Component

Alfred J Lin edited this page Jun 2, 2026 · 1 revision

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.

1. Component anatomy (SDC)

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 / label and group: UMD Libraries
  • variants — design variations selected in the Layout Builder (e.g. the card's standard / overlay / icon)
  • props — typed configuration, each with an enum of allowed values and a default
  • 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

2. Naming conventions in markup

  1. Namespace + component + BEM element. The root element gets umd-lib plus the component name (class="umd-lib card"); internal parts use BEM-style component--element (card--content, card--title, card--eyebrow). These are the hooks your component's own CSS targets for layout.
  2. 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).

3. Spacing (s-*) — four jobs, four families

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 fixed padding: 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 the var(--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.

4. Color (c-*) — pick a role, never a hue

You choose what the element is for; the system decides the value and flips it for dark mode.

  1. Text = a c-content-* role. Default c-content-primary; drop to secondary for body, tertiary for meta.
  2. A surface gets a c-bg-* role, and its text gets the matching role — chosen together. c-bg-secondary is the default panel.
  3. 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-primary for primary text, c-content-dark-secondary for muted copy.)
  4. 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.

5. Typography (t-*) — one ramp slot per element, by role

Role Class
Eyebrow / kicker t-eyebrow
Card / section title t-title-medium (default); t-title-small tighter, t-title-large bigger
Page h1 t-headlinepage-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 wrappert-* 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.

6. House rules (non-negotiable)

  1. No border-radius. Ever. The brand is square-cornered; base.css has zero radius declarations. Don't round buttons, cards, chips, or images.
  2. Buttons are a ready-made base class — never rebuild them. Apply class="umd-lib button" (primary) or class="umd-lib button secondary" (outlined). Do not compose a button from utilities, and do not create a button component. See CTA Button.

7. What lives in component CSS (and what doesn't)

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.

8. The validation fallback

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 %}

Worked example — the canonical card

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.

60-second checklist (for humans and AI)

Spacing (s-)

  1. Root = umd-lib <name> → add s-margin-general-medium (behind a bottom_margin prop).
  2. Has a background/border? → wrap content in s-box-medium-v s-box-medium-h. No surface? → skip box. Decorative chip? → fixed var(--space-xs) in CSS.
  3. Each stacked child gets s-stack-small; bump to s-stack-medium where a new logical group begins; the last child gets none.
  4. Horizontal row? → flex + gap in CSS. Only use s-inline-medium for a stray non-flex sibling.

Color (c-)

  1. Root surface → c-bg-secondary + c-content-primary.
  2. 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 own c-content-dark-* class — don't rely on inheritance.
  3. CTAs / error chip → the pair c-content-interactive-primary c-bg-interactive-primary. Links → text role + c-underline-primary ani-underline. Buttons → the base class umd-lib button (or umd-lib button secondary), never composed from utilities.

Type (t-)

  1. 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-headline is the page h1 only.
  2. Set body type on the wrapper (it cascades); add t-bold/t-italic only as emphasis.

Always

  1. Never write raw px/rem, hex colors, or font-size for these jobs, and never add spacing/type media queries — the utilities are already role-based and responsive.
  2. No border-radius anywhere — the brand is square-cornered.

Clone this wiki locally