Back to Ghost

Working with primitives

apps/shade/src/docs/primitives-guide.mdx

6.37.18.0 KB
Original Source

import { Meta } from '@storybook/addon-docs/blocks';

<Meta title="Primitives Guide" /> <div className="sb-doc">

Working with primitives

<p className="excerpt">Primitives are how Shade replaces the endless string of `flex flex-col gap-4 items-start` utilities with something a human can read and an AI agent can generate consistently. This page is a practical guide to the six primitives, their props, and how to migrate existing layouts.</p>

Why primitives exist

Most layout code in any React codebase looks the same: a <div> with five Tailwind layout utilities. After a while it becomes invisible. Reviewers stop reading those classes; agents glue together their own variants of "vertical stack with medium gap"; small spacing inconsistencies accumulate across the product.

Primitives solve that by giving the structural concepts names. Instead of inferring "this is a vertical stack" from flex flex-col gap-4, you write <Stack gap="md"> and the intent is right there.

The benefits are practical:

  • A reviewer skimming a screen can tell in one second what its structure is.
  • An AI agent generating UI doesn't have to re-derive flex syntax every time.
  • Spacing decisions become semantic (gap="lg") rather than numeric (gap-6), so we can tune the spacing scale without touching every component.

The six primitives

You only need to remember six. They cover essentially every layout situation that doesn't involve writing a custom component.

Stack is for vertical groupings. Use it whenever you have a column of children that need consistent spacing between them.

Inline is for horizontal groupings — toolbars, action rows, sequences of inline controls.

Box is a framing primitive. Use it when you want padding or radius without imposing a layout direction.

Container constrains width — for page shells where content shouldn't span the full viewport.

Grid is for actual two-dimensional grids: card grids, multi-column lists.

Text is the typographic primitive. Use it for paragraphs, headings, labels, captions — anything that's "rendered text".

The fast version of when to reach for each:

  • Vertical composition? Stack.
  • Horizontal composition? Inline.
  • A framed region? Box.
  • A page shell with a max width? Container.
  • Cards or columns? Grid.
  • Words on the screen? Text.

Props at a glance

Stack

PropTypeDefaultPurpose
gap`nonexssm
align`startcenterend
justify`startcenterend

Inline

PropTypeDefaultPurpose
as`divheadersection
gap`nonexssm
align`startcenterend
justify`startcenterend
wrapbooleanfalseWrap children to multiple rows

Box

PropTypeDefaultPurpose
padding`nonexssm
paddingX`nonexssm
paddingY`nonexssm
radius`nonesmmd

Container

PropTypeDefaultPurpose
size`xs...9xlprosepage
centeredbooleantrueApply horizontal centering
paddingX`nonexssm

Grid

PropTypeDefaultPurpose
columns`123
gap`nonexssm
align`startcenterend
justify`startcenterend

Text

PropTypeDefaultPurpose
as`pspandiv
size`2xsxssm
weight`regularmediumsemibold
tone`primarysecondarytertiary
leading`nonesnugnormal
truncatebooleanfalseSingle-line truncation with ellipsis

Putting them together

A typical page shell, all primitives:

tsx
import {Button} from '@tryghost/shade/components';
import {Container, Inline, Stack, Text} from '@tryghost/shade/primitives';

<Container size="page" paddingX="lg">
    <Stack gap="xl">
        <Inline justify="between" align="center">
            <Text as="h1" size="2xl" weight="bold" leading="heading">Members</Text>
            <Button>New member</Button>
        </Inline>

        <Stack gap="md">
            <Text tone="secondary">Manage your audience.</Text>
        </Stack>
    </Stack>
</Container>

Read that out loud and it tells you what's there: a page-width container; vertically stacked content; the top row is a horizontal inline split with the title on the left and the action button on the right; the body below is another vertical stack of text.

A header with actions and a count:

tsx
<Stack gap="sm">
    <Text as="h2" size="xl" weight="semibold" leading="heading">Posts</Text>
    <Inline justify="between" align="center" wrap>
        <Text tone="secondary">2,132 total</Text>
        <Inline gap="sm">
            <Button variant="outline">Export</Button>
            <Button>New post</Button>
        </Inline>
    </Inline>
</Stack>

A list of framed rows:

tsx
<Stack gap="sm">
    <Text as="h3" size="lg" weight="semibold">Drafts</Text>
    <Grid columns={1} gap="sm">
        <Box padding="md" radius="md" className="border border-border-default">
            <Text>Draft row content</Text>
        </Box>
        <Box padding="md" radius="md" className="border border-border-default">
            <Text>Another draft row</Text>
        </Box>
    </Grid>
</Stack>

Migrating existing layouts

The migration is mechanical, and almost always shorter:

tsx
// Before
<div className="flex flex-col gap-4">
    <div className="flex flex-col gap-2">
        <h1 className="text-2xl font-bold">Members</h1>
        <p className="text-gray-600">Manage your audience</p>
    </div>
    <div className="flex items-center justify-between gap-2">...</div>
</div>

// After
<Stack gap="lg">
    <Stack gap="sm">
        <Text as="h1" size="2xl" weight="bold" leading="heading">Members</Text>
        <Text tone="secondary">Manage your audience</Text>
    </Stack>
    <Inline align="center" justify="between" gap="sm">...</Inline>
</Stack>

Or for a card grid:

tsx
// Before
<div className="grid grid-cols-3 gap-6">
    <div className="rounded-lg p-4">A</div>
    <div className="rounded-lg p-4">B</div>
    <div className="rounded-lg p-4">C</div>
</div>

// After
<Grid columns={3} gap="xl">
    <Box radius="lg" padding="lg">A</Box>
    <Box radius="lg" padding="lg">B</Box>
    <Box radius="lg" padding="lg">C</Box>
</Grid>

Things to avoid

Don't add anonymous <div> wrappers that only carry flex/grid/gap utilities. If you need that structure, reach for a primitive instead.

Don't use raw spacing numbers (gap-6, p-3) in primitive APIs. Use the semantic scale (gap="lg", padding="sm") — it's what the spacing tokens are for.

Don't put product workflow logic inside a primitive. Primitives are layout vocabulary; if a component is starting to know about Ghost-specific data, it belongs in a Feature, not a primitive.

Don't reach for Box when Stack or Inline would do. Box is the right tool when you genuinely don't want a layout direction — just framing.

</div>