Styling
In a clone of the repo, npm run demo:styling renders each style string next to what GPUI made
of it.
What works and what does not
There is no CSS engine. The renderer parses the style attribute into a plain object and sends it
to GPUI, which lays everything out with flexbox (or a simple grid) in logical pixels. <style>
blocks work for class rules, which are compiled the same way.
<div class="btn" style="padding: 8px 16px; border-radius: 8px">Click</div>
<style>
.btn { background-color: #313244; color: #cdd6f4; }
.btn:hover { background-color: #45475a; }
</style>
What works
- Inline
styleandstyle:directives. Box shorthands (padding: 8px 16px,margin,border-width,border-radius,gap,inset) expand to GPUI's longhands. <style>rules made of classes, at most one tag, and:hoveror:active:.btn,.btn.primary,.a, .b,div,div.card. They're scoped per component, like Svelte's DOM output. The rule with more classes wins; with equal classes, a rule with a tag beats one without; after that, the later rule wins. Inlinestylealways wins.class:directives and dynamic class strings restyle live.hover="..."andactive="..."attributes, for hover and press styles set directly on the element.var(--token)andvar(--token, fallback)in class rules and inline styles, on any property. Values come fromset_css_vars({ token: '#fff' }), and a theme switch is one call (see Theming with CSS variables).- Any CSS colour syntax: hex,
rgb(),hsl(), named colours. display: flex | gridand the flexbox properties,position: absolute,overflow: hidden | scroll | auto,opacity,cursor,white-space,text-overflow,font-*,text-align,text-decorationandvisibility: hidden.
What doesn't work
- Units other than
pxandrem(em,vh,vw), and%orautoanywhere exceptwidth,height,min-*andmax-*. These are dropped with a warning, somargin: 0 autocan't center anything. - Shorthands GPUI has no field for:
flex: 1(useflex-grow: 1),border: 1px solid #fff(useborder-widthandborder-color),background: linear-gradient(...)andbox-shadow. - Unitless
line-height:line-height: 1.5means 1.5 px, so give it a unit. display: nonehas no effect. Use{#if}, orvisibility: hiddento keep the box.- Any other selector: combinators (descendant,
>,+,~), pseudo-classes besides:hoverand:active, pseudo-elements,:global, attribute selectors,@mediaand nesting. The compiler refuses them with a warning. transform,transition,z-indexandletter-spacingare ignored without a warning.- Only text properties inherit from a parent (colour, font,
line-height,text-align,white-space,text-overflow,line-clamp,text-decoration), plususer-selectandselection-color. - Assume the rest of CSS is missing too.
Working, standard CSS semantics
Values are logical pixels unless noted, and rem is 16 px, GPUI's fixed rem size. Only the listed
keywords are recognised.
- Layout:
display: flex | grid;flex-direction: row | column;flex-wrap: wrap | wrap-reverse | nowrap;flex-grow,flex-shrink,flex-basis;gap,row-gap,column-gap. - Alignment:
align-items: center | start | end;align-self: center | start | end | stretch | baseline;align-content: center | start | end | space-between | space-around | space-evenly | stretch | normal;justify-content: center | start | end | space-between | space-around. Theflex-prefixed forms work too.justify-contenthas nospace-evenly. - Sizing:
width,height,min-width,min-height,max-width,max-heightin px,%orauto. - Spacing:
padding,marginand their four sides, with 1 to 4 value shorthands. - Position:
position: relative | absolute;top,right,bottom,left;inset. - Overflow:
overflow,overflow-x,overflow-yashidden | scroll | auto.autoships asscroll, and since GPUI paints no scrollbars the two behave the same. - Paint:
background-color,color,border-colorin any CSS colour syntax;opacity;border-widthand its four sides;border-radiusand its four corners, with 1 to 4 value shorthands. - Text:
font-size,font-family,font-weight(100–900,normal,bold);text-align: left | center | right | start;line-heightin px or rem;white-space: nowrap | normal;text-overflow: ellipsis;text-decoration: underline | line-through | none. - Visibility:
visibility: hiddenpaints nothing, keeps the layout box and leaves the tab order. - Interaction:
cursorwith the CSS keyword set (pointer,text,grab,grabbing,move,crosshair,not-allowed, the*-resizefamily,copy,alias,context-menu,default);pointer-events: none | auto;user-select: none;:hoverand:activerules in<style>.
Working, non-standard CSS semantics
These are accepted, but they mean something different from what CSS would.
- Unitless numbers are pixels:
padding: 12,font-size: 18. max-content,min-contentandfit-content(...)onwidth/height/min-*/max-*read asauto.grid-template-columns: 3andgrid-template-rows: 2take a number: the count of equal tracks.grid-column-minandgrid-row-min(min-content | max-content) are GPUI's own properties for each track's minimum.justify-content: between | aroundare aliases forspace-betweenandspace-around.position: fixedlays out exactly likeabsolute.text-overflow: ellipsis-starttruncates from the left.line-clamp: Nworks unprefixed, with nodisplay: -webkit-boxneeded.font-weightalso takesthin,extralight,light,medium,semibold,extrabold,blackand any number from 1 to 1000.hover="..."andactive="..."are attributes that take the same CSS text asstyleand apply while the pointer is over, or pressing, the element. They're an inline form of:hoverand:active:<div style="color: #ccc" hover="color: #fff">equals.x { color: #ccc }plus.x:hover { color: #fff }. When both exist, the attribute wins.selection-colorsets the colour of the text-selection highlight, the job CSS gives to::selection.- Mouse events never bubble, and with
pointer-eventsunset, an element that paints a background or isposition: absoluteblocks clicks to whatever is behind it. Puthitbox="self"on the clickable element, and the renderer setspointer-events: noneon every descendant that has no listener of its own,<img>and<svg>included, so badges and icons pass the click through. A few descendants keep their hitbox: a child with its own handler, focusable elements (tabindexorautofocus), scroll containers, and the elements that take input themselves (input,textarea,code,diff,markdown,virtual-list,anchored,canvas). Apointer-eventsyou set yourself still wins. onclickis the primary button alone, as in the DOM. A right or middle click isonauxclick, wheree.isRightClicktells the two apart ande.x/e.yare window coordinates, so a menu rendered through<Portal>can be placed right at them. macOS's ctrl+click arrives as an ordinaryonclickwithe.modifiers.ctrlset, so a handler that wants the platform's secondary click checks for it.hitbox="self"shields aux clicks like any other.- Animation goes through the
motion={{ initial, animate, transition }}prop, which animatesleft,top,width,height,opacityandborderRadiusnatively, with durations in seconds.
Theming with CSS variables
Class rules are compiled ahead of time, but a var() in one resolves at runtime against a map you
control. A palette is one object, and a theme switch is one call:
<script>
import { set_css_vars } from 'gpuix-svelte';
const LIGHT = { surface: '#fbf7ef', ink: '#2a251f', border: '#e2d8c4' };
const DARK = { surface: '#231f1b', ink: '#ece3d3', border: '#36302a' };
let { dark } = $props();
$effect(() => set_css_vars(dark ? DARK : LIGHT));
</script>
<div class="card">…</div>
<style>
.card { background-color: var(--surface); color: var(--ink); border-color: var(--border); }
.card:hover { border-color: var(--ink); }
.badge { padding: var(--badge-pad, 2px 6px); }
</style>
set_css_vars restyles exactly the elements whose style read a variable, in one batch. A call
whose values are all already in force does nothing. var() works on any property, pixel-only ones
included, and inside inline style= too. Keys work with or without the --, and setting one to
null unsets it.
var(--name, fallback) uses the fallback while the variable is unset. Without a fallback, the
declaration is dropped and the name is warned about once, when the frame that read it ships.
Because the warning waits for that frame, variables set by an $effect like the one above (which
runs after Svelte has styled the tree) are never reported.
Substrate, a second-brain app built on this renderer, themes itself exactly this way: one palette
object per mode, handed over from its root component in an $effect.