@cloudgrid-io/ui
The CloudGrid design system: CSS custom properties, a Tailwind v4 theme, self-hosted fonts, and the same values as machine-readable JSON.
47 React components, built on Base UI, styled only through tokens and carrying no hardcoded colour.
Install
npm install @cloudgrid-io/ui
The three font families come with it and are self-hosted, so there is no request to a font CDN in your critical path.
Use a component
import { Button, Card, PricingTiers } from "@cloudgrid-io/ui"
Components are shipped compiled, one module per component, with type declarations. The 20
that need it keep their "use client" directive, so a React Server Component app gets
exactly the client boundaries the source declares.
React is an optional peer dependency: take the stylesheets alone and nothing pulls in a component tree.
Import the CSS, in this order
@import "tailwindcss";
@import "@cloudgrid-io/ui/fonts.css";
@import "@cloudgrid-io/ui/tokens.css";
@import "@cloudgrid-io/ui/console.css"; /* optional, see below */
@import "@cloudgrid-io/ui/theme.css";
@import "@cloudgrid-io/ui/shadcn.css";
The order matters, and getting it wrong fails quietly rather than loudly.
theme.cssmaps Tailwind utilities onto the tokens. Beforetokens.csshas run there is nothing to map, so every utility resolves to nothing and the page renders unstyled instead of erroring.console.cssis a product preset, not part of the base. It re-declares the body face as the UI face, Manrope, and leaves the display face on Tanker, as the CloudGrid 2026 Figma has it (#371). The CloudGrid Console imports it; so should any product whose body type is the UI face. Everyone else leaves the line out and keeps the default. It also sets the body and label sizes to 14px (--cg-body-sm,--cg-label-size), so every library piece set in body text matches the Console's own 14px text; the caption stays 13px, and every product without the preset keeps 15px (#440). The Console's five type roles, as tokens: Bodytext-body-sm(14 / 1.46), Body Boldtext-body-sm font-bold, Smalltext-caption(13 / 1.46), H4text-heading-lg font-medium(22 / 1.2), Headlinefont-display(Tanker). A page picks a role, not a size. It has to sit aftertokens.css, which is what it re-declares: import it beforetokens.cssand the default face wins at equal specificity, silently. Its position relative totheme.cssdoes not matter.theme.cssis@theme inline, so the utilities it emits are livevar()references that resolve wherever the token ends up, and Tailwind hoists theme output above plain CSS anyway; measured with Tailwind 4,console.cssbefore or aftertheme.csscompiles byte-identical. The order above is still the recommended one, because it keeps the presets beside the tokens they change.shadcn.cssmust come after whatever:rootyour ownshadcn initwrote, because it overrides those values. Put it earlier and the stock grey palette wins.
Not using the Console's face: skip the console.css line. Not using shadcn: skip the last line. Not using Tailwind: tokens.css alone gives you every
value as a custom property.
The docs site (Starlight)
The docs site is not Tailwind. It takes three stylesheets through Starlight's customCss, in this
order, and the code-block configuration in one line:
import { cloudgridExpressiveCode } from "@cloudgrid-io/ui/expressive-code"
starlight({
customCss: [
"@cloudgrid-io/ui/fonts.css",
"@cloudgrid-io/ui/tokens.css",
"@cloudgrid-io/ui/docs.css",
],
expressiveCode: cloudgridExpressiveCode,
})
docs.css maps Starlight's --sl-* variables onto the tokens and adds a few targeted rules. It
is light only: it styles no dark scope, so the site must force the light theme and drop the
theme picker (override Starlight's ThemeProvider and ThemeSelect). It has to come after
tokens.css, which every value in it reads.
Point Tailwind at the package
Tailwind v4 ignores node_modules when it scans for classes, so utilities used only inside
a component are never generated:
@source "../node_modules/@cloudgrid-io/ui/dist";
Adjust the path to your layout. Skipping this fails quietly — the build succeeds, the page renders, and the components come out unstyled.
Surfaces flip
Put data-cg-surface on a container and every semantic token inside it resolves to its
on-surface value. Borders, backgrounds, focus rings and component internals follow, with no
dark: variants and no second stylesheet.
<div data-cg-surface="panel" class="bg-panel text-ink">
<!-- everything in here is now on brand purple -->
</div>
<div data-cg-surface="ink" class="bg-code text-ink">
<!-- and everything in here is on near-black -->
</div>
Three surfaces: the default white page, panel on brand purple, and ink on near-black.
Text is the exception. Children inherit a colour their ancestor already resolved, so a
container that sets data-cg-surface must also declare a token-based text colour — that is
what text-ink is doing above. Borders and backgrounds flip on their own. Text does not.
There is no dark mode here, and this is not a substitute for one. Dark surfaces are a property of a container, never a page-level theme.
The install panel
Every CloudGrid install panel comes from this package, content and design both. Nothing is fetched.
import { CloudGridInstall, INSTALL_PRESETS, installToolPreset } from "@cloudgrid-io/ui"
<CloudGridInstall /> // the whole panel
<CloudGridInstall preset={INSTALL_PRESETS.onboarding} onValueChange={recordTool} />
<CloudGridInstall preset={installToolPreset("cursor")} />
There are no props for the words. A page chooses what is on screen (tools, methods,
defaultTool, defaultMethod, showFetchPrompt, showFooter, or a preset) and what only
it can know (the selected tool and method, the heading level, a Continue action). Tool values
are stable identifiers, safe to record: claude-code, cursor, codex, antigravity,
gemini, chatgpt, claude-web.
CloudGridFetchPrompt and CloudGridInstallFooter render on their own. Without React, take the
strings from @cloudgrid-io/ui/install-content (INSTALL_TOOLS, INSTALL_COMMANDS and the
rest).
Entry points
@cloudgrid-io/ui/tokens.css | every value as a CSS custom property |
@cloudgrid-io/ui/console.css | product preset: body type set to Manrope at 14px (label 14px), headlines stay Tanker |
@cloudgrid-io/ui/docs.css | product preset: the Starlight docs site, light only, see below |
@cloudgrid-io/ui/expressive-code | the docs site's code blocks: Expressive Code theme and overrides, monochrome on ink |
@cloudgrid-io/ui/theme.css | maps Tailwind utilities onto the tokens |
@cloudgrid-io/ui/shadcn.css | maps shadcn's variables onto the tokens |
@cloudgrid-io/ui/fonts.css | the three self-hosted families |
@cloudgrid-io/ui/prose.css | long-form text |
@cloudgrid-io/ui/tokens.json | the same values, machine-readable |
@cloudgrid-io/ui/install-content | the install content as plain data, no React |
@cloudgrid-io/ui/email | renderEmail: the CloudGrid email layout, content in, { html, text } out, no React |
@cloudgrid-io/ui/chart | shadcn's chart (ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle) on Recharts, set to the library's defaults. Not in the root import, so a page that never draws a chart never loads Recharts |
@cloudgrid-io/ui/line-chart | LineChart: one or more series over a date axis, on the chart above. Not in the root import, for the same reason |
The JSON sets under tokens/ are DTCG and are the source the CSS is compiled from.
The CloudGrid sign as files
The socket glyph in a rounded square, as static files for a browser tab, a home screen or
a web manifest. Two colourways: mark is the brand sign (purple square, white glyph, what
the site uses) and mark-inverted is white with a purple glyph (what the Console's tab uses).
@cloudgrid-io/ui/brand/mark.svg | SVG favicon, purple |
@cloudgrid-io/ui/brand/mark-inverted.svg | SVG favicon, white |
@cloudgrid-io/ui/brand/mark-32.png, mark-inverted-32.png | PNG favicon fallback |
@cloudgrid-io/ui/brand/mark-180.png, mark-inverted-180.png | apple-touch-icon, full bleed: iOS rounds it |
@cloudgrid-io/ui/brand/mark-192.png, mark-inverted-192.png | web manifest |
@cloudgrid-io/ui/brand/mark-512.png, mark-inverted-512.png | web manifest |
A browser needs a URL, so copy them into your public folder or import them through your bundler:
// Next.js app router, e.g. app/layout.tsx
import favicon from "@cloudgrid-io/ui/brand/mark-inverted.svg"
import appleTouch from "@cloudgrid-io/ui/brand/mark-inverted-180.png"
export const metadata = {
title: "CloudGrid",
icons: { icon: favicon.src, apple: appleTouch.src },
}
<link rel="icon" href="/mark-inverted.svg" type="image/svg+xml">
<link rel="icon" href="/mark-inverted-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/mark-inverted-180.png">
Inside a page, use the component instead: <LogoMark tone="inverted" size={32} />. It draws
the same glyph from the same constant as Logo, and the files are generated from that
constant and the brand token by scripts/generate-brand-mark.mjs, so the three cannot drift.
The empty creations state
The new-user empty state for a list of someone's creations: a dashed box, a fan of thumbnails around a "+" tile, "No creations yet", one line and an Add button.
import Link from "next/link"
import { EmptyCreations } from "@cloudgrid-io/ui"
<EmptyCreations href="/drop" linkComponent={Link} level={3} />
<EmptyCreations onAdd={openComposer} />
The words and the four thumbnails come from the package; addLabel and previews override
them. It lays out by container query, so it fits whatever width it is given. The thumbnails
are inlined in the component, and also ship as files for any other use:
@cloudgrid-io/ui/brand/creations/1.webp to 4.webp (164x94, 2 to 5KB each). After
replacing a file, run node scripts/generate-creation-previews.mjs.
Pictures in a component: embed or resolve
Two components ship pictures, and they do it two ways on purpose. A new component with pictures picks one by this rule rather than by whichever example its author read last.
- Embed small decorative defaults that must work with no consumer wiring. The pictures
are inlined in the component as data URIs and ship as files under
@cloudgrid-io/ui/brand/, with a test that holds the two byte-equal.EmptyCreationsis the example: 4 thumbnails, about 13KB of files, about 18KB once inlined inempty-creations-previews.ts. It renders correctly the moment it is imported. - Resolve large sets, or any set the consumer is expected to choose. The pictures ship as
files only, and the consumer hands the component a URL for each one (
resolveSrc, or asrcper item) through its own bundler or public folder.ShowcaseScrollis the example: 19 pictures, about 205KB of files. Inlined, they would add over a quarter of a megabyte to every bundle that imports the package barrel, whether or not that page shows them.
Rough line: embed when it is a handful of pictures and under about 20KB in total, and the component is useless without them. Past that, resolve. Base64 grows a file by about a third, and an inlined picture is paid for by every consumer of the barrel rather than only by the page that renders it.
The email layout
import { renderEmail } from "@cloudgrid-io/ui/email"
const { html, text } = renderEmail({
headline: "expiring-soon",
title: "Pricing Grid expires in 5 days.",
points: ["Not opened in 25 days", "Expires Friday, October 16"],
action: { label: "Keep it", url: "https://my.cloudgrid.io/entities/ent_7a10?action=keep" },
note: "Kept pages don't expire.",
footer: { lines: ["You get this because you own Pricing Grid."] },
assetBaseUrl: "https://assets.example.com/email/", // where YOU serve brand/email/*
})
No dependencies and no React. Tables and inline styles for Gmail, Outlook and Apple Mail, and a plain-text part with every link written out.
The library names no host. Pictures and fonts load only from URLs the sender passes:
assetBaseUrl: serve this package'sdist/brand/email/*(the wordmark, six headline images, the grid tile) from your own domain and pass that base. Without it the email is image-free for every client: the wordmark and headline are live text, which is also what Outlook for Windows always gets.webFontsUrl: a stylesheet for Space Grotesk and Manrope, on a host you choose (GOOGLE_FONTS_EMAIL_CSSif you accept Google). Without it the system stack renders.items[].imageUrl: your own picture URL, used as given.
Previews of every frame, as Apple Mail, Gmail and Outlook: /gallery/email in the registry.
Versioning
Semver. Token value changes are minor; removals are major.
Licence
See the repository.