Styling & Animation
Each widget renders in its own closed shadow root and carries its own complete CSS: the build writes one <name>.css next to <name>.js, which the host adopts into the shadow root at mount. Isolation runs both ways: your styles never leak into the dashboard or other widgets, the dashboard’s never reach you, and everything you render must be styled from your bundle.
Tailwind, built in
The full Tailwind v4 utility set plus tw-animate-css. Only the classes you actually use ship in your bundle.
Your own CSS
Import a .css file from your widget and it gets bundled into the widget stylesheet, scoped to your shadow root.
Theme follows the home
The user's theme variables inherit through the shadow boundary. Use them; never redefine them.
What’s in the bundle
The SDK runs Tailwind over exactly three sources into your widget’s stylesheet:
- Your widget’s own files (the directory of your
index.tsx), so your JSX utility classes are included. @glasshome/ui: the theme and component styles. Keep it installed so the build can read them, but import its components from@glasshome/widget-sdk(why). It is a peer of the SDK with its own version line, sobun installpulls in the version your SDK was built against; the build stops with@glasshome/ui is requiredwhen it is missing.- The SDK shell styles: the
.glasshome-widget-*classes and CSS variables theWidgetcomponents use.
Tailwind runs with source(none), so there is no automatic project-root scan: each widget gets only the utilities it uses, not the superset of every widget in your repo. Classes must appear literally in your source, so build class names statically, not by runtime concatenation.
Importing UI components
Import GlassHome’s UI primitives from the SDK, not from @glasshome/ui:
// Do this
import { Badge, Button, Slider, SchemaForm } from "@glasshome/widget-sdk";
// Not this
import { Badge, Button, Slider, SchemaForm } from "@glasshome/ui/solid";
Both resolve to the same components at runtime; the difference is version checking. The dashboard refuses to mount a widget whose declared SDK range (sdkVersion in manifest.json) excludes the SDK it ships, and that is the only compatibility gate. @glasshome/ui has none: the dashboard serves it directly, and widgets update from the Hub independently of the dashboard they land on, so a direct import can meet a version it was never built against, unchecked. Routed through the SDK, the same components sit behind sdkVersion.
The failure is not graceful: a missing export is a module link error, so the widget never evaluates. No error boundary, no partial render, a dead tile.
These are re-exported (SDK 1.9.0+):
Badge, Button, Color, ColorSlider, ColorWheel, Input, Label, parseColor, ResponsiveDialog, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogHeader, ResponsiveDialogTitle, SchemaForm, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Switch.
Direct @glasshome/ui imports still work, and bun widget build warns once per file that uses one. They are removed in SDK 2.0.0, so migrate when you touch a widget: the import path is the only change.
Missing a primitive? Ask for an SDK re-export instead of importing @glasshome/ui, so it arrives version-gated.
Tailwind utilities
Use Tailwind exactly as you would anywhere else:
<Widget.Content class="flex items-center gap-3 rounded-lg bg-card/40 p-4">
<span class="font-semibold text-lg text-foreground">22°C</span>
</Widget.Content>
tw-animate-css is bundled too, so its animation utilities (animate-in, fade-in, slide-in-from-bottom, …) work without extra setup.
Dark mode
dark: variants work: the host mirrors the dashboard’s dark class onto your shadow host element, and the SDK redefines the variant to be shadow-aware, so:
<div class="bg-white text-black dark:bg-zinc-900 dark:text-white" />
reacts to the dashboard theme. For the boolean, use isDark() from the SDK.
Adding your own CSS
Import a stylesheet anywhere in your module graph and it joins the same <name>.css, scoped to your shadow root:
// src/my-widget/index.tsx
import "./styles.css";
/* src/my-widget/styles.css */
.spark {
background: radial-gradient(circle, var(--widget-color), transparent);
}
@keyframes drift {
from { transform: translateX(0); }
to { transform: translateX(8px); }
}
Plain CSS, nesting, custom properties, and @keyframes all work. Selectors match only your own DOM, so no defensive namespacing is needed.
Theme variables
The user’s theme reaches your widget by inheritance: variables like --background, --foreground, --card, --primary, --border, --muted, --accent, and --radius are defined on the dashboard document and inherit across the shadow boundary, which is how a widget follows the household theme and live theme changes.
Use them through Tailwind (bg-card, text-foreground, rounded-[var(--radius)]) or directly (color: var(--foreground)).
Never redefine theme variables on :host
Defining a theme variable on :host in your widget CSS freezes it at build-time and stops the value inheriting, so your widget stops reacting to the theme. The build fails if it finds one of the theme variables above defined under :host. Set your own (--widget-* or custom) variables freely; just leave the theme ones to inherit.
Their names are part of the contract, not just their values: var(--card) is baked into your stylesheet at build time and resolved against the dashboard at mount, so renaming or dropping a token would break published widgets. The names are snapshotted and gated on our side (adding is routine, removing is deliberate); build against the names above and they keep resolving.
Responsive sizing with container queries
There are no size tiers and no JS measurement for visual scale: the widget shell is a CSS size container (container-type: size; container-name: widget), and the SDK’s own padding, gap, icon, and text sizes are fluid functions of it, so two widgets at the same rendered box look identical and resizing is smooth.
Scale your own content the same way; Tailwind’s container-query variants resolve against the widget container:
<span class="text-3xl @[150px]:text-4xl @[300px]:text-6xl">
{time()}
</span>
Or write the query by hand in your CSS:
@container widget (min-aspect-ratio: 1) and (max-height: 149px) {
.my-layout { flex-direction: row; }
}
To branch rendered content, for example hiding a forecast strip on a small chip, read the measured box and apply your own pixel thresholds:
const dims = useWidgetDimensions();
const compact = () => dims().width < 200;
(ctx.dimensions() is deprecated since SDK 1.9 and removed in 2.0; useWidgetDimensions() throws outside <Widget> instead of silently reporting a zero-size box. See the API Reference.)
Variant and shell
The variant prop on <Widget> picks the base shell look (background, blur, padding, layout defaults): "classic-glass" (the standard glass look of every official widget), "minimal", or "compact-horizontal"; omit it for a bare, unstyled shell. The color props below layer on top. See Variants for the full list and helpers.
Coloring the shell
<Widget> draws a gradient shell from a single accent color. Set it with props:
| Prop | Effect |
|---|---|
tone |
Semantic color: "success" | "warning" | "danger" | "info" | "neutral" | "accent". Resolves to --widget-color. |
color |
CSS color override for --widget-color. Overrides tone. |
colorTo |
Second gradient stop (--widget-color-to). |
gradient |
A full CSS gradient string; replaces the auto-generated shell. |
<Widget tone={isOn() ? "accent" : "neutral"}>…</Widget>
<Widget color="oklch(0.7 0.18 250)" colorTo="oklch(0.6 0.2 280)">…</Widget>
SDK CSS custom properties
Defined by the SDK on the widget shell; read them, and override except where noted:
| Variable | Meaning |
|---|---|
--widget-color |
The shell’s accent color (driven by tone/color). |
--widget-color-to |
Second gradient stop. |
--widget-icon-color |
Per-icon / per-fill accent (set by Widget.Icon color / SliderFill color). |
--widget-glow-strength |
Glow intensity multiplier for icons and fills. |
--widget-fill-value |
Slider fill level, 0–100. |
--tone-{name} |
The resolved color for each semantic tone. |
The fluid scale tokens (--widget-pad, --widget-gap, --widget-icon-box, --widget-title-size, --widget-value-size, …) are also on the shell; override them on .glasshome-widget for a denser or looser look.
SDK shell classes
The Widget slot components render with these classes; target them to restyle a slot rather than replace it:
| Class | Slot |
|---|---|
.glasshome-widget |
The shell (gradient, border, container context) |
.glasshome-widget-content |
Widget.Content layout wrapper |
.glasshome-widget-icon |
Widget.Icon tile |
.glasshome-widget-title |
Widget.Title |
.glasshome-widget-status |
Widget.Status |
.glasshome-widget-value |
Widget.Value |
.glasshome-widget-badge |
Title badge |
Need the SDK tokens in a root the host didn’t set up (a custom mount, a test)? Call injectTokens(root).
Animation
Standard CSS and Tailwind animation work inside the shadow root. SDK pieces that keep motion correct and cheap:
tw-animate-cssutilities for one-shot enter/exit animations (animate-in fade-in, etc.).loadingprop on<Widget>renders a pulsing overlay while data is pending:<Widget loading={!hasEntities()}>.Widget.SliderFillanimates a 0–100 fill (brightness, volume, …). PassisDraggingto drop the transition while the user drags so the fill tracks the finger 1:1.
<Widget.SliderFill value={brightnessPercent()} isDragging={dragging()} />
Respect reduced motion
useReducedMotion() reactively tracks the user’s prefers-reduced-motion setting; gate non-essential animation on it:
const reduced = useReducedMotion();
<div classList={{ "animate-pulse": !reduced() }} />
Pause off-screen animation
useIntersectionPause(el) returns true while the element is outside the viewport, so you can stop animating widgets the user can’t see:
let ref: HTMLDivElement | undefined;
const paused = useIntersectionPause(() => ref);
createEffect(() => {
if (paused()) stopTicker();
else startTicker();
});
Charts
For SVG charts, svgColors gives energy-domain colors ready for fill/stroke. Each key (solar, grid, battery, ev, home, positive, negative) exposes solid, stroke, and a reduced-opacity fill for area shading:
import { svgColors } from "@glasshome/widget-sdk";
<path d={areaPath()} fill={svgColors.solar.fill} stroke={svgColors.solar.stroke} />
For smooth lines, monotoneCubicPath(points) returns an SVG path string from a list of points.