Global foundation and fonts
An app built on @craft-ts/style has no styles.css. What used to go there falls into three layers, and craft-ts writes the first two for you:
| layer | written by | what it holds |
|---|---|---|
craft.reset | craft-ts, on by default | a modern reset |
craft.base | craft-ts, on by default | colour scheme, focus ring, reduced motion, selection, form accent |
craft.global | your app, craftGlobalStyles | your theme variables on :root, element defaults (body, a, …) |
They come before the component layers. The full order is fixed by the emitter, whatever order your modules are imported in:
@layer craft.reset, craft.base, craft.tokens, craft.global,
craft.components, craft.variants, craft.overrides;Every layer is named craft.*. A third-party stylesheet that arrives unlayered wins over all of them — that is how CSS treats unlayered styles — which is exactly why one should be rare, deliberate and attested.
The reset
On by default. It sets box-sizing: border-box everywhere, removes default margins, makes media blocks that never overflow (max-inline-size: 100%), lets form controls inherit the document font, balances headings and avoids orphans in paragraphs (text-wrap), wraps long words (overflow-wrap: anywhere) and stops mobile browsers from inflating text.
It is written with the typed vocabulary, like any sheet (libs/style/src/lib/global/reset.ts). Turning it off is a deliberate choice:
craftStyle({ reset: false });The base
Also on by default (base: false to opt out). It states once, for the whole document, what components used to have to remember one by one:
color-schemefollows theschemeaxis, so scrollbars and form controls turn dark with the page;- every
:focus-visiblegets a visible ring; - scrolling is smooth only for users who did not ask for less motion, and under
prefers-reduced-motionevery animation and transition collapses to an instant. The guard is!importantin the earliest layer, the one place that beats every later layer, so no component can forget it; accent-colorand::selectioncome from the theme.
The colours and sizes are typed theme variables, exported as craftBase: accent, focusRing, focusWidth, focusOffset, selectionBg, selectionInk. Re-theme them from your own global styles (below).
Your app's globals
import {
color,
craftBase,
craftGlobalStyles,
darkOf,
definePalette,
fontFamily,
scheme,
set,
when,
} from '@craft-ts/style';
export const brand = definePalette('brand', {
accent: { primary: { light: '#1b5fa1', dark: '#6fb2f0' } },
text: { strong: { light: '#111318', dark: '#f2f4f8' } },
});
craftGlobalStyles('app', {
// The foundation's focus ring and form accent, in the app's colours.
root: [
set(craftBase.focusRing, brand.accent.primary),
set(craftBase.accent, brand.accent.primary),
when(scheme.dark, [
set(craftBase.focusRing, darkOf(brand.accent.primary)),
set(craftBase.accent, darkOf(brand.accent.primary)),
]),
],
elements: {
body: [fontFamily(bodyFont), color(brand.text.strong)],
},
});root goes on :root, elements on tag names — the only selectors accepted. Anything narrower than an element belongs to a component sheet. The items are the same as in a sheet: generated properties, set(...), when(...), and pseudo.*. when on a state axis becomes an attribute on :root, which is how a theme toggle is written (when(theme.dark, [...]) → :root[data-theme='dark']).
no-raw-css-value applies here as everywhere else: no literal reaches a helper.
Fonts
import { defineFont, googleFont } from '@craft-ts/style';
export const bodyFont = defineFont('body', {
family: 'Chivo',
source: googleFont({ weights: [400, 600, 700] }),
display: 'swap',
fallback: 'system-ui',
});defineFont replaces the three things a styles.css used to carry:
- the
@import url(fonts.googleapis…)— the plugin injects apreconnectto both Google origins, then the stylesheet preloaded and applied, into<head>ofindex.html. An@importinside the CSS could only be discovered once the CSS itself had downloaded; - the
@font-faceblocks —localFont({ files: [...] })emits them, and preloads the.woff2files; - the
* { font-family: … !important }— the returned token is a family stack, used once withfontFamily(bodyFont)onbodyand inherited from there. Form controls inherit it through the reset.
A server renderer that writes <head> itself reads the same tags as HTML:
import head from 'virtual:craft-style-head';Fallback without layout shift
While the web font loads, the fallback font is shown, and the text jumps when the real one arrives. adjustFallback builds a "<family> Fallback" face from a local font, resized so both occupy the same space:
defineFont('body', {
family: 'Chivo',
source: googleFont({ weights: [400, 700] }),
fallback: 'system-ui',
adjustFallback: {
// The web font's metrics, in font units. Capsize publishes them for every
// Google font (@capsizecss/metrics).
metrics: {
unitsPerEm: 1000,
ascent: 954,
descent: -250,
lineGap: 0,
xWidthAvg: 505,
},
// Optional, Arial by default: `face: { local: 'Helvetica', metrics }`.
},
});size-adjust matches the average glyph width, so lines break at the same place; ascent-override, descent-override and line-gap-override keep the line height.
What this replaces in the ESLint rules
require-focus-visible and require-reduced-motion used to read each component's CSS text to check that a focus ring and a reduced-motion branch were there. The base layer guarantees both for the whole document, so a component no longer has anything to prove.