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:
- Inside a component, write CSS with the design tokens:
padding: calc(var(--spacing) * 4),font-size: var(--text-sm). - In the page, use any CSS you like, utility classes included. Content you pass into a component's
<slot>stays in the page, so page classes style it. - Across the boundary, inherited properties and custom properties do flow in. Tokens you set on
:rootreach every component. - For REALM UI components, use their attributes,
::part()and:state(), described in Styling components from outside.
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():
<!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);
}<!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> 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:
part="name"on inner elements, styled from outside withui-dialog::part(dialog),- CSS custom properties they read, such as tokens,
- custom states, styled with
ui-button:state(attr-variant-is-ghost).
Each REALM UI component page lists its parts and states.