Skip to content
AstroCraft Docs
On this theme

Layout & Spacing

Grafio has no Container component. Page width is two @apply classes in src/styles/global.css, applied directly to the elements that need them. That is a smaller mechanism than a wrapper component, and it means a section can put the frame on an inner row while keeping a full-bleed background — which the header does.

The two frames

.page-frame { @apply mx-auto w-full max-w-[90rem] px-5 md:px-10; }
.site-container { @apply mx-auto max-w-[1100px] px-4; }

.page-frame is the design frame: 90rem (1440px) max width with 20px gutters that step to 40px at md. It matches the Figma artboard, and it is what almost every section uses — 25 files, including the header and footer.

.site-container is the reading measure: 1100px, narrower, for pages where line length matters more than layout. Three files use it — the legal article, the 404 page and the UI catalog.

The distinction is content type, not page importance. If a section arranges things, it wants .page-frame. If a section is text somebody reads top to bottom, it wants .site-container.

Note what .page-frame frames: contents, not elements. The header keeps a full-width blurred backdrop and puts .page-frame on the row inside it, so the blur runs edge to edge while the logo and nav align with the page. Apply the same pattern for any section with a background that should bleed.

To change the site’s maximum width, edit one value in one of those two classes. Nothing else references a width.

Vertical rhythm

Sections use py-24 md:py-32 as the standard vertical rhythm — 96px stepping to 128px. Full-viewport sections like the home hero and the 404 use min-h-[100lvh] instead, and centre their content.

There is one clearance detail worth knowing: the header is fixed and overlays content. Sections that centre in the viewport are unaffected, but a section that starts at the top of the page needs padding to clear the bar. The legal article’s py-16 md:py-24 is doing exactly that — 64px against a roughly 62px header.

Breakpoints

Tailwind’s defaults, restated explicitly, plus one addition:

--breakpoint-xs: 400px;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;

xs exists for the narrowest phones. The theme is mobile-first throughout — base styles are the small-screen case and every breakpoint adds.

Where a layout splits from column to row is a measured decision, not a habit. The closing CTA band splits at lg rather than md because the pill takes about 227px plus a 64px gap, which would leave the headline 397px at md — too narrow for the line to hold.

The shared utility classes

Beyond the two frames and the type classes covered under Typography, three more are worth knowing:

@utility primary-focus {
  @apply focus-visible:outline-primary-500 focus:outline-hidden
         focus-visible:rounded-xs focus-visible:outline-2;
}

.main-text-gradient {
  @apply from-primary-800 to-primary-600 dark:from-primary-400 dark:to-primary-200
         bg-gradient-to-r bg-clip-text text-transparent;
}

primary-focus is the site’s focus ring. It is declared as an @utility rather than a class inside @layer utilities, and that difference is functional: a registered utility can be reached by @apply from any layer, which is how article.css gives article links the same ring. A plain class in the utilities layer cannot be applied from the components layer.

Keep a visible focus ring on anything interactive. Either use primary-focus or the focus-visible:ring-3 that the primitives already carry.

The layer order

global.css declares the cascade order explicitly, which is what makes @apply predictable:

@layer theme, base, components, utilities;

The full import graph, starting at the single entry point that BaseLayout imports:

src/styles/global.css
├── fonts.css                    two @font-face declarations
├── tailwindcss                  the framework
├── @variant dark, @variant js
├── tailwind-theme.css           palette aliases + the @theme inline bridge
├── motion/index.css             the animate-* catalog
│   └── motion/keyframes.css
├── @layer theme, base, components, utilities
├── article.css  (into layer: components)
├── @layer base       :root, .dark, element defaults
├── @layer components .h1 .h2 .h3 .display-heading .description .site-container .page-frame
├── @utility          primary-focus
└── @layer utilities  .main-text-gradient

Two more stylesheets sit outside this graph, imported from component frontmatter and deliberately unlayered so they outrank utilities: ui/_overlay.css (dialog and sheet transitions, plus the scroll lock) and ui/accordion/_accordion.css.

When to reach for @apply

The house rule: @apply is for a pattern repeated across many unrelated elements. .h2 earns it — thirty files use it. .page-frame earns it — twenty-five do.

Reach for a component or a tv() variant config when the thing has structure or variants. Do not @apply to tidy a one-off class list; that just moves the mess somewhere harder to find.

The smell test: six or more utilities with a ternary inside a class attribute means it should be a tv() component. The same cluster of classes appearing three or more times means it should be a component or an @apply class.

Class ordering

prettier-plugin-tailwindcss orders utility classes, and it must be the last plugin in .prettierrc.mjs. Run pnpm format and let it sort. Hand-ordering class lists is work the tool does better, and fighting it produces diffs nobody wants to review.

Overriding a primitive’s layout

Every primitive merges a consumer class through its tv() config, and tv() runs tailwind-merge, so the last conflicting utility wins:

<Card variant="interactive" class="max-w-sm sm:flex-row">

That is how the shipped code makes a horizontal card, sets a status accent (class="border-t-primary border-t-4"), or constrains a width. A one-off class, not a new variant — variants are for arrangements the design uses repeatedly.

One trap: because the consumer class merges last, putting a colour into an override can beat a state the primitive sets. The contact form’s underline-style fields carry a shape override deliberately free of colour, so that the border-error applied by state="error" still wins. Style shape in your override; leave colour to the primitive.

NEXT STEPUI Components