Styling

A component's <style> and <link rel="stylesheet"> children become constructable stylesheets adopted by its shadow root. They apply inside the component only, and one stylesheet is shared by every instance of the component.

<custom-element name="info-note">
  <style>
    :host {
      display: block;
      padding: calc(var(--spacing) * 4);
      border-radius: var(--radius-lg);
      background: var(--muted);
    }
    :host([hidden]) {
      display: none;
    }
    ::slotted(a) {
      font-weight: var(--font-weight-medium);
    }
  </style>

  <slot></slot>
</custom-element>

The usual shadow DOM tools apply:

Selector Styles
:host The element itself. Give it a display. Custom elements are inline by default
:host([attr]), :host(.class) The element when it has an attribute or class
::slotted(selector) Top-level elements placed into a <slot> from outside
::part(name) (from outside) Inner elements marked with part="name"
:host(:state(name)) The element when it has a custom state

Inherited properties (color, font-*, CSS custom properties) still flow in from the page.

Shadow DOM and utility CSS

Every component renders into a shadow root, and the page's stylesheets do not reach inside it. That is what stops a component's styles from leaking out and the page's styles from leaking in. It also means utility-class frameworks such as Tailwind CSS, UnoCSS or Bootstrap's utility classes do not work in a component's markup. Their classes live in the page's stylesheet, so this gets no padding and no font size:

<custom-element name="info-note">
  <!-- p-4 and text-sm are defined in the page, not in this shadow root -->
  <div class="p-4 text-sm"><slot></slot></div>
</custom-element>

The Tailwind Play CDN script has the same limit: it writes its styles into the document <head> only.

What works instead:

A component can load a compiled utility stylesheet with <link rel="stylesheet">, but every component then carries the whole file and you need a build step to generate it. REALM does not recommend it.

Reactive CSS

REALM reads the component's CSS for two patterns and keeps them in sync with props and state. Nothing needs to be declared in the script.

Variables: --attr-* and --state-*

var(--attr-name) is the value of the prop or attribute name. var(--state-todo-count) is the value of #todo.count, with dots written as dashes. REALM sets these as custom properties on the host, so they work anywhere in the component's styles, including calc():

Previewindex.htmlmeter-bar.html
-10 +10
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Styling demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>

  <import-element from="./meter-bar.html"></import-element>

  <meter-bar></meter-bar>
  <meter-bar value="75" hue="30"></meter-bar>
</body>
</html>
<custom-element name="meter-bar">
  <script>
    const props = defineProps({ value: prop.number().min(0).max(100).default(40), hue: prop.number().default(150) });
    const meter = useState({
      get width() {
        return `${props.value}%`;
      },
    });

    function nudge(amount) {
      props.value = Math.min(100, Math.max(0, props.value + amount));
    }
  </script>

  <style>
    :host {
      display: grid;
      gap: calc(var(--spacing) * 2);
      width: calc(var(--spacing) * 72);
    }
    .track {
      height: calc(var(--spacing) * 3);
      border-radius: var(--radius-full);
      background: var(--muted);
    }
    .fill {
      width: var(--state-meter-width);
      height: 100%;
      border-radius: inherit;
      background: oklch(0.65 0.15 var(--attr-hue));
      transition: width var(--default-transition-duration);
    }
    .actions {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
  </style>

  <div class="track"><div class="fill"></div></div>
  <div class="actions">
    <ui-button size="sm" variant="outline" onclick="nudge(-10)">-10</ui-button>
    <ui-button size="sm" variant="outline" onclick="nudge(10)">+10</ui-button>
  </div>
</custom-element>

Objects, arrays, null and undefined remove the variable. For a variable on an inner element instead of the host, bind it: <div _--width="#meter.width">.

States: :state(attr-…) and :state(state-…)

:state(attr-open) matches while the prop or attribute open is truthy. :state(state-todo-isEmpty) matches while #todo.isEmpty is truthy. Add a comparison to match a specific value:

Selector Matches when
:state(attr-variant-is-outline) variant equals "outline"
:state(state-cart-count-gt-0) cart.count > 0
:state(state-cart-count-gte-10) cart.count >= 10
:state(state-stock-left-lt-5) stock.left < 5
:state(state-stock-left-lte-0) stock.left <= 0

This is how REALM UI styles its variants without any script:

:host(:state(attr-variant-is-destructive)) button {
  background: var(--destructive);
  color: var(--destructive-foreground);
}
:host(:state(attr-size-is-sm)) button {
  height: calc(var(--spacing) * 8);
}
Previewindex.htmlstock-label.html
 left
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Styling demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="./stock-label.html"></import-element>

  <stock-label left="12"></stock-label>
  <stock-label left="3"></stock-label>
  <stock-label left="0"></stock-label>
</body>
</html>
<custom-element name="stock-label">
  <script>
    const props = defineProps({ left: prop.number().int().min(0).default(0) });
  </script>

  <style>
    :host {
      display: inline-flex;
      padding: calc(var(--spacing) * 1) calc(var(--spacing) * 3);
      border-radius: var(--radius-full);
      background: var(--secondary);
      font-size: var(--text-sm);
    }
    :host(:state(attr-left-lt-5)) {
      background: color-mix(in oklab, var(--chart-4) 30%, transparent);
    }
    :host(:state(attr-left-lte-0)) {
      background: color-mix(in oklab, var(--destructive) 10%, transparent);
      color: var(--destructive);
    }
  </style>

  <slot name="@left"></slot>&nbsp;left
</custom-element>

State names follow the reference path with dashes, so the names in a path should not themselves contain dashes. These states are custom states (CustomStateSet), so they are also visible from outside: ui-button:state(attr-variant-is-outline).

Your own states

Set states from the script with internals.states:

observe(() => {
  if (menu.open) internals.states.add("open");
  else internals.states.delete("open");
});

REALM adds :state(loading) to a component's host while one of its fetch attributes is running.

Design tokens

REALM UI's theme.css defines the tokens its components use. Use them in your own components so they match REALM UI and follow theme changes:

Group Tokens
Colour --background, --foreground, --card, --popover, --primary, --secondary, --muted, --accent, --destructive (each with a -foreground pair where it makes sense), --border, --input, --ring, --chart-1 to --chart-5, --sidebar*
Spacing --spacing (0.25rem). Write sizes as calc(var(--spacing) * 4)
Type --text-xs to --text-4xl, --font-weight-normal to --font-weight-bold, --leading-tight, --leading-normal, --tracking-tight, --font-sans, --font-mono
Radius --radius, --radius-xs to --radius-2xl, --radius-full
Shadow --shadow-xs to --shadow-lg, --focus-ring
Motion --default-transition-duration, --default-transition-timing-function

Custom properties inherit through shadow roots, so a token set on :root reaches every component.

Theming and dark mode

Override tokens on :root, or on any element to theme a subtree:

:root {
  --radius: 0.375rem;
  --primary: oklch(0.55 0.2 260);
  --primary-foreground: oklch(0.98 0 0);
}

.marketing {
  --font-sans: "Inter", system-ui, sans-serif;
}

theme.css switches to dark colours when the system prefers a dark scheme, unless the page sets data-theme="light" on <html>. Setting data-theme="dark" forces dark mode. A theme toggle sets that attribute:

document.documentElement.dataset.theme = "dark";

Styling components from outside

Shadow styles are encapsulated, so page CSS cannot reach inside a component by accident. Components choose what to expose:

Each REALM UI component page lists its parts and states.

Edit this page on GitHub · View as Markdown