Preflights and utility classes
DDI (Designer–Developer Interface) exists to close the gap between a designer’s intent and a developer’s markup. A designer thinks in terms of a design system — spacing scales, semantic colors, type levels, layout patterns. A developer, left to their own devices, reaches for one-off utility classes and inline styles that drift from that system a little more with every page. DDI’s bet is that most of a page’s styling should come for free from what the HTML already is, not from what classes get bolted onto it. Utility classes are the escape hatch for the last 10%, not the tool you build a page with.
The sections below outline the principles that follow from that bet. They outrank convenience — if a shortcut in this guide conflicts with one of these, the shortcut is wrong.
Preflights style the page first
Most of a page’s visual styling already exists before you write a single
class. It’s generated as preflight CSS from your project’s ddi.config.ts
and targets bare semantic HTML and composition classes directly.
<div class="x-flow">
<button>Primary</button>
<button class="secondary">Secondary</button>
</div>Neither button above has been given any styling by the page author — the
button element is fully styled by the preflight layer, and .secondary
is a composition-level modifier, not a utility class. An <a> needs
.button only because it isn’t a <button> to begin with:
<a class="button" href="/contact">Contact us</a>
Before reaching for a utility class, ask whether semantic HTML plus a
composition class already gets you there. Utility classes exist for
one-off adjustments — a nudge in spacing, a one-time color override — not
for constructing a page’s primary layout or typography. If you find
yourself writing four or five utility classes to make a div look like a
heading, the real fix is usually to use the heading.
Prose goes inside .body-text, not hand-styled
Headings and paragraphs are never styled element-by-element with utility
classes like font-size:value or bold. Instead, prose is wrapped in
.body-text, and its direct children — h1–h4, p, ul, ol, pre,
img — are styled automatically based on their semantic level.
Category label
Prose, not utilities
No element here has a font-size:value or bold class on it. The
heading, the subtitle, and this paragraph all get their styling
from being direct children of .body-text.
<div class="body-text">
<p class="pre-heading">Category label</p>
<h2 id="prose-not-utilities">Prose, not utilities</h2>
<p>No element here has a <code>font-size:value</code> or <code>bold</code> class on it. The
heading, the subtitle, and this paragraph all get their styling
from being direct children of <code>.body-text</code>.</p>
</div>Compare the two ways of producing the same heading:
<!-- Avoid: hand-styled heading -->
<p class="font-size:2xl font-weight:bold mbe:s">Section title</p>
<!-- Prefer: real heading inside .body-text -->
<div class="body-text">
<h2>Section title</h2>
</div>
The second version stays correct automatically if the type scale changes later — the first one is frozen the moment it’s written.
Composition classes already do their own spacing
.flow and .x-flow space their children using a --space custom
property and margins, not gap. Adding a gap:value utility class alongside
one of them doesn’t replace that spacing — it stacks on top of it, and now
two systems are fighting over the same visual gap.
<!-- Avoid: gap stacks on top of .flow's own margin-based spacing -->
<div class="flow gap:m">
<p>First</p>
<p>Second</p>
</div>
<!-- Prefer: let .flow own the spacing -->
<div class="flow">
<p>First</p>
<p>Second</p>
</div>
If you need real gap-based spacing instead, reach for .stack or
.x-stack — the flex-container equivalents that don’t apply any spacing
of their own — and add gap:value to those.
Pick one container strategy, don’t layer them
.content and .wrapper both center content with a max-width, but they
solve different problems: .content centers a page-level section at the
page’s content width, .wrapper centers a block of reading content at
the narrower prose measure width. They’re alternatives for the same job
at different scales, not layers meant to be nested.
<!-- Avoid: two competing max-width strategies on the same content -->
<div class="content">
<div class="wrapper body-text">...</div>
</div>
<!-- Prefer: pick the one that matches what this container is -->
<div class="wrapper body-text">...</div>
A page-level section (a hero, a full-width band) is .content. A block of
reading content (an article body) is .wrapper. If you’re not sure which,
ask what would happen if the type scale’s measure and the layout’s
content width diverged — whichever one the container should track is the
right pick.
Reach for scopedRules, not utility-class complexity
Utility classes are single-condition and unconditional — a class either
applies or it doesn’t. There’s no hover:/md:-style variant syntax in
this system, and that’s a deliberate boundary, not a gap waiting to be
filled. When something genuinely needs a @media query, a :hover state,
or another compound/conditional selector, reach for scopedRules() +
rule() (@dynamic-type/ddi/server) and write real, scoped CSS — not a
pile of utility classes straining to simulate conditional logic.
This is safe to nest without extra bookkeeping: rule()’s third argument
accepts further rules that get emitted verbatim via native CSS nesting, and
scopedRules() only hashes each top-level rule’s own selector — so
anything nested inside it (&:hover, @media (...), @keyframes)
inherits that rule’s scoping for free. This is the same mechanism already
used for &[open] in the Drawer component; the example below is the
scopedRules equivalent of StickyCTA’s hand-written
@media (max-width: 768px) mobile/desktop split.
A layout that stacks in a column on mobile and switches to a row on
desktop — resize the browser window (not just this panel) to see it
change, since @media matches viewport width:
---
import { rule, $c, $s, scopedRules, Style } from "@dynamic-type/ddi/server";
const [{ layout, item }, rules] = scopedRules([
rule(
".layout",
{
display: "flex",
"flex-direction": "column",
gap: $s("s"),
},
[
rule("@media (min-width: 768px)", {
"flex-direction": "row",
}),
],
),
rule(".item", {
flex: "1",
padding: $s("padding"),
background: $c("bg-alt"),
"border-radius": $s("border-radius"),
}),
]);
---
<div class={layout}>
<div class={item}>Sidebar</div>
<div class={item}>Main content</div>
<div class={item}>Aside</div>
</div>
<Style rules={rules} />See Scoped CSS Rules for the full scopedRules() API.
Why this ordering matters
These principles are listed in the order they should be applied, and
llms.txt — the reference an LLM working in a DDI-based project reads
first — states them the same way for a reason: getting them backwards is
the most common source of non-idiomatic DDI markup. A page built
utility-class-first will still render, but it stops benefiting from the
design system the moment ddi.config.ts changes — every hand-picked
font-size:value and gap:value is a small fork of the system that someone has to
find and reconcile later. A page built semantic-HTML-first, with utility
classes reserved for genuine one-offs, moves with the system instead of
against it.