Four stone blocks in ascending size, each engraved with progressively more specific HTML — from the raw </> through <input type="button"> and <a role="button"> to a fully realised <button> — illustrating the evolution from a basic primitive to a complete interactive element

Design System Primitives

A Practical Implementation Guide

Introduction

Not every component in a design system is meant to be visible.

Some components don't render buttons, cards, or forms. They define structure.

These are called primitives.

A primitive is a minimal component that represents a single structural role — like vertical arrangement , horizontal alignment , or containment — without taking ownership of visual styling.

Series Context

This article is part of a broader design system series. While it builds on ideas introduced in Theming Foundations, it stands on its own.

In this post, we'll define the structural primitives I use ( <Stack />, <Inline />, and <Block /> ), and implement minimal versions of them. These components expose controlled structural properties like spacing, alignment, and containment.

In the next post, we'll cover the remaining token layer — spacing, typography scale, radius, and other raw values that the system relies on but haven't been defined yet.

What is a Primitive?

At its core, a primitive exists to own a specific structural responsibility within a layout.

Instead of scattering spacing, alignment, and containment across arbitrary elements, primitives encode those structural decisions into reusable layout components that define explicit structural contracts.

Primitives are not “better divs”. A thin wrapper that accepts arbitrary class name or style props and forwards them to a div has not encoded any intent — it has just renamed markup.

Compare how structural intent is expressed in the following examples:

Before
<div className="flex flex-col gap-md">
  <div className="flex items-center justify-between">
    <h2>Account</h2>
    <button>Edit</button>
  </div>

  <div className="flex flex-col gap-sm">
    <p>Manage your subscription.</p>
    <button>View billing history</button>
  </div>
</div>

In the first example, structure is expressed through utility classes. Flex direction, alignment, and spacing are attached to generic elements as styling instructions rather than structural roles.

After
<Stack gap="large">
  <Inline align="center" justify="between">
    <h2>Account</h2>
    <button>Edit</button>
  </Inline>

  <Stack gap="small">
    <p>Manage your subscription.</p>
    <button>View billing history</button>
  </Stack>
</Stack>

In the second example, those same structural decisions are expressed through dedicated primitives. Stack defines vertical rhythm. Inline defines horizontal alignment.

If both approaches render the same result, the natural question is: why not just use HTML and utility classes?

Why Not Just Use HTML?

HTML and primitives operate at different layers of the system:

HTML defines document structure:

  • Document hierarchy
  • Content meaning and relationships
  • Landmarks

Primitives define responsibility:

  • Who owns layout decisions
  • Where layout boundaries are enforced

The distinction becomes clearer when viewed next to each other:

Document Structure

  • Document hierarchy
  • Content meaning & relationships
  • Landmarks

Layout Responsibility

  • Who owns layout decisions
  • Where layout boundaries are enforced
  • How structure is composed
HTML defines document semantics. Primitives define layout responsibility.

Primitives make structural ownership explicit and enforceable.

Minimizing surface area

While implementations vary, most design systems converge around a small set of structural primitives.

If you think in terms of software architecture, a primitive behaves like a single-responsibility function. It owns one structural concern, exposes a constrained API, and does not leak responsibilities it does not control.

In practice, that vocabulary usually converges around:

  • Vertical composition — stacking elements with controlled rhythm
  • Content flow — inline grouping that participates in document flow
  • Containment — defining layout boundaries and spacing surfaces

Three roles. That is the structural vocabulary. Everything else is composition.

Designing a Primitive

Before implementing any Primitive, we need a shared foundation for all primitives. In this system, every primitive composes from Block rather than reinventing the basics.

Primitives define structural responsibility — not semantics. For that reason, all primitives are polymorphic, accepting an as prop that declares the rendered element while preserving correct prop types.

import { ElementType, ComponentProps } from "react";

export type PrimitiveProps<T extends ElementType = "div"> = {
  as?: T;
} & Omit<ComponentProps<T>, "as">;

With that base contract in place, we can implement the simplest possible primitive: <Block />.

import { clsx } from "clsx";

type BlockProps<T extends ElementType = "div"> = PrimitiveProps<T>;

function Block<T extends ElementType = "div">({
    as,
    className,
    ...rest
}: BlockProps<T>) {

    const Component = as || "div";

    return <Component className={clsx("block", className)} {...rest} />;
}

The implementation is intentionally minimal. Block doesn't introduce layout behavior — it simply provides a predictable containment surface that other primitives can build on.

From here, Inline and Stack compose Block, adding focused structural responsibilities without duplicating the base surface.

Other systems may call this a Box, Container, or Wrapper. The name is less important than the role: Block is the containment surface everything else composes from.

Composing

With <Block /> providing containment, we can layer focused structural responsibilities on top of it.

A common structural role in a layout system is vertical composition — placing elements in a predictable vertical rhythm. That responsibility belongs to <Stack />.

<Stack /> extends BlockProps, adding only the structural properties it owns.

type StackProps<T extends ElementType = "div"> =
BlockProps<T> & {
    gap?: number;
    align?: "start" | "center" | "end" | "stretch" | "baseline";
    justify?: "start" | "center" | "end" | "stretch";
};
function Stack<T extends ElementType = "div">({
    gap = 4,
    align = "baseline",
    justify,
    className,
    ...blockProps
}: StackProps<T>) {

    const classes = clsx(
                'stack',
                // Gap utilities are defined globally (gap.css)
                // rather than scoped to Stack
                `gap-row-${gap}`, 
                `stack-align-${align}`,
                justify && `stack-justify-${justify}`,
                className);

    return (
        <Block
            className={classes}
            {...blockProps as BlockProps<T>}
        />
    )
}

The implementation is intentionally small. Stack does not redefine containment. It does not introduce horizontal behavior. It adds vertical composition — and nothing more.

This layering keeps structural concerns isolated. Containment lives in Block. Vertical rhythm lives in Stack.

Inline

Full implementation is available in the resources section below.

Inline follows the same pattern, adding a distinct structural responsibility without duplicating the base surface.

Inline participates in content flow. It behaves as an inline-level flex container, ideal for icon-text pairs, tags, and metadata clusters. Unlike Stack, it does not own its width — it takes up only as much space as its children need.

Each primitive owns a distinct structural surface: vertical rhythm ( Stack ) or flow-based horizontal grouping ( Inline ). Neither duplicates containment. Neither collapses into a generic utility surface.

Stack - owns the vertical layout surface

Child AChild BChild C

Inline - flows with content

Child AChild BChild C

Remaining space - not owned

Each primitive owns a distinct structural surface: vertical rhythm (Stack) or flow-based (Inline).

Where I Draw the Line

Not every structural role deserves to become a primitive.

Row

Row seems like it should exist — a horizontal counterpart to Stack.

In practice, it breaks down immediately.

The problem is that horizontal layouts are almost never static.

  • A toolbar becomes a column on mobile
  • A header reflows at smaller widths

The moment responsive behaviour enters the picture, Row stops owning its structural responsibility — it just becomes a starting point that every consumer works around.

Text

Text is another common candidate. It could own typography decisions — font size, weight, line height — but the global styling already does most of that work.

In practice, it would mostly be wrapping <p> tags that already behave correctly, putting a component between the developer and a semantic HTML element that communicates meaning clearly.

The type scale tokens are still part of the system. They just don't need a primitive to distribute them.

Grid & Flex

These are the most obvious candidates — they map directly to CSS layout modes that developers already know. The problem is that they describe implementation, not intent.

A primitive named Grid tells you how something is rendered. A primitive named Stack tells you what it does. In a system built on structural contracts, the name should describe the role — not the implementation.

Spacer

Spacer appears in many design systems as a way to add explicit whitespace between elements.

The problem is that it puts layout decisions inside content — a <Spacer size="md" /> between two elements is the child deciding its own spacing, which is the layout primitive's job.

Primitives — When and When Not To

Two side-by-side illustrations — on the left, colourful UI cards stacked neatly labelled "With", on the right, the same cards in a chaotic pile labelled "Without"

Primitives solve structural problems that appear as systems scale.

  • Layout Drift - Primitives centralise spacing rules
  • Hidden Intent - Primitives make arrangement explicit.
  • Duplication of Structural Logic - Primitives consolidate repeated patterns.
  • Fragile Refactoring - Changing a primitive is predictable and system-wide.

Primitives reduce entropy by limiting where structural decisions can live.

That said, avoid primitives when you're prototyping quickly, or when semantic HTML alone communicates both structure and meaning clearly.

Over-abstraction is just as harmful as under-structure. The goal is clarity — not purity.

Summary

This post introduces primitives as structural building blocks.

In the next post, we'll cover the remaining token layer — spacing, typography scale, radius, and other raw values that the system relies on but haven't been defined yet.

But first, it's important to understand them simply as what they are:

A small vocabulary of components that make layout intentional.

Code & Resources

This section documents constraints, tradeoffs, and reference material — it does not introduce new concepts.

Class Naming & Utility Class Conflicts

Class names used in this post (e.g. stack-align-center, inline-justify-between) are chosen for readability.

In projects that use Tailwind, Bootstrap, or a home-grown utility system, these may clash. A namespace prefix such as ds- or your system's name is worth considering to keep design system classes isolated.

Typescript notes

The shared PrimitiveProps type uses Omit to remove any existing as prop from ComponentProps<T>. This prevents duplicate or conflicting polymorphic definitions when composing custom element types.

Further Reading

Layout & Primitives

Design Systems

Primitives (Reference)

Reference implementation of the Primitives discussed in the post.

Types

import { ElementType, ComponentProps } from "react";
                    
/*
    Block defines the containment surface.
    All higher-order Primitives compose from Block.

    Each primitive exposes alignment and distribution
    values appropriate to its layout mode.
*/

type PrimitiveProps<T extends ElementType = "div"> = {
    as?: T;
} & Omit<ComponentProps<T>, "as">;
 
type BlockProps<T extends ElementType = "div"> =
    PrimitiveProps<T>;
  
type StackProps<T extends ElementType = "div"> =
BlockProps<T> & {
    gap?: Gap;
    align?: GridAlignment;
    justify?: GridJustify;
};

type InlineProps<T extends ElementType = "div"> =
BlockProps<T> & {
    gap?: Gap
    align?: FlexAlignment;
    justify?: FlexJustify;
    wrap?: boolean; // true = wrap (default), false = nowrap
}

Components

block.tsx

The block className connects the component to the paint system. Without it, data-paint attributes would have no CSS target to apply styles to. See Theming Foundations for how the paint layer works.

block.tsx
import type { BlockProps } from "@/components/primitives/types";
import { clsx } from "clsx";

function Block<T extends ElementType = "div">({
    as,
    className,
    ...rest
    }: BlockProps<T>) {

    const Component = as || "div";

    return <Component  className={clsx("block", className)} {...rest} />;
import { clsx } from "clsx";
import { Block } from "@/components/primitives/block";
import type { StackProps, BlockProps } from "@/components/primitives/types";

export function Stack<T extends ElementType = "div">({
    gap = 4,
    align = "baseline",
    justify,
    className,
    ...blockProps
    }: StackProps<T>) {

    const classes = clsx(
                    'stack',
                    // Gap utilities are defined globally (gap.css)
                    // rather than scoped to Stack
                    `gap-row-${gap}`, 
                    `stack-align-${align}`,
                    justify && `stack-justify-${justify}`,
                    className);

    return (
        <Block
        className={classes}
        {...blockProps as BlockProps<T>}
        />
    );
}
import { clsx } from "clsx";
import { Block } from "@/components/primitives/block";
import type { InlineProps, BlockProps } from "@/components/primitives/types";

export function Inline<T extends ElementType = "div">({
    gap = 4,
    // Default to center — most inline groupings (icon-text pairs, tags) expect vertical centring
    align = "center",
    justify = "start",
    wrap = true,
    className,
    ...blockProps
    }: InlineProps<T>) {

    const classes = clsx(
        "inline",
        `gap-${gap}`,
        `inline-align-${align}`,
        `inline-justify-${justify}`,
        wrap ? "inline-wrap" : "inline-nowrap",
        className
    );

    return (
        <Block
        className={classes}
        {...blockProps as BlockProps<T>}
        />
    );
}