@luwiostack/utopia
v0.1.0
Published
Fluid, viewport-relative type and space scales — CSS clamp() vars ported from utopia.fyi's own calculator.
Readme
@luwiostack/utopia
Fluid, viewport-relative type, space & grid scales as CSS custom properties — clamp() math
ported from utopia.fyi's own calculators (type, space, and grid), so a scale
you've already tuned there drops straight in. No media-query breakpoints, no JS resize listener:
each step interpolates smoothly between a size at one viewport width and a size at another.
Standalone — no dependency on @luwiostack/theme or anything else in LuwioStack. If you're already
using @luwiostack/theme for colours, mount <Utopia> alongside <Theme>; they don't know about
each other.
Install
npm install @luwiostack/utopiareact is a peer — needed only for @luwiostack/utopia/react.
Two entry points
@luwiostack/utopia— the React-free generator:generateVars,generateTextScaleVars,generateSpaceScaleVars,generateGridScaleVars,fluidClamp,DEFAULT.@luwiostack/utopia/react— the one mounting component,<Utopia config?>.
config is optional everywhere it appears — omitting it is the same as 'on', so the scale is
active by default. Pass it only to override.
Usage
import { generateVars } from '@luwiostack/utopia'
generateVars()
// → { '--step-0': 'clamp(1.125rem, ...)', '--step-1': '...', '--space-s': '...',
// '--grid-gutter': '...', '--grid-columns': '12', '--grid-max-width': '77.5rem', ... }generateVars returns a flat { [varName]: value } map — apply it however you like: spread it onto
an inline style object (scoped to one element or container), or build your own <style> block.
React
import { Utopia } from '@luwiostack/utopia/react'
// Mount once, anywhere — active by default, no props needed. Renders one inline <style>
// targeting :root, SSR-safe.
<Utopia />
function Heading() {
return <h1 style={{ fontSize: 'var(--step-2)' }}>Fluid heading</h1>
}The scale
{
"width": { "min": 360, "max": 1240 },
"rootFontSize": 16,
"units": ["vw", "cqi"],
"text": {
"fontSize": { "min": 18, "max": 20 },
"typeScale": { "min": 1.2, "max": 1.25 },
"steps": { "positive": 5, "negative": 2 },
"prefix": "step"
},
"space": {
"base": { "min": 18, "max": 20 },
"multipliers": { "negative": [0.75, 0.5, 0.25], "positive": [1.5, 2, 3, 4, 6] },
"pairs": { "oneUp": true, "custom": [["xs", "m"]] },
"prefix": "space"
},
"grid": {
"columns": 12,
"gutter": { "min": 18, "max": 40 },
"columnMaxWidth": 60,
"prefix": "grid"
}
}text / space / grid compile viewport-relative type, space, and grid scales straight to
clamp(). text emits one clamp() per step, named by signed offset from the base — --step-0,
--step-1, --step--1, … — sized as baseSize * ratio^step (the min ratio at width.min, the max
ratio at width.max); text.fontSize doubles as space's own base size below. space emits
t-shirt-named tokens (--space-xs, --space-s, --space-m, …) built from multipliers of a base
size, which defaults to text.fontSize when space.base isn't given directly. By default space
also emits "one-up" pairs for the gaps between adjacent tokens (--space-s-m) — set
pairs.oneUp: false to skip them, or add pairs.custom: [["s", "xl"]] for named pairs beyond the
adjacent ones. grid emits --grid-gutter (a clamp(), the fluid column gap and outer container
padding), --grid-columns (a plain column count, not fluid), and --grid-max-width (a plain rem
value, derived as columns * columnMaxWidth + (columns + 1) * gutter.max — one gutter per internal
gap, plus one on each outer edge). A {min, max} pair (width, fontSize, typeScale, base,
gutter) is written whole or not at all — there's no partial pair.
Every fluid var is emitted once per unit in units (default ['vw', 'cqi']) — the first is
unsuffixed (--step-0), every other gets a trailing -${unit} (--step-0-cqi, sized off a
containing block with container-type: inline-size rather than the viewport; --grid-columns /
--grid-max-width aren't fluid, so they're emitted once regardless). It's one setting for the whole
config, shared by text, space, and grid alike, rather than something each declares on its own.
Each scale's var prefix (step / space / grid) is itself just a default — pass text.prefix /
space.prefix / grid.prefix to rename any of them.
Defaults
generateVars (and <Utopia config>) take Config = 'on' | Settings, and config is optional
everywhere — omitting it is the same as 'on', which (like {}) means "give me DEFAULT in full".
A partial Settings object merges underneath DEFAULT instead, so you only write what you want to
change:
generateVars() // DEFAULT in full
generateVars({ text: { fontSize: { min: 16, max: 18 } } }) // DEFAULT, with just fontSize overriddenwidth is required as soon as text, space, or grid is set (by you or by DEFAULT) — in
practice this only matters if you build one of them by hand without going through 'on'/{}/a
merge, since DEFAULT always supplies one. Pass text: null / space: null / grid: null inside
an object form to drop just one scale, even though DEFAULT has it — independently of the other two:
generateVars({ space: null }) // DEFAULT's text and grid scales, no space scale at all
generateVars({ text: null, space: null, grid: null }) // {} — nothing to generateDEFAULT mirrors utopia.fyi's own type, space, and grid calculators — DEFAULT.grid's gutter is
that same space scale's s token (at width.min) through its l token (at width.max), and
columnMaxWidth is its xl token's max, matching utopia.fyi/grid/calculator's own example exactly.
Import DEFAULT directly to merge your own values over pieces of it yourself:
import { DEFAULT, generateVars } from '@luwiostack/utopia'
generateVars({ ...DEFAULT, width: { min: 320, max: 1240 } })