Getting started
Install, derive, and use a theme
xtyle derives a complete, internally consistent set of 311 design tokens (surfaces, text, accents, type, spacing, and more) from an algorithm and a few anchor colors. This page covers installing it, deriving a theme, using the result with or without a framework, and writing your own algorithm.
Install
xtyle is three packages. @xtyle/core is the engine: the
derivation, the xtyle command line, and the web components
(behind the @xtyle/core/elements entry). Add a binding only if
you build with that framework.
npm install @xtyle/core
# optional framework bindings
npm install @xtyle/svelte
npm install @xtyle/astroDerive a theme
An algorithm maps your anchors and knobs to a full token register. The
built-in algorithms ship with the engine under
@xtyle/core/algorithms; import one and call
derive.
import { derive, emit } from "@xtyle/core";
import { xtyleDefault } from "@xtyle/core/algorithms";
const register = derive(xtyleDefault, {
constraints: { "--bg-0": "#0b0d12", "--fg-0": "#e6e9ef", "--accent": "#6ea8fe" },
knobs: { scheme: "dark", contrastBand: "aa", vibrancy: 0.5 },
});
const css = emit(register, "css"); // a :root { … } block
const json = emit(register, "json"); // a flat token mapOr derive from the command line without writing any code:
npx xtyle derive --bg "#0b0d12" --accent "#6ea8fe" --format css > theme.cssThe built-in algorithms, each a named export and an id:
xtyleDefault
the neutral default
xtyleHc
high contrast
xtyleQuiet
restrained
xtyleLoud
vibrant
nxiNite
Day/Night, time-aware
Anchors
anchors is { bg, fg, accent }, each a hex
string. They seed the derivation; every other token solves around them.
Pass as few or as many as you like. Pin nothing and the algorithm's
own defaults fill in, or pin individual tokens through
constraints and the rest re-derive to fit.
Knobs
Knobs adjust how an algorithm derives. The common ones, plus any an
algorithm declares itself (nxi-nite adds hour):
Use the theme
A derived theme is plain CSS custom properties. Write the emitted CSS into
your page and the cascade applies it; nothing has to be running at view
time. In the browser, @xtyle/core/dom writes a register straight
onto a live element.
import { apply } from "@xtyle/core/dom";
apply(register); // write onto :root
apply(register, { target: previewEl }); // scope to one element
apply(register, { persistKey: "theme" }); // and persist itRun the engine live only when an input changes at runtime: a theme editor, or a time-aware algorithm like nxi-nite re-deriving as the hour moves. Otherwise the engine's job ends once the CSS is written.
Colors outside CSS
Tokens derive as color-mix() and oklch(), which the cascade
understands and nothing else does: an SVG presentation attribute, a canvas
fillStyle, or a WebGL uniform. resolveColor lets the browser
compute the value against the live theme and hands back plain sRGB, either from an
expression (its var() references resolve against an optional context element)
or from a property read off an element.
import { formatColor } from "@xtyle/core";
import { resolveColor } from "@xtyle/core/dom";
const shade = resolveColor("color-mix(in oklab, var(--yellow), white 20%)");
if (shade) flood.setAttribute("flood-color", formatColor(shade, "hex"));
const accent = resolveColor(card, "--accent"); color-mix(in oklab, var(--yellow), white 20%) on this theme:
(needs JavaScript)
Components
The token register is the contract: components read it with
var(--token) and never raw colors or magic numbers, so any valid
theme styles them with no extra work. Use the raw custom elements, or a
framework binding.
import "@xtyle/core/elements"; // registers <xtyle-button>, <xtyle-card>, …
import { install } from "@xtyle/core/install";
install({ constraints: { "--accent": "#6ea8fe" } }); // derive, apply, and land the component + effect sheets
The entry declares every tag in HTMLElementTagNameMap, so
document.createElement("xtyle-segmented") and
querySelector("xtyle-switch") come back as their element classes and
their properties are typed, with no casts.
In a browser, install is the whole bootstrap: it derives and applies
the theme, then puts the component and effect sheets in the document. Deriving and
applying alone looks right until a light-DOM component (render: "light"
in its manifest) draws without its rules or a data-fx spec does nothing.
Each call takes a claim on the sheets and uninstall() releases one, so two
apps can share a page.
import { Button, Card } from "@xtyle/svelte"; @xtyle/svelte and @xtyle/astro wrap the same elements;
each depends only on @xtyle/core.
Focus traps
Built bare or through Svelte, a component renders its native control inside a shadow
root. A focus trap written against querySelectorAll and
document.activeElement then sees only the hosts: it finds nothing to
focus and never sees the edge it should wrap at. focusableIn walks into
shadow roots and through slots in tab order, and activeLeaf returns the
control that actually holds focus. Dialog, Sheet, and a modal
Popover already trap focus; these are for chrome of your own.
import { focusableIn, activeLeaf } from "@xtyle/core/dom";
panel.addEventListener("keydown", (event) => {
if (event.key !== "Tab") return;
const ring = focusableIn(panel);
const active = activeLeaf();
const edge = event.shiftKey ? ring[0] : ring[ring.length - 1];
if (active !== edge) return;
event.preventDefault();
(event.shiftKey ? ring[ring.length - 1] : ring[0]).focus();
});Bundlers
Components render their chrome through a sandboxed fragment runtime, and that runtime
loads a WebAssembly module of its own. Vite's dependency pre-bundling rewrites the
runtime's JavaScript into node_modules/.vite/deps/ without carrying the
wasm along, so the fetch 404s and every element mounts as an empty box. Tokens still
apply, which makes it read as a styling problem rather than a loading one.
// vite.config.ts
import { viteExcludes } from "@xtyle/core/vite";
export default defineConfig({
optimizeDeps: { exclude: [...viteExcludes] },
});
Excluding @xtyle/core alone is not enough: the runtime is reached
transitively and stays pre-bundled, so the whole chain has to be named. That is xtyle's
dependency graph, not yours, which is why the list ships from
@xtyle/core/vite and moves when the graph does. Other bundlers that rewrite
module locations can need the same treatment; the symptom is always an element that
registers, paints nothing, and reports a wasm module whose first bytes are HTML.
The other route needs no bundler config at all, and it covers every bundler rather than one: hand xtyle a QuickJS build that carries the wasm inline, and there is no separate asset left to lose.
// main.ts — no bundler config at all
import { setSandboxVariant } from "@xtyle/core";
import variant from "@jitl/quickjs-singlefile-browser-release-sync";
import "@xtyle/core/elements";
setSandboxVariant(variant);
Install the variant at the version quickjs-emscripten-core resolves to.
A mismatched one loads and then answers every sandbox call with
QuickJSContext had no callback with id 0, which reads as an element that
renders nothing. The trade is the ordinary one and it is yours: inline costs every
visitor the bytes up front and cannot be cached on its own, while a separate asset is
smaller but has to survive what your bundler does to module locations.
Write an algorithm
An algorithm is a xript mod: a directory with a mod-manifest.json
and code. Build on the shared xtyle derivation with
defineXtyleAlgorithm (declare taste; the house derivation does the
rest), or write one from scratch with defineAlgorithm.
// algorithms/sunrise/mod-manifest.json declares the mod;
// algorithms/sunrise/src/mod.ts defines it:
import { defineXtyleAlgorithm } from "@xtyle/core/authoring";
defineXtyleAlgorithm({
id: "sunrise",
anchors: { bg: "#1a1410", accent: "#ff9e5e" },
vibrancy: 0.7,
});
Drop the directory in, build the mods, and the algorithm loads by id. Same
path the built-in algorithms take. An algorithm that derives in
stages lists ordered passes; nxi-nite is a worked example, layering
a time-of-day shift onto the base derivation.
See it run
Questions or contributions live on