The foundations philosophy explains the reasoning in more detail, but the short version is simple: this is a reusable design system for typography primitives, content blocks, the long-form essay shell, token and theme CSS, and the build-time tooling that keeps the baseline consistent. That includes SEO, OG cards, lint rules, and contrast checks.
It is used in software graduating from the Supertype Incubator, so projects like Viably work operating system, Supertype.ai, and Sectors Financial Data Platform all use the MIT-licensed Foundations as their design system.
It is designed thoroughly around the principles of reusability, consistency, and maintainability, ensuring that all projects built with Foundations have a solid (albeit slightly opinionated) typographic and styling foundation while sticking to best practices and design guidelines consistent with Next.JS 15+ and Tailwind 4+.
yarn add @supertype.ai/foundationsStart here: Install, then Your first page.
Reference: Typography · Blocks · The essay shell · Build-time tooling · The CLI
Working on the package itself: Contributing, for local iteration against a consumer and for releasing.
Like the project? ⭐ Star it on GitHub
Check out: the documentation site, or alternatively run the example site locally:
yarn example:install # once, to install Next, the peers and the package
yarn example # then open http://localhost:3000examples/site renders every component with the code next to it, and includes whole-page recipes you can copy into your project. It also includes the dark and .editorial switches.
This package includes a CLI that writes the CSS for you and checks the rest:
npx @supertype.ai/foundations init # edits your CSS entry, prints the rest
npx @supertype.ai/foundations doctor # checks this app against everything below
npx @supertype.ai/foundations upgrade # after a version bump, moves source the release changedinit edits one file: the CSS entry that imports Tailwind. It adds any missing
imports and reorders the existing ones if needed. Run it with --dry-run first
to preview the patch. It also prints the font bindings and the llms.txt snippet
your coding agent should read.
upgrade is for later: after bumping the Foundations package, it rewrites source written for
an older version and lists what it leaves. Works only on a clean git tree.
The steps performed by init are listed below. See the CLI for
the full list of checks and details.
yarn add @supertype.ai/foundations
# or: npm install @supertype.ai/foundationsPeers are Tailwind 4+, React 19+, Next 15+, next-view-transitions 0.3+ and
@base-ui/react 1.4+.
tokens.css declares
@custom-variant and @theme inline, and the @source line below is Tailwind-v4
syntax; on v3 they are parse errors. Run
npx @tailwindcss/upgrade first to upgrade your Tailwind to v4. foundations init will warn you when you are using an out-of-date Tailwind version.
Installing from a git tag instead
Every release is tagged and published, so a commit can be installed directly as well.
/* app/globals.css */
@import "tailwindcss";
@import "@supertype.ai/foundations";That one line carries tokens.css, theme.css, type.css and prose.css in
the order the cascade needs, and wires it up to Tailwind v4 resolution correctly.
Add @import "@supertype.ai/foundations/shiki.css"; after the two above if you render
code fences.
Importing the parts separately
You can also import these CSS files separately. This is useful if you want to
set your own colour values and leave out theme.css. In that case, use the
import order below and add the @source path so Tailwind scans the package:
/* app/globals.css */
@import "tailwindcss";
@import "@supertype.ai/foundations/tokens.css"; /* structural tokens + dark variant */
@import "@supertype.ai/foundations/theme.css"; /* the house palette */
@import "@supertype.ai/foundations/type.css"; /* the type ramp + font roles */
@import "@supertype.ai/foundations/prose.css"; /* inline-code rule */
@source '../node_modules/@supertype.ai/foundations/dist/**/*.js';The @source line is required in this form. Tailwind does not scan
node_modules by default, so without it the package’s classes get purged
and the components render without styles. The path is relative to your CSS file,
so it changes with your layout — and in a workspace, where the package hoists to
the repo root, ../node_modules is not where it lives.
theme.css is required. tokens.css names the colour roles, and
theme.css gives them values. Without it, the colour utilities cannot be
resolved, so the page renders unpainted without an obvious error. It also
carries --secondary-ink, --subtle-foreground and the four earth tones used
for marker highlights.
Skip it only if you declare every role yourself; foundations doctor fails if
neither path is true.
The package cannot load the typefaces for you. next/font runs in your app and
generates hashed variable names at build time, so each app loads the three fonts
and binds them to the roles type.css expects:
// app/layout.tsx
import { Ubuntu_Sans, Ubuntu_Sans_Mono, Average } from "next/font/google";
const sans = Ubuntu_Sans({ variable: "--font-ubuntu-sans", subsets: ["latin"] });
const mono = Ubuntu_Sans_Mono({ variable: "--font-ubuntu-sans-mono", subsets: ["latin"] });
const serif = Average({ variable: "--font-average", weight: "400", subsets: ["latin"] });
<html className={`${sans.variable} ${mono.variable} ${serif.variable} font-sans`}>Bind with .variable, never .className. A className sets font-family
on the element itself and leaves the roles unresolved, which causes a mismatch
where the page renders one typeface while the font-sans and font-heading
utilities render another.
npx foundations doctorIt reads your CSS entry, your root layout and the installed tree, then reports
on import order, the @source path, the font bindings and the peer versions. It
exits non-zero on a real problem, so it works as a CI step too. Every check and
what it catches is listed in the CLI.
import {
TypographyH1,
TypographyH2,
TypographyProse,
TypographyEyebrow,
TypographyLink,
TypographyCaption,
} from "@supertype.ai/foundations";
import { Card, Cards, Callout } from "@supertype.ai/foundations/blocks";
export default function Page() {
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<TypographyEyebrow>Guides</TypographyEyebrow>
<TypographyH1 variant="display" className="mt-2 text-balance">
Getting data out of Postgres
</TypographyH1>
<TypographyProse className="mt-4">
Three approaches, ordered by how much of your schema they need to know.
</TypographyProse>
<TypographyH2 divider className="mt-12">
Approaches
</TypographyH2>
<Cards>
<Card
href="/notes/logical-replication"
title="Logical replication"
description="Row-level changes, no schema coupling."
/>
<Card
href="https://www.postgresql.org/docs/current/sql-copy.html"
title="COPY"
description="Fastest bulk path. Leaves the app."
/>
</Cards>
<Callout tone="warn" title="Before you start" className="mt-8">
Replication slots hold WAL until they are consumed. An abandoned slot
fills the disk — see{" "}
<TypographyLink href="/ops/slots" addArrow>
slot hygiene
</TypographyLink>
.
</Callout>
<TypographyCaption as="p" className="mt-8">
Last reviewed October 2026
</TypographyCaption>
</main>
);
}Two rules cover most of the API:
- Do not write type styles by hand. A paragraph carrying
text-sm text-muted-foregroundis<TypographyMuted>. Using the primitives keeps a size and a colour from drifting apart across the entire codebase. - Retune with CSS variables, not classes. The package owns its own
classnames. Change a
--text-*,--heading-weight, or a colour specification intheme.cssto retune the whole package. Read Tokens and theming for instructions.
yarn example (above) builds the package, syncs it in and starts the dev
server. yarn example:build is what CI would run.
It installs the package from a git tag rather than the registry and updates it
with yarn sync.
/recipes holds whole pages rather than single components: a marketing hero, a
metrics panel, pricing tiers, a docs page, an article index, and examples of MDX-rendered pages. Each one lives in
app/_recipes/ as a complete file that imports
only from this package, so you can paste it into your app.
The package includes an llms.txt with the public API, rules and usage guidance. Point your agent at it once and it stops
writing text-sm text-muted-foreground where a primitive exists:
<!-- CLAUDE.md, AGENTS.md, or your agent's equivalent -->
@node_modules/@supertype.ai/foundations/llms.txt| import | contains | docs |
|---|---|---|
@supertype.ai/foundations |
all typography primitives, cn |
Typography |
@supertype.ai/foundations/blocks |
Button, Badge, Card, Callout, Steps, TabGroup, Disclosure, SEGMENT |
Blocks |
@supertype.ai/foundations/mdx |
proseMdxComponents — the MDX element map |
In MDX |
@supertype.ai/foundations/essay |
the long-form shell, TOC, reading rail, post meta | Essay |
@supertype.ai/foundations/seo |
createSeo(...) — metadata + JSON-LD |
Tooling |
@supertype.ai/foundations/og |
ogCard, OG_SIZE — an element for next/og |
Tooling |
@supertype.ai/foundations/eslint |
the design rules as ESLint selectors; five ship off until an app has swept for them | Tooling |
@supertype.ai/foundations/rehype |
rehypeProseCode — build-time only |
In MDX |
@supertype.ai/foundations/contrast |
token resolution + legibility checks, build-time only | Tooling |
./tokens.css ./theme.css ./type.css ./prose.css ./shiki.css |
the style layer | Tokens and theming |
foundations (bin) |
init and doctor |
The CLI |
tokens.css names the structural roles: --background,
--foreground, --card, --muted, --primary, --border and --ring, plus
the status set. They are named for meaning rather than hue, so a project that
renders success in blue still reads correctly. It holds no values, only the roles themselves.
Each status hue ships twice, on the same rule as the categorical tints:
--success, --warn and --info are fills, held to 3:1 against the page
and a card; --success-ink,
--warn-ink and --info-ink are the same hues as text, held to 4.5:1.
--danger ships as an ink only. --destructive keeps shadcn's shape, where
--destructive-foreground is the label printed on the fill — that is what
-foreground means throughout, and -ink means the hue used as words.
checkSignals in @supertype.ai/foundations/contrast measures all of them and fails if any are below the threshold.
theme.css gives those roles the latte and espresso palette, and adds the
editorial inks (--secondary-ink, --subtle-foreground, and the ochre,
terracotta, sage and fig pairs) along with the elevation shadows.
tokens.css also binds the dark: variant to the .dark class which sets color-scheme: dark such that scrollbars, form controls and
autofill follow the palette, including on a .dark section of a light page.
Each theme's ground is available by name on :root as --latte-background
and --espresso-background. Use them when something outside a .dark element
needs the dark ground. The usual case is <html> behind a page that pins its
content dark, which otherwise shows the light ground on overscroll:
html:has(.dark[data-ground]) {
background-color: var(--espresso-background);
}No brand colours in the package. Structural tokens only, with brand colours left to the app. To repaint, override the raw variables after the imports rather than patching the utilities:
:root {
--primary: hsl(24 60% 42%);
}
.dark {
--primary: hsl(24 70% 62%);
}type.css names three font roles (--font-sans, --font-mono and
--font-heading) and the weight that goes with the heading face. .editorial
gives the heading role to the serif and drops the weight to 400.
<div className="editorial">…</div> {/* or on <html> for an editorial site */}Heading sizes are a ratio to the body text under them with 13px in the product, 18px on .editorial. Scope the class to whichever surfaces should be editorial, whether that is a marketing and docs section or the whole site.
- The package owns its final classnames. Retune with CSS custom properties
(the
--text-*,--heading-weight, the colour tokens) rather than by patching classes. A property the package declares is read by the package; if a knob turns nothing,test/tokens-live.test.tsfails on it. - No variant props on the MDX map. Elements that MDX renders automatically take no options.
- Native html first, and a library only where it falls short.
Disclosureis a<details>/<summary>pair: no JavaScript, correct before hydration, available to an MDX author, and animated by CSS where the browser can.TabGroupuses Base UI because managed selection is beyond what native HTML offers. - No brand colours. Structural tokens only, with brand colours left to the app.
- Put structure in CSS rather than the component map. A host framework can substitute its own element and strip the classes off it, but it cannot strip a child combinator. Both the Shiki theming and the inline-code rule rely on this.
With 0.4, there is one way to put an icon, checkbox or dot beside words.
// before
<TypographyCaption className="flex items-center gap-1">
<Icons.Clock className="size-3" /> 44d ago
</TypographyCaption>
// after
<TypographyCaption mark={<Icons.Clock />}>44d ago</TypographyCaption>Every removal corresponds to a feature that 0.4 replaces with the new mark and markEnd system.
| 0.3 | 0.4 |
|---|---|
ON_FIRST_LINE wrapper beside a block of text |
the words' mark prop, or <Marked mark title> for a title with lines under it |
ON_BASELINE on a row |
items-baseline for words at two sizes; an icon in the row goes in the words' mark or a <Mark> |
icon-inline utility |
nothing: a glyph in a mark is one em of the words |
TypographyCaption sizing a direct-child svg |
pass the icon as mark or markEnd; a loose one renders at Lucide's 24px |
CAP_TRIM on a label so items-center meets an icon |
the label's mark; CAP_TRIM stays for text centred in its own box or beside an object taller than the line |
inline + align-middle or -mt-* on an icon |
<Mark> around it, or the words' mark |
TypographySmall |
TypographyCaption: as="small" for small print, as="p" for a note. upgrade writes as="p", which keeps the block the old one rendered |
TypographyProseList |
TypographyList variant="prose" |
Accordion, AccordionItem, AccordionTrigger, AccordionContent |
DisclosureGroup and Disclosure, which now animate open and closed. A literal defaultValue becomes open on those rows; state the app keeps is open and onToggle on each Disclosure |
Tabs, TabsList, TabsTrigger, TabsContent |
TabGroup, built with .map when the tabs are data. A strip with no panels is a TabGroup picker (from 0.4.1); a compact one is size="sm"; a frame around the strip and panels goes around the TabGroup |
DISCLOSURE |
Disclosure; the surfaces are its own |
INK_ON_CARD, INK_ON_POPOVER, INK_ON_SIDEBAR |
style={inkOnSurfaceStyle("--card-foreground")}, and the same for any surface |
the accordion-down and accordion-up keyframes |
nothing: Disclosure animates ::details-content |
markandmarkEndon every text role, headings andCardTitleincluded. A leading mark starts the line; an end mark follows the words, andjustify-betweensends it to the edge.<Marked mark={…} title={<TypographyLabel>…</TypographyLabel>}>lines</Marked>for a title and the lines under it.<Mark>, the slot on its own, for a glyph inside a sentence or in words no role renders.truncateon every text role. With a mark it cuts the words, never the mark.- From 0.4.1,
TabGroupwith nocontenton any tab is a picker, andsize="sm"is the compact strip for a toolbar or a chart. - From 0.4.1, no block gives itself a margin.
Cards,Steps,DisclosureGroupandTabGroupdropped theirmy-6: in proseProseFlowspaces them, and elsewhere the layout holding them does, as it already did for every other block. One that sat bare in a page gets its space from agapor aspace-y-*on its container.
- A glyph beside words is one em of them. Meta icons in a caption grow from 0.8em to 1em; an icon that carried a bigger size class shrinks to 1em.
Button,Badgeand aTabGrouptab size a glyph to one em of the label, by the rule every mark uses: a bare glyph, a direct child.Buttonused to reach a glyph at any depth; one you wrap in an element of your own now keeps the size you give it.Badge size="xs"no longer forces a 12px icon into a 10px label.Badgetrims its label asButtondoes, and both keep adjacent words as one label, so{count} to act onno longer splits around the gap.TypographyCaptionstates its regular weight, so a count inside a label's line stays regular.TypographyLinkwith notonetakes the ink around it. It defaulted tomuted, which is the same ink on a page and failed contrast on a filled surface. ItsaddArrowglyph is a mark, one em of the link.Disclosureanimates open and closed where the browser supports::details-contentandinterpolate-size, and opens at once elsewhere.- The design lint (
designRules) flags a loose icon in a text role, a size class on a glyph in a mark, a<Mark>or a control, and a glyph held into line with a top margin oralign-middle.
upgrade does most of this. Take 0.4.1 or later, whose upgrade also handles the
accordions, tabs and surface inks automatically.
- Bump the package and run
npx foundations doctor. Its Source check counts the files still written for 0.3. - On a clean git tree, run
npx foundations upgrade --dry-runto see what moves, thennpx foundations upgradeto move it. It reads your.tsxand.jsx, writes only the files it moves something in, and the diff is exactly what it did. It moves:- a glyph first or last inside a text role into
mark/markEnd, a glyph beside a text role in a flex row into that role'smark, and an inline glyph nudged withalign-middleor a top margin into<Mark>; TypographySmallandTypographyProseListto the props they pinned;- an
Accordionof trigger-and-content items toDisclosureGroupandDisclosure, a literaldefaultValueincluded; - a
Tabsstrip, written out or built with.map, with or without panels, toTabGroup, its compact classes tosize="sm"and a box around it to a box around theTabGroup; - an
INK_ON_*in a className tostyle={inkOnSurfaceStyle(…)}; - and the imports that go with each.
- a glyph first or last inside a text role into
- Do what it marks ✖ by hand: each line names the file, the line and why it could not move the code (an accordion whose open state the app keeps, a strip beside other controls in one row, an attribute it has no place for). Look at what it marks !: those moved, and each says what to check, such as a glyph's colour or a panel's spacing.
- Run the compiler and your lint. Check what's left, e.g., an import of
a removed name fails to resolve, and
designRulesflags a glyph that is not yet a mark. - Look at the pages. No tool sees these: words that a flex gap used to separate
and now need a
{" "}, centred or right-aligned words, where an inline<Mark>keeps the glyph with them, and an avatar or anything else taller than the line (in the last case, Foundations useCAP_TRIMto align it).
Sites running the package:
- supertype.ai — Supertype, a regional-leading analytics engineering and data science consulting firm.
- viably.app — Viably, an observability-first business operating system and CRM for automation-obsessed teams.
- sectors.app — Sectors, a financial data platform and market intelligence suite built for the Indonesia and Singapore markets.
MIT. Copyright © 2026 Supertype. See LICENSE.
Published to npm as
@supertype.ai/foundations,
and installable from this repository by tag.
