Styling & Animation
Each widget renders inside its own closed shadow root in the dashboard. That root is cut off from the dashboard stylesheet, so every widget carries its own complete CSS. The build produces one <name>.css next to <name>.js, and the host adopts it into the widget’s shadow root at mount.
The isolation runs both ways: your styles never leak into the dashboard or another widget, and the dashboard’s styles never leak into you. Everything your widget renders has to be styled by CSS the build put in 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
When you build, the SDK runs Tailwind over exactly three sources and writes the result to your widget’s stylesheet:
- Your widget’s own files (the directory of your
index.tsx) — so the utility classes in your JSX are included. @glasshome/ui— the theme and component styles every GlassHome widget ships with. Keep it as a dependency.- The SDK shell styles — the
.glasshome-widget-*classes and CSS variables theWidgetcomponents use.
Tailwind is run with source(none), which disables the automatic project-root scan. The practical effect: each widget gets only the utilities it uses, not the superset of every widget in your repo. Classes have to appear literally in your source for Tailwind to see them, so build class names statically rather than concatenating them at runtime.
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, …) are available 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 dark 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 logic that needs the boolean, use isDark() from the SDK.
Adding your own CSS
Import a stylesheet anywhere in your widget’s module graph and it is bundled into 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. Because the stylesheet lives in your shadow root, your selectors only match your own DOM, so you don’t need to namespace class names defensively.
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 into your root. That 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.
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 that container. 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; }
}
When you need to branch rendered content (not just size) — for example, hiding a forecast strip on a small chip — read the measured box from the context and apply your own pixel thresholds:
const ctx = useWidgetContext();
const compact = () => ctx.dimensions().width < 200;
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
These are defined by the SDK on the widget shell and are yours to read (and, except where noted, to override) in your own CSS:
| 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 if you want a denser or looser look than the default.
SDK shell classes
The Widget slot components render with these classes. Target them when you want 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 |
If you ever need the SDK tokens in a root the host didn’t set up (a custom mount, a test), call injectTokens(root) to attach them.
Animation
Standard CSS and Tailwind animation work inside the shadow root. A few SDK pieces help you 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() is a reactive accessor over 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 to drop into 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 drawing smooth lines, monotoneCubicPath(points) returns an SVG path string from a list of points.