@pitlane/theme
v0.4.1
Published
Type-safe styling with design tokens for Remix 3: a schema tree plus the CSS values it describes compile to a typed token accessor and a <Theme /> component.
Maintainers
Readme
@pitlane/theme
Type-safe styling with design tokens for Remix 3. createTheme compiles a schema tree and a tree of CSS values into a typed token accessor plus a <Theme /> component that installs CSS custom properties. The css, tva, combine, and cx helpers wrap remix/ui's css() mixin and enforce the theme palette at the type level.
Install
npm install @pitlane/theme
# or
vp add @pitlane/themeRequires remix@^3.0.0-rc.1 as a peer.
Quick start
Define the schema beside the tokens it describes. Token values are the CSS they become:
// app/theme.ts
import { createTheme, lightDark } from "@pitlane/theme";
import * as s from "@pitlane/theme/schema";
export let {
token: t,
raw,
Theme,
} = createTheme({
schema: {
color: s.color(),
spacing: s.scale(),
radius: s.dimension(),
shadow: s.shadow(),
animate: s.any(),
},
tokens: {
color: {
white: "#fff",
gray: { 50: "#fafafa", 900: "#171717" },
page: lightDark("#fff", "#171717"),
},
spacing: "0.25rem",
radius: { full: "999px", responsive: "clamp(0.25rem, 2vw, 1rem)" },
shadow: { card: "0 1px 2px rgb(0 0 0 / 0.07)" },
animate: { spin: "spin 1s linear infinite" },
},
});Render <Theme /> once near the root. It emits one <style data-pitlane-theme> element with the custom properties:
import { Theme } from "./theme.ts";
function App() {
return () => (
<html lang="en">
<head>
<Theme />
</head>
<body>…</body>
</html>
);
}<Theme /> declares color-scheme: light dark on :root whenever a token uses lightDark(), because light-dark() resolves to its light value when color-scheme is undeclared. Declare a narrower value yourself to override it.
Pass tokens to css() inline at each element through the mix prop. Token-mapped properties accept only the matching brand:
import { css } from "@pitlane/theme";
import { t } from "./theme.ts";
<article
mix={css({
color: t.color.page,
padding: [t.spacing(2), t.spacing(4)],
margin: 0,
// color: "#ff0000", // type error: outside the palette
"&:hover": { color: t.color.gray[900] },
})}
/>;t.spacing(4) produces calc(var(--spacing) * 4), and t.spacing.token is the unmultiplied var(--spacing). Use module-level scale(token) to multiply an ordinary dimension, duration, or number token.
Reference one token from another
A reference is a property access on the layer below, so it goes in an extend. There is no string syntax for one, which is what lets the compiler check every reference and break the build when a target is renamed:
export let {
token: t,
raw,
Theme,
} = createTheme({
schema: { palette: s.color() },
tokens: { palette: { ink: "#1c1a16", paper: "#f5f1e8" } },
}).extend(base => ({
schema: { color: s.color(), shadow: s.shadow() },
tokens: {
color: { text: base.palette.ink, surface: base.palette.paper },
// A composite is CSS text, so a reference goes in by interpolation.
shadow: { card: `0 1px 2px ${base.palette.ink}` },
},
}));The emitted declarations keep their var() indirection, --color-text: var(--palette-ink), so overriding a primitive reaches everything that references it. A mode override that references another token goes in an extend layer too, since that is where an accessor is in scope.
A layer's declarations follow the ones they reference, which moves them later in the :root block. Custom properties in one rule resolve independently of order. Declare the namespace as an empty group in the base tree to reserve its position.
Exports
createTheme({ schema, tokens, modes? })compiles a theme and returns{ token, raw, Theme, extend, select }.token, conventionallyt, mirrors the tree with brandedvar()strings.raw(ref)resolves a base value.<Theme />installs the custom properties.createTheme(DefaultTheme)accepts a published theme component and returns a derivable theme.@pitlane/theme/defaultexportsDefaultTheme, Tailwind v4 primitives without a semantic layer.css(props)isremix/ui'scss()with token-brand enforcement. Call it inline at eachmixcallsite.tva(config)creates a cva-style variant resolver.combine(...fns)composes tva components.cx(...)joins clsx-compatible class values.lightDark(light, dark)returns CSSlight-dark()text.scale(token)returns a multiplier for an ordinary dimension, duration, or number token.ThemeErrorreports structural failures: a reference whose type does not match its position, a reference to an untyped token, a variable collision, an undeclared token, a mode overriding an unknown token, and a"{a.b.c}"string left over from the pre-0.3.0 format. Invalid values raiseValidationErrorfromremix/data-schema, whoseissuesarray contains the detail.@pitlane/theme/schemaexportss.color(),s.dimension(),s.duration(),s.number(),s.easing(),s.shadow(),s.border(),s.transition(),s.gradient(),s.stroke(),s.font.family(),s.font.weight(),s.scale(),s.any(), ands.group().@pitlane/theme/dtcgis the DTCG interchange:fromDTCG(document)reads a W3C DTCG document into acreateThemeinit, andtoDTCG(theme)exports a theme as a document plus per-mode overrides.- Types include
ThemeInit,ThemeMode,ThemeResult,ThemeComponent,ThemeProps,TokenTree,ScaleFn,Tokens,TokenValue,ThemedCSSProps,ThemedCSSMixin, TVA types, and per-type token brands.
Links
License
MIT
