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

What doesn't work

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. The flex- prefixed forms work too. justify-content has no space-evenly.
  • Sizing: width, height, min-width, min-height, max-width, max-height in px, % or auto.
  • Spacing: padding, margin and their four sides, with 1 to 4 value shorthands.
  • Position: position: relative | absolute; top, right, bottom, left; inset.
  • Overflow: overflow, overflow-x, overflow-y as hidden | scroll | auto. auto ships as scroll, and since GPUI paints no scrollbars the two behave the same.
  • Paint: background-color, color, border-color in any CSS colour syntax; opacity; border-width and its four sides; border-radius and 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-height in px or rem; white-space: nowrap | normal; text-overflow: ellipsis; text-decoration: underline | line-through | none.
  • Visibility: visibility: hidden paints nothing, keeps the layout box and leaves the tab order.
  • Interaction: cursor with the CSS keyword set (pointer, text, grab, grabbing, move, crosshair, not-allowed, the *-resize family, copy, alias, context-menu, default); pointer-events: none | auto; user-select: none; :hover and :active rules 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-content and fit-content(...) on width / height / min-* / max-* read as auto.
  • grid-template-columns: 3 and grid-template-rows: 2 take a number: the count of equal tracks. grid-column-min and grid-row-min (min-content | max-content) are GPUI's own properties for each track's minimum.
  • justify-content: between | around are aliases for space-between and space-around.
  • position: fixed lays out exactly like absolute.
  • text-overflow: ellipsis-start truncates from the left.
  • line-clamp: N works unprefixed, with no display: -webkit-box needed.
  • font-weight also takes thin, extralight, light, medium, semibold, extrabold, black and any number from 1 to 1000.
  • hover="..." and active="..." are attributes that take the same CSS text as style and apply while the pointer is over, or pressing, the element. They're an inline form of :hover and :active: <div style="color: #ccc" hover="color: #fff"> equals .x { color: #ccc } plus .x:hover { color: #fff }. When both exist, the attribute wins.
  • selection-color sets the colour of the text-selection highlight, the job CSS gives to ::selection.
  • Mouse events never bubble, and with pointer-events unset, an element that paints a background or is position: absolute blocks clicks to whatever is behind it. Put hitbox="self" on the clickable element, and the renderer sets pointer-events: none on 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 (tabindex or autofocus), scroll containers, and the elements that take input themselves (input, textarea, code, diff, markdown, virtual-list, anchored, canvas). A pointer-events you set yourself still wins.
  • onclick is the primary button alone, as in the DOM. A right or middle click is onauxclick, where e.isRightClick tells the two apart and e.x / e.y are window coordinates, so a menu rendered through <Portal> can be placed right at them. macOS's ctrl+click arrives as an ordinary onclick with e.modifiers.ctrl set, 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 animates left, top, width, height, opacity and borderRadius natively, 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.