Components

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.

A component's styles let it look the same wherever it's used, without its rules leaking onto the rest of the page. In Unframework they're a plain CSS file beside the component, imported for its effect. The compiler scopes the sheet once, the way Vue's <style scoped> does, and every framework receives the same scoped sheet, byte for byte. So a .title rule in one component never reaches another component's .title, and the targets render the same pixels, apart from the sheet order that Angular and Qwik declare (below).

A sibling stylesheet

Import a .css file from the component's module, and write its selectors as if the component were the whole page:

Badge.uf.tsx
import "./Badge.css";

export default function Badge({ label, count }: { label: string; count: number }) {
  return (
    <span class="badge">
      <span class="label">{label}</span>
      <span class="count">{count}</span>
    </span>
  );
}
Badge.css
.badge {
  display: inline-flex;
  align-items: center;
  column-gap: 6px;
  padding: 4px 10px;
  border-radius: 12px;
  background-color: rgb(30, 64, 175);
  color: rgb(255, 255, 255);
}

.badge .label {
  font-weight: 700;
}

.count {
  padding: 0 6px;
  border-radius: 8px;
  background-color: rgb(255, 255, 255);
  color: rgb(30, 64, 175);
}

The compiler writes the scoped sheet as a second output file, named after the source, Badge.uf.tsx.css. Every selector now ends in the component's scope attribute, and every element the component renders carries it:

Badge.uf.tsx.css
.badge[data-uf-d9cef2e7] {
  display: inline-flex;
  align-items: center;
  column-gap: 6px;
  padding: 4px 10px;
  border-radius: 12px;
  background-color: rgb(30, 64, 175);
  color: rgb(255, 255, 255);
}

.badge .label[data-uf-d9cef2e7] {
  font-weight: 700;
}

.count[data-uf-d9cef2e7] {
  padding: 0 6px;
  border-radius: 8px;
  background-color: rgb(255, 255, 255);
  color: rgb(30, 64, 175);
}

Each framework attaches that file its own way:

Badge.tsx
import type { CSSProperties } from "react";
import "./Badge.uf.tsx.css";

export interface BadgeAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Badge({
  label,
  count,
  className,
  style,
  ...attributes
}: { label: string; count: number } & BadgeAttributes) {
  return (
    <span
      className={cx("badge", className)}
      style={style}
      data-uf-d9cef2e7=""
      {...scopeAttributes(attributes)}
    >
      <span className="label" data-uf-d9cef2e7="">
        {label}
      </span>
      <span className="count" data-uf-d9cef2e7="">
        {count}
      </span>
    </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;
}
TargetAttachment
Vue<style src="./Badge.uf.tsx.css">, without scoped, since the sheet is scoped already
React, Solid, Svelteimport "./Badge.uf.tsx.css", after the other imports
Astroimport "./Badge.uf.tsx.css" in the frontmatter
AngularstyleUrl: "./Badge.uf.tsx.css", with ViewEncapsulation.None
Qwikimport styles from "./Badge.uf.tsx.css?inline", attached with useStyles$(styles)

Your own Badge.css is never loaded: every output imports the scoped ./Badge.uf.tsx.css, which the unplugin serves. None of the frameworks' own scoping runs, so Svelte keeps the selectors it couldn't match statically, and Angular doesn't scope the sheet a second time.

Keep in mind that a module that imports a stylesheet declares exactly one component, which owns the sheet. A sheet imported by a module that declares several is UF5001.

How scoping works

The scope attribute is data-uf-<id>. The id is eight hex digits, a 32-bit hash of the component's name and its sheets' text. No path goes into it, so it's the same on every machine and in every target, and the golden files never change with the checkout's place. The attribute goes on every element the component renders, including the content it fills into a child's slots.

The selectors are rewritten as Vue's compileStyle({ scoped: true }) rewrites them, with data-uf-<id> in place of Vue's data-v-<id>:

SourceScoped
.a .b.a .b[data-uf-x]: the attribute goes on the last compound selector
.a::before.a[data-uf-x]::before: before the pseudo-element
.a :deep(.b).a[data-uf-x] .b
:slotted(.b).b[data-uf-x-s]
:global(.b).b
@media, @supports, @layer, @containerkept, with the rules inside scoped
@keyframes spin@keyframes spin-x, and the animation and animation-name that name it too
--gap: 4pxunchanged

The declarations reach the targets as you wrote them, apart from the keyframe names in animation and animation-name. A rule that nests other rules keeps its own declarations in a nested & rule at its top, which is scoped as any other rule.

Reaching into a child

A scoped rule matches only the component's own elements. A child component's elements carry the child's attribute, or none if the child has no sheet. So :deep() lets a rule reach inside a child: the attribute stays on the part before it.

Article.css
.article {
  padding: 12px;
  border-left: 4px solid rgb(37, 99, 235);
}

.title {
  margin: 0 0 8px;
  font-size: 20px;
}

/* Reaches the child's paragraph: the attribute stays on `.article`. */
.article :deep(.note) {
  margin: 0;
  color: rgb(185, 28, 28);
  font-style: italic;
}

/* Scoped: the child's paragraph does not carry the attribute, so it is not underlined. */
.note {
  text-decoration-line: underline;
}
Article.uf.tsx.css
.article[data-uf-15946226] {
  padding: 12px;
  border-left: 4px solid rgb(37, 99, 235);
}

.title[data-uf-15946226] {
  margin: 0 0 8px;
  font-size: 20px;
}

/* Reaches the child's paragraph: the attribute stays on `.article`. */
.article[data-uf-15946226] .note {
  margin: 0;
  color: rgb(185, 28, 28);
  font-style: italic;
}

/* Scoped: the child's paragraph does not carry the attribute, so it is not underlined. */
.note[data-uf-15946226] {
  text-decoration-line: underline;
}

Note renders <p class="note"> inside its own <div>. The :deep() rule colours it, and the plain .note rule doesn't underline it, since the paragraph doesn't carry Article's attribute.

The child's root

As Vue's scope id does, a child component's root element carries its styled parent's attribute too. So a parent can lay out the children it renders by their root's class, and nothing inside them:

Toolbar.uf.tsx
import "./Toolbar.css";
import Button from "./Button.uf.tsx";

export default function Toolbar() {
  return (
    <div role="toolbar" aria-label="Actions" class="toolbar">
      <Button text="Save" />
      <Button text="Cancel" />
    </div>
  );
}
Button.uf.tsx
// An unstyled child: its root takes its styled parent's scope attribute, and nothing inside it
// does.
export default function Button({ text }: { text: string }) {
  return (
    <button type="button" class="button">
      <span class="icon" aria-hidden="true">
        +
      </span>
      {text}
    </button>
  );
}
Toolbar.css
.toolbar {
  display: flex;
  column-gap: 8px;
  padding: 8px;
  background-color: rgb(243, 244, 246);
}

/* The child's root carries the toolbar's attribute, as Vue's scope id does. */
.button {
  padding: 6px 12px;
  border: 2px solid rgb(17, 24, 39);
  background-color: rgb(255, 255, 255);
}

/* The child's own elements do not: its icon keeps its colour. */
.icon {
  color: rgb(220, 38, 38);
}

The parent writes its attribute on the child's tag, as Vue does:

Toolbar.vue
<script setup lang="ts">
import Button from "./Button.vue";
</script>

<template>
  <div role="toolbar" aria-label="Actions" class="toolbar" data-uf-a33d603f="">
    <Button text="Save" data-uf-a33d603f="" />
    <Button text="Cancel" data-uf-a33d603f="" />
  </div>
</template>

<style src="./Toolbar.uf.tsx.css"></style>

So each <button> takes .button's border, and its icon keeps its own colour. The corpus records the rendered DOM this way, with each text node in quotes:

<div aria-label="Actions" class="toolbar" data-uf-a33d603f="" role="toolbar">
  <button class="button" data-uf-a33d603f="" type="button">
    <span aria-hidden="true" class="icon">
      "+"
    </span>
    "Save"
  </button>
  <button class="button" data-uf-a33d603f="" type="button">
    <span aria-hidden="true" class="icon">
      "+"
    </span>
    "Cancel"
  </button>
</div>

On Vue it's the framework's own attribute fallthrough. A child can't know its parents' ids, so React, Solid, Svelte, Qwik and Astro pick the data-uf-* keys out of the props they receive, with a small inline helper, scopeAttributes, and spread them on the root (child-root-scope, emulated). That reaches the same roots as Vue's fallthrough: a root element, a root component, a root <component is> or whichever branch of a root conditional renders:

Button.tsx
export default function Button({
  text,
  className,
  style,
  ...attributes
}: { text: string } & ButtonAttributes) {
  return (
    <button
      type="button"
      className={cx("button", className)}
      style={style}
      {...scopeAttributes(attributes)}
    >
      <span className="icon" aria-hidden="true">
        +
      </span>
      {text}
    </button>
  );
}

Angular renders each child inside a host element, uf-button, styled display: contents. A directive moves the parent's attributes from the host onto the child's root, but selectors still see the host in between. A rule that reaches a child's root by its place, through >, +, ~ or a structural pseudo-class such as :first-child, may match differently there, so child-root-scope is unsupported on Angular, for information (UF4003). Select a child's root by its class or its attributes, as .button does here.

Slot content

Content a parent fills into a child's slot belongs to the parent, so it carries the parent's attribute. A child styles it with :slotted(). The parent stamps the child's -s attribute on what it fills in, at compile time:

Frame.css
.frame {
  margin: 0;
  padding: 12px;
  border: 2px dashed rgb(107, 114, 128);
}

/* Matches the content a parent fills into the frame's slot, which carries the frame's `-s`. */
:slotted(.caption) {
  color: rgb(21, 128, 61);
  font-weight: 700;
}
Frame.uf.tsx.css
.frame[data-uf-9dc516fd] {
  margin: 0;
  padding: 12px;
  border: 2px dashed rgb(107, 114, 128);
}

/* Matches the content a parent fills into the frame's slot, which carries the frame's `-s`. */
.caption[data-uf-9dc516fd-s] {
  color: rgb(21, 128, 61);
  font-weight: 700;
}
Gallery.uf.tsx
import Frame from "./Frame.uf.tsx";
import Mat from "./Mat.uf.tsx";

// Content filled three ways: into a slot, through a scoped slot, and into a `<component is>`,
// whose fill takes the `-s` of each candidate whose sheet uses `:slotted()`.
export default function Gallery({ matted = true }: { matted?: boolean }) {
  return (
    <section>
      <Frame>
        <p class="caption">A quiet harbour</p>
      </Frame>
      <Frame>{{ label: ({ text }) => <p class="caption">{text}</p> }}</Frame>
      <component is={matted ? Mat : Frame}>
        <p class="caption">A matted print</p>
      </component>
      <p class="caption">Outside the frame</p>
    </section>
  );
}
Gallery.vue
<script setup lang="ts">
import Frame from "./Frame.vue";
import Mat from "./Mat.vue";

const { matted = true } = defineProps<{ matted?: boolean }>();
</script>

<template>
  <section>
    <Frame>
      <p class="caption" data-uf-9dc516fd-s="">A quiet harbour</p>
    </Frame>
    <Frame>
      <template #label="{ text }">
        <p class="caption" data-uf-9dc516fd-s="">{{ text }}</p>
      </template>
    </Frame>
    <component :is="matted ? Mat : Frame">
      <p class="caption" data-uf-09929c4a-s="" data-uf-9dc516fd-s="">A matted print</p>
    </component>
    <p class="caption">Outside the frame</p>
  </section>
</template>

A scoped slot's content takes it too, and so does the content of a <component is>, which takes the -s of every candidate whose sheet uses :slotted(). The caption outside the frame takes none, so it stays plain.

Page-wide rules

:global() writes a rule unscoped. It reaches any element on the page with that selector, inside the component or not:

Banner.css
.banner {
  padding: 8px 12px;
  background-color: rgb(254, 243, 199);
}

/* Unscoped: matches the highlight inside the child, which carries no scope attribute. */
:global(.uf-highlight) {
  padding: 0 4px;
  background-color: rgb(250, 204, 21);
  font-weight: 700;
}
Banner.uf.tsx.css
.banner[data-uf-e25060c2] {
  padding: 8px 12px;
  background-color: rgb(254, 243, 199);
}

/* Unscoped: matches the highlight inside the child, which carries no scope attribute. */
.uf-highlight {
  padding: 0 4px;
  background-color: rgb(250, 204, 21);
  font-weight: 700;
}

We recommend keeping such rules rare, and giving their classes a prefix of your own, like uf-highlight, since they apply to the whole page.

Custom properties

JSX sets a dynamic value as a custom property, and the sheet reads it. Custom properties pass through the scoper unchanged:

Stack.uf.tsx
import "./Stack.css";

export default function Stack({ gap, accent }: { gap: string; accent: string }) {
  return (
    <ul class="stack" style={{ "--gap": gap, "--accent": accent }}>
      <li class="item">One</li>
      <li class="item">Two</li>
      <li class="item">Three</li>
    </ul>
  );
}
Stack.css
.stack {
  display: flex;
  column-gap: var(--gap);
  margin: 0;
  padding: 0;
  list-style-type: none;
}

.item {
  padding: 4px 8px;
  border: 2px solid var(--accent);
}
Stack.vue
<script setup lang="ts">
const { gap, accent } = defineProps<{ gap: string; accent: string }>();
</script>

<template>
  <ul class="stack" :style="{ '--gap': gap, '--accent': accent }" data-uf-e3a22111="">
    <li class="item" data-uf-e3a22111="">One</li>
    <li class="item" data-uf-e3a22111="">Two</li>
    <li class="item" data-uf-e3a22111="">Three</li>
  </ul>
</template>

<style src="./Stack.uf.tsx.css"></style>

A custom property is a quoted key in a style object, which Attributes covers.

Layers, media queries and keyframes

At-rules are kept, and the rules inside them are scoped. A layer's order decides as it does in any sheet:

Card.uf.tsx.css
/* The order of the layers, not of the rules, decides: `theme` wins over `base`. */
@layer base, theme;

@layer theme {
  .card[data-uf-bb60e985] {
    border-color: rgb(22, 101, 52);
    color: rgb(22, 101, 52);
  }
}

@layer base {
  .card[data-uf-bb60e985] {
    padding: 12px;
    border: 3px solid rgb(185, 28, 28);
    color: rgb(185, 28, 28);
  }

  .title[data-uf-bb60e985] {
    margin: 0;
    font-size: 18px;
  }
}

So does a media query's:

Panel.uf.tsx.css
.panel[data-uf-48dda3f4] {
  padding: 8px;
  background-color: rgb(229, 231, 235);
}

.text[data-uf-48dda3f4] {
  margin: 0;
}

/* Matches the browser projects' 800×600 viewport. */
@media (min-width: 600px) {
  .panel[data-uf-48dda3f4] {
    padding: 24px;
    border: 2px solid rgb(37, 99, 235);
  }
}

/* Does not match it. */
@media (min-width: 1200px) {
  .panel[data-uf-48dda3f4] {
    background-color: rgb(220, 38, 38);
  }
}

A @keyframes name takes the scope id as a suffix, so two components' arrive animations never collide. An animation or animation-name in the same sheet that names it is rewritten to match:

Progress.css
/* Renamed with the scope id, as the `animation` that names it is. */
@keyframes arrive {
  from {
    background-color: rgb(229, 231, 235);
  }

  to {
    background-color: rgb(37, 99, 235);
  }
}

.progress {
  display: flex;
  align-items: center;
  column-gap: 8px;
}

/* The browser projects run every animation in no time, so `forwards` keeps the last frame. */
.dot {
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 8px;
  animation: arrive 1s ease-out forwards;
}
Progress.uf.tsx.css
/* Renamed with the scope id, as the `animation` that names it is. */
@keyframes arrive-0738d8af {
  from {
    background-color: rgb(229, 231, 235);
  }

  to {
    background-color: rgb(37, 99, 235);
  }
}

.progress[data-uf-0738d8af] {
  display: flex;
  align-items: center;
  column-gap: 8px;
}

/* The browser projects run every animation in no time, so `forwards` keeps the last frame. */
.dot[data-uf-0738d8af] {
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 8px;
  animation: arrive-0738d8af 1s ease-out forwards;
}

States and variants

Pseudo-classes and pseudo-elements keep their meaning, with the attribute before the pseudo-element:

Tag.uf.tsx.css
/* The scope attribute goes before the pseudo-element. */
.tag[data-uf-ef5e671c]::before {
  content: "#";
  color: rgb(37, 99, 235);
  font-weight: 700;
}
Continue.uf.tsx.css
.continue[data-uf-bb3adc28] {
  padding: 6px 14px;
  border: 2px solid rgb(75, 85, 99);
  background-color: rgb(255, 255, 255);
}

/* The keyboard's focus ring: a pointer's focus would not show it. */
.continue[data-uf-bb3adc28]:focus-visible {
  outline: 3px solid rgb(37, 99, 235);
  outline-offset: 2px;
}

Variants read well as data-* attributes that the props set, since each selector then names the variant it styles:

Chip.uf.tsx
import "./Chip.css";

export default function Chip({
  label,
  variant = "neutral",
  pressed = false,
}: {
  label: string;
  variant?: "neutral" | "danger";
  pressed?: boolean;
}) {
  return (
    <button
      type="button"
      class="chip"
      data-variant={variant}
      data-state={pressed ? "on" : "off"}
      aria-pressed={pressed ? "true" : "false"}
    >
      {label}
    </button>
  );
}
Chip.uf.tsx.css
.chip[data-uf-69db3802] {
  padding: 4px 12px;
  border: 2px solid rgb(107, 114, 128);
  border-radius: 16px;
  background-color: rgb(255, 255, 255);
  color: rgb(55, 65, 81);
}

.chip[data-variant="danger"][data-uf-69db3802] {
  border-color: rgb(185, 28, 28);
  color: rgb(185, 28, 28);
}

.chip[data-state="on"][data-uf-69db3802] {
  background-color: rgb(55, 65, 81);
  color: rgb(255, 255, 255);
}

.chip[data-variant="danger"][data-state="on"][data-uf-69db3802] {
  background-color: rgb(185, 28, 28);
}

What the compiler refuses

A sheet the compiler can't scope the same way for every target is a diagnostic. UF5002 and UF5004 point at the place in the sheet, and UF5003 and UF5005 at the import:

  • UF5002: the CSS doesn't parse, or uses an at-rule the parser doesn't know, such as @apply, whose rules would reach the page unscoped.
  • UF5003: the sheet is .scss, .sass, .less, .styl or .pcss. Compile it to .css first. Nesting, custom properties, @layer and @media are CSS already.
  • UF5004: a selector that can't be scoped to the component, such as :root, html or body outside :global(), :host, a namespace prefix, or an empty or misplaced :deep(), :slotted() or :global().
  • UF5005: no file is at the path the import names.

For example, a :root rule matches the page, never the component:

Page.css
:root {
  --page-gap: 16px;
}

.page {
  padding: var(--page-gap);
}

That's UF5004, at :root. Wrap it in :global(), or move it to a global stylesheet.

Sheet order on Angular and Qwik

Where two components' rules of equal specificity match one element, the sheet that loads later wins. Vue, React, Solid, Svelte and Astro load a component's sheet as its module loads: a child's before its parent's. Angular and Qwik attach a sheet when the component first renders: a parent's before its children's, and siblings in the order they render. So the winner can differ where two sheets meet on one element, as on a child's root, on content filled into a slot, or under :deep() and :global().

styles/slotted-order shows it. Two children style the same slot content, each with a :slotted(.caption) rule:

Amber.css
.amber {
  padding: 4px;
  border: 2px solid rgb(180, 83, 9);
}

/* As specific as Blue's rule: on content that carries both `-s`, the sheet that loads later wins. */
:slotted(.caption) {
  color: rgb(180, 83, 9);
}

The content filled into a <component is> over both takes both -s attributes, so both rules match it. It takes Blue's colour on Vue and Amber's on Angular and Qwik.

Neither framework's idiom can load sheets in Vue's order, so the difference is declared: sheet-order is unsupported on Angular and Qwik, for information (UF4002), and the output is still emitted. Any two sheets that meet involve a styled module, so the compiler reports UF4002 at every styled module's first stylesheet import. So when the unplugin loads a styled module for Angular or Qwik, Vite prints UF4002 as a warning, in the client and the server build alike, and on Angular a styled module that renders a child prints UF4003 too. That's expected, and needs no change.

Qwik's server render also writes the sheet as a <style> element where the component renders, before its root, so :first-child, :nth-child() and + count it in the server's markup.

Where one rule must win, give it a higher specificity than the other, or keep the two components' rules off the same element.

The decisions behind this page are ADR-0009 and ADR-0063. Engines such as Styleframe are on Styling engines.

Copyright © 2026