---
title: "Styling & Animation"
description: "How widget styling works in the GlassHome SDK, covering per-widget CSS bundles, Tailwind utilities, your own CSS files, theme variables, container queries, and animation."
canonical: https://glasshome.app/docs/widgets/widget-styling
section: "Build Widgets"
updated: 2026-08-16
---
# 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

## 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](#importing-ui-components)). 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`:

```tsx
// 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`.

> **Warning:** 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:

```tsx
<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:

```tsx
<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:

```tsx
// src/my-widget/index.tsx
import "./styles.css";
```

```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 <code>:host</code> in your widget CSS freezes it at build-time and stops the value inheriting, so your widget stops reacting to the theme. The build <strong>fails</strong> if it finds one of the theme variables above defined under <code>:host</code>. 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:

```tsx
<span class="text-3xl @[150px]:text-4xl @[300px]:text-6xl">
  {time()}
</span>
```

Or write the query by hand in your CSS:

```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:

```tsx
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](/docs/widgets/widget-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](/docs/widgets/widget-sdk#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. |

```tsx
<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-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.

```tsx
<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:

```tsx
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:

```tsx
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:

```tsx
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

  ### [Widget SDK guide](/docs/widgets/widget-sdk)

Components, hooks, props, and the data layer.
  ### [API Reference](/docs/widgets/widget-api-reference)

Every public export across the SDK, /schemas, and /vite.
  ### [Getting Started](/docs/widgets/widget-development)

Scaffold, develop, and connect to a running dashboard.