Skip to content

Updated

View MarkdownOpen in ChatGPTOpen in Claude

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:

  1. Your widget’s own files (the directory of your index.tsx), so your JSX utility classes are included.
  2. @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, so bun install pulls in the version your SDK was built against; the build stops with @glasshome/ui is required when it is missing.
  3. The SDK shell styles: the .glasshome-widget-* classes and CSS variables the Widget components 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, 0100.
--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-css utilities for one-shot enter/exit animations (animate-in fade-in, etc.).
  • loading prop on <Widget> renders a pulsing overlay while data is pending: <Widget loading={!hasEntities()}>.
  • Widget.SliderFill animates a 0–100 fill (brightness, volume, …). Pass isDragging to 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.

See also