Styling engines
A styling engine lets a team share one set of tokens, utilities and component recipes across every component, instead of repeating colours and spacings in each sheet. Unframework works with any engine whose Vite plugin registers its modules, such as virtual:styleframe, or, through an option, generates them as files: an import of such a module reaches all seven outputs as you wrote it, and the engine's own plugin resolves it in each framework's build. The compiler knows no engine, and no engine ships with Unframework.
How an engine plugs in
An import whose specifier starts with virtual:, Vite's convention for a module a plugin registers, passes through. Two forms do:
- Named imports, called in a
class:class={chip(tone)}, alone, in an array or in a conditional. The engine's function returns class names. - A side-effect import:
import "virtual:fixture-engine.css", the engine's stylesheet or a setup module, whatever its suffix.
The integration tests prove it with a small fixture engine of their own, written for nobody's compiler. Its Vite plugin registers a module with one function, chip, and a stylesheet:
export function fixtureEnginePlugin(): Plugin {
return {
name: "fixture-engine",
enforce: "pre",
resolveId: (id) => (id === MODULE ? `\0${MODULE}` : id === STYLESHEET ? STYLESHEET : null),
load: (id) => (id === `\0${MODULE}` ? CODE : id === STYLESHEET ? CSS : null),
};
}
A component imports both:
import { chip } from "virtual:fixture-engine";
import "virtual:fixture-engine.css";
export default function Chip({ label, tone }: { label: string; tone?: "plain" | "accent" }) {
return <span class={chip(tone)}>{label}</span>;
}
Every output keeps the two imports as written, after its own sorted imports and in source order, since an engine's setup module or two engines' sheets can depend on their order:
import type { CSSProperties } from "react";
import { chip } from "virtual:fixture-engine";
import "virtual:fixture-engine.css";
export interface ChipAttributes {
className?: string;
style?: CSSProperties;
}
export default function Chip({
label,
tone,
className,
style,
...attributes
}: { label: string; tone?: "plain" | "accent" } & ChipAttributes) {
return (
<span className={cx(chip(tone), className)} style={style} {...scopeAttributes(attributes)}>
{label}
</span>
);
}
/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
const names: string[] = [];
for (const part of parts) {
if (typeof part === "string") {
if (part) names.push(part);
} else if (part && typeof part === "object") {
for (const [key, on] of Object.entries(part)) {
if (on) names.push(key);
}
}
}
return names.join(" ");
}
/** The scope attributes of the styled parents that render this component, for its root. */
function scopeAttributes(props: object): Record<string, ""> {
const scope: Record<string, ""> = {};
for (const key of Object.keys(props)) {
if (key.startsWith("data-uf-")) scope[key] = "";
}
return scope;
}
Angular's template reads the function through a class member of the same name, protected readonly chip = chip. Every other target calls it where the class is.
Add the engine's plugin beside Unframework's in each framework's Vite configuration. The unplugin never claims a pass-through id, so the two plugins can sit in either order. We list the engine's first, as Styleframe's installation guide does:
import unframework from "@unframework/unplugin/vite";
import vue from "@vitejs/plugin-vue";
import styleframe from "styleframe/plugin/vite";
export default {
plugins: [styleframe(), unframework({ target: "vue" }), vue()],
};
Keep in mind that an engine's module is code its plugin generates, and may import the engine's runtime: Styleframe's recipes call @styleframe/runtime. That's the one exception to "an output imports only its framework". Everything else in an output still comes from its framework or from an inline helper.
The passThrough option
Some engines write files instead of registering virtual modules, such as styled-system/css. The passThrough option, on compile() and on the unplugin, lists the specifier prefixes that pass through. It defaults to ["virtual:"], and a list replaces the default, so keep virtual: in it if you still use such a module:
unframework({ target: "vue", passThrough: ["virtual:", "styled-system/"] });
A prefix that is empty, or starts with ., /, \ or a drive letter such as C:, throws, when the plugin is created and on every compile() call, since such a specifier names a file of your project: a sibling stylesheet or a child component. An import the compiler reads itself, from unframework, from a framework, from a .uf.tsx file or from a relative path, never passes through, whatever the prefixes.
An import that matches no prefix keeps the codes it had: UF1002, or UF5003 for a preprocessor's suffix such as .scss.
What doesn't pass through
Any other use of a module that passes through is UF5006: a default, namespace or type-only import, a name read outside a class part, such as in a condition or in setup code, and a name nothing calls, whose import would reach every output unused. The compiler can't analyse what such code does, so it would reach seven frameworks unchecked:
// UF5006 unsupported-virtual-import: a name a pass-through import binds is called outside a `class`.
import { badge } from "virtual:recipes";
export default function Badge({ label }: { label: string }) {
return <span title={badge()}>{label}</span>;
}
That's UF5006 at badge, with the help "Call it in a class, as in class={badge()}." A local name that every output would declare beside its own, such as $btn or ngButton, is UF2003.
Styleframe
Styleframe is the engine the corpus tests with, beside the fixture engine. Its configuration declares the tokens, utilities, recipes and themes, and its Vite plugin registers virtual:styleframe, with a function per recipe, and virtual:styleframe.css, the stylesheet. The examples below use the corpus's configuration, tests/integration/styleframe.config.ts.
Recipes
A recipe is a set of classes with variants. The configuration declares it:
recipe({
name: "button",
base: { borderRadius: "@radius.md" },
variants: {
size: { sm: { padding: "@spacing.sm" }, lg: { padding: "@spacing.lg" } },
tone: {
neutral: { backgroundColor: "@color.surface", color: "@color.text" },
primary: { backgroundColor: "@color.primary", color: "@color.on-primary" },
},
},
defaultVariants: { size: "sm", tone: "neutral" },
});
A component calls the recipe in its class, with its variants from props. An absent prop takes the recipe's default variant:
import { button } from "virtual:styleframe";
import "virtual:styleframe.css";
export default function Button({
label,
size,
tone,
}: {
label: string;
size?: "sm" | "lg";
tone?: "neutral" | "primary";
}) {
return (
<button type="button" class={button({ size, tone })}>
{label}
</button>
);
}
<script setup lang="ts">
import { button } from "virtual:styleframe";
import "virtual:styleframe.css";
const {
label,
size = undefined,
tone = undefined,
} = defineProps<{ label: string; size?: "sm" | "lg"; tone?: "neutral" | "primary" }>();
</script>
<template>
<button type="button" :class="button({ size, tone })">{{ label }}</button>
</template>
Each target keeps the two imports as the fixture engine's are kept, and calls button(…) with the two props in its own class binding.
A recipe's classes can sit beside a scoped sheet's on one element, in an array:
import { button } from "virtual:styleframe";
import "virtual:styleframe.css";
import "./Card.css";
export default function Card({ title }: { title: string }) {
return (
<article class={["card", button({ size: "lg", tone: "primary" })]}>
<h2 class="title">{title}</h2>
</article>
);
}
Styleframe's stylesheet is a side-effect import on every target, so it loads when the component's module loads. The scoped sheet loads with the module on five targets, and when the component first renders on Angular and Qwik, which attach it through styleUrl and useStyles$ (Sheet order). Card.css sets the width and the border, and the recipe the padding and the colours, so neither sheet's order decides what renders. We recommend keeping them apart the same way.
Tokens
Styleframe's stylesheet declares each token as a custom property on :root, such as variable("color.primary", …) as --color--primary. A scoped sheet reads them:
import "virtual:styleframe.css";
import "./Panel.css";
export default function Panel({ text }: { text: string }) {
return (
<section class="panel">
<p class="body">{text}</p>
</section>
);
}
.panel {
width: 240px;
padding: var(--spacing--lg);
border-radius: var(--radius--md);
background-color: var(--color--primary);
}
.body {
margin: 0;
color: var(--color--on-primary);
}
Utilities
Styleframe's utility classes go in a static class, its arbitrary values included:
import "virtual:styleframe.css";
export default function Tag({ label }: { label: string }) {
return (
<span class="_padding:lg _background-color:primary _color:on-primary _border-radius:[4px]">
{label}
</span>
);
}
Styleframe's scanner finds the utilities a project uses in its sources, and registers them, arbitrary values included. It runs only when the plugin's scanner.content lists the files to read, and its JSX extractor reads only className. So give it an extractor that reads a .uf.tsx file's class too, as the integration harness does:
/**
* The utility classes a `.uf.tsx` source writes in a `class`, for Styleframe's scanner, whose JSX
* extractor reads only `className`: `_padding:lg`, `_border-radius:[4px]`.
*/
export function sourceUtilities(content: string, file: string): string[] {
if (!file.endsWith(".uf.tsx")) return [];
return [...content.matchAll(/\bclass="([^"]*)"/g)].flatMap(([, value]) =>
value!.split(/\s+/).filter((name) => name.startsWith("_")),
);
}
The harness then passes the sources and the extractor to the plugin. It also turns off minify, which shortens utility class names in a production build by default:
styleframe({
entry: config,
silent: true,
minify: false,
dts: { enabled: false },
// No `*.styleframe.ts` extends the configuration: only the root is searched.
include: [join(ROOT, "*.styleframe.ts")],
scanner: { content: [join(ROOT, "cases/**/*.uf.tsx")], extractors: [sourceUtilities] },
}),
Theming
The configuration can set tokens under a media query, so a page in the dark colour scheme takes the dark values:
// The dark colour scheme, which a case opts into with `"colorScheme": "dark"`.
media("(prefers-color-scheme: dark)", ({ selector }) => {
selector(":root", ({ variable: set }) => {
set(colorSurface, "rgb(31, 41, 55)");
set(colorText, "rgb(249, 250, 251)");
});
});
import "virtual:styleframe.css";
export default function Note({ text }: { text: string }) {
return <p class="_padding:md _background-color:surface _color:text">{text}</p>;
}
A theme applies the same values to an element with data-theme="dark" and its descendants, which a prop can toggle:
// The dark theme, which `data-theme="dark"` applies to an element and its descendants.
theme("dark", ({ variable: set }) => {
set(colorSurface, "rgb(31, 41, 55)");
set(colorText, "rgb(249, 250, 251)");
});
import "virtual:styleframe.css";
export default function Themed({ text, dark = false }: { text: string; dark?: boolean }) {
return (
<section
data-theme={dark ? "dark" : "light"}
class="_padding:md _background-color:surface _color:text"
>
{text}
</section>
);
}
Licensing
Styleframe Community needs no licence key, so neither does Unframework's CI, which never sets STYLEFRAME_KEY. Only Styleframe Pro checks a licence: a production build of a configuration that uses a Pro feature fails without a key, with "A valid styleframe Pro license key is required to build in production mode." A harness test builds the corpus's configuration for production with no key, and checks that a configuration flagged as Pro fails.
The decisions behind this page are ADR-0004 and ADR-0064. A component's own stylesheet is on Styles.
Styles
A component's styles are a plain CSS file beside it. The compiler scopes the sheet once, with Vue's attribute algorithm, and every framework attaches the same scoped sheet its own way.
Targets
What each of the seven targets writes, the capability matrix that declares where a framework differs, how each target takes a component's events, and how Angular hosts a component.