npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

preact-homeassistant

v0.5.2

Published

Preact hooks and helpers for building Home Assistant custom cards

Readme

preact-homeassistant

Preact hooks and helpers for building Home Assistant custom cards. Handles the web-component lifecycle, Shadow DOM, entity subscriptions, and data fetching so you can focus on your card's UI.

Install

pnpm add preact preact-homeassistant

preact is a peer dependency.

Quick start

The easiest way is to use the template

import { registerPreactCard, HACard, useEntity, css } from 'preact-homeassistant';

css`
  .my-card { padding: 16px; }
  .my-card .temperature { font-size: 2em; }
`;

function MyCardContent({ config }: { config: { entity: string } }) {
  const weather = useEntity(config.entity);

  return (
    <HACard>
      <div class="card-content my-card">
        <span class="temperature">{weather?.state ?? '...'}</span>
      </div>
    </HACard>
  );
}

function MyCardEditor({ hass, config, onConfigChanged }) {
  const entities = Object.keys(hass.states).filter((e) => e.startsWith('weather.'));

  return (
    <div style={{ padding: '16px' }}>
      <ha-select
        label="Weather entity"
        value={config.entity}
        naturalMenuWidth
        fixedMenuPosition
        onChange={(e) => onConfigChanged({ ...config, entity: (e.target as HTMLSelectElement).value })}
        onclosed={(e) => e.stopPropagation()}
      >
        {entities.map((id) => (
          <ha-list-item key={id} value={id}>
            {hass.states[id]?.attributes?.friendly_name ?? id}
          </ha-list-item>
        ))}
      </ha-select>
    </div>
  );
}

registerPreactCard({
  type: 'my-weather-card',
  name: 'My Weather Card',
  description: 'A simple weather card',
  Component: MyCardContent,
  ConfigComponent: MyCardEditor,
  getStubConfig: () => ({ entity: '' }),
});

That's it. registerPreactCard creates the web component, registers the custom element with Home Assistant, sets up Shadow DOM, injects registered styles, and wraps your component in the data provider. Your component receives config as a prop and uses hooks for everything else.

registerPreactCard(options)

| Option | Type | Required | Description | |---|---|---|---| | type | string | Yes | Custom element tag name (e.g. 'my-weather-card') | | name | string | Yes | Display name in the HA card picker | | description | string | Yes | Description in the HA card picker | | Component | ComponentType<{ config: T }> | Yes | Main card Preact component | | ConfigComponent | ComponentType<{ hass, config, onConfigChanged }> | No | Visual editor. Receives hass, the current config, and an onConfigChanged callback. Registered as ${type}-editor. | | UnconfiguredComponent | ComponentType<{}> | No | Shown before config/hass are available | | getStubConfig | () => Partial<T> | No | Default config for the card picker |

The card renders into a Shadow DOM root. The editor renders into the light DOM (required for HA's own custom elements like <ha-select> to work).

<HACard>

Use HACard as the root of your card instead of a raw <ha-card>. It makes the card fill the height Home Assistant assigns it.

In HA's sections (grid) layout, when a card is resized (e.g. to 3 rows) HA gives the card's host element a definite height. A plain <ha-card> collapses to its natural content height and renders slightly short, leaving a gap. HACard sets the host and the ha-card to fill that height, so the card matches the slot exactly. In layouts with no fixed height (masonry, auto rows) it safely collapses back to natural height, so it's a drop-in replacement everywhere.

<HACard class="size-large" align="space-between">
  <div class="card-content my-card">…</div>
</HACard>

| Prop | Type | Default | Description | |---|---|---|---| | align | HACardAlign | 'top' | How content is distributed vertically when the slot is taller than the content. Friendly aliases top / center / bottom, or any flex justify-content value (space-between, space-around, space-evenly, flex-start, flex-end). | | class | string | — | Class applied to the underlying ha-card. | | children | ComponentChildren | — | Card contents. |

align only positions content as a block. To make an inner section stretch to absorb the extra height, give it flex: 1 in your card's CSS.

Hooks

useEntity(entityId)

Subscribe to a specific entity. Only re-renders when that entity's state changes.

const sensor = useEntity('sensor.temperature');
// sensor?.state === '72'

Returns a strict type based on the domain prefix:

  • 'calendar.*'CalendarEntity
  • 'weather.*'WeatherEntity
  • 'sun.sun'SunEntity
  • 'fan.*'FanEntity
  • Other domains → HassEntity (the loose type from home-assistant-js-websocket)

The mapping comes from the DomainEntityMap interface. To add a new domain, see the Contributing types section below.

useService(entityId)

Returns a stable function that calls services on a specific entity. The service domain is parsed from the entity ID prefix and entity_id is auto-injected into every call. Service names and data shapes are strongly typed via DomainServiceMap when the domain is registered.

const fanService = useService(config.entity);   // config.entity: `fan.${string}`
await fanService('toggle');                     // entity_id auto-injected
await fanService('set_percentage', { percentage: 67 });

For registered domains (currently fan), TypeScript autocompletes service names and validates the data shape. For other domains the hook still works, just without per-service autocomplete — useful for ad-hoc calls until the domain is added to DomainServiceMap.

The returned function is a no-op if the entity ID is empty (common while the card config is being set up) or if hass isn't connected yet.

useHass()

Access the full hass object for reading config or making service calls that useService doesn't cover (different entity per call, no entity, custom return_response, etc.). Does not re-render on entity changes.

const { getHass } = useHass();
await getHass()?.callService('script', 'morning_routine');

useHassValue(selector, isEqual?)

Subscribe to a derived slice of the hass object (config, themes, anything that isn't entity state — entity state goes through useEntity). The selector runs on every hass update, but the consumer only re-renders when isEqual reports a change, so it's cheap for rarely-changing values. isEqual defaults to Object.is.

const unitSystem = useHassValue((hass) => hass?.config?.unit_system?.temperature);

useHassConfig()

Shorthand for useHassValue((hass) => hass?.config). Re-renders when hass.config changes (units, latitude/longitude, etc.).

useDarkMode()

Shorthand for the active theme's dark-mode flag. Returns boolean.

useCalendarEvents(entityIds, { start, end })

Fetch events from one or more calendars for a date range. entityIds is an array of `calendar.${string}` IDs, and every returned event carries the calendarId it came from. Caches per-card in memory with stale-while-revalidate, and debounce-refetches when any of the entities change.

const { events, status, error, refetch, prefetch } = useCalendarEvents(
  ['calendar.family', 'calendar.work'],
  { start, end },
);
// status: 'loading' | 'cached' | 'ready' | 'refreshing'

// Warm adjacent months without touching component state:
prefetch({ start: prevMonthStart, end: prevMonthEnd });

prefetch(range) is best-effort — it skips ranges already cached and swallows failures.

Events are read from the REST view GET /api/calendars/{entity_id}, which (unlike the calendar.get_events service response) includes uid, recurrence_id, and rrule. The service call is used as a fallback when hass.callApi isn't available (test mocks, Storybook).

useWeatherForecast(entityId, type)

Fetch weather forecast data. Caches per-card in memory, debounce-refetches on entity changes, and auto-refetches at the top of each hour.

const { forecast, status, error, refetch } = useWeatherForecast('weather.home', 'hourly');

useCachedFetch(cacheKey, fetcher, deps)

Generic hook for fetching data with per-card caching. The domain-specific hooks above are built on this. fetcher is re-run whenever deps change; the result is stored under cacheKey in the provider's cache.

const { data, status, error, refetch } = useCachedFetch(
  `my-thing:${id}`,
  () => fetchMyThing(id),
  [id],
);

Behavior is stale-while-revalidate and key-change aware: when cacheKey changes it swaps to that key's cached value synchronously, or keeps the previously rendered data on a cold key — it never blanks to a loading state. 'loading' only appears on a true cold start (nothing cached, nothing fetched). In-flight fetches are ignored if a newer one has started.

useResizeObserver(ref, callback, deps?)

Observe an element's size via ResizeObserver. The callback fires once after mount with the current size, on every subsequent resize, and whenever deps change. The callback is held in a ref, so passing a fresh closure each render is safe — the observer is never re-created.

const containerRef = useRef<HTMLDivElement>(null);

useResizeObserver(
  containerRef,
  ({ width, height }) => {
    if (width === 0 || height === 0) return; // optional, consumer's call
    drawChart(canvasRef.current, forecast, width, height);
  },
  [forecast],
);

The callback is suppressed while the element is detached from the document. Zero width/height is passed through — many draw routines need to guard against zero dimensions (a 0-sized canvas throws InvalidStateError on drawImage; ratios of measurements like Math.ceil(width / cellSize) produce Infinity when a dimension is zero and infinite-loop the next for they feed into) — but the guard belongs at the call site so the hook stays general-purpose.

Sizes are read from offsetWidth / offsetHeight (CSS pixels, includes padding and border).

useWidth(ref)

Stateful sibling to useResizeObserver for the JSX path: tracks a referenced element's width and re-renders the component when it changes. Returns undefined until the first non-zero measurement, then a positive number that never returns to undefined or 0 — transient zero-width firings during HA layout transitions (dashboard switch, edit-mode toggle) and detached states are silently ignored.

const ref = useRef<HTMLDivElement>(null);
const width = useWidth(ref);
return (
  <div ref={ref}>
    {width !== undefined && <Chart width={width} />}
  </div>
);

Use this when the width needs to appear in JSX (responsive layout, prop to a sized child). For imperative use inside a draw callback, prefer useResizeObserver directly — no state, no extra re-renders.

Styles

Styles are registered globally via the css\`tagged template and auto-injected into each card's Shadow DOM byregisterPreactCard. Use .styles.ts` files imported as side effects.

// MyCard.styles.ts
import { css } from 'preact-homeassistant';

css`
  .my-card { padding: 16px; }
`;

// MyCard.tsx
import './MyCard.styles'; // registers styles on import

registerRawStyles(cssString)

Register a raw CSS string, e.g. from a Vite ?inline import. No-ops if the exact string is already registered.

getAllStyles()

Returns every registered style as one concatenated string. registerPreactCard calls this to inject styles into the card's Shadow DOM; you only need it if you're rendering a card tree yourself.

Calendar mutations

Plain async functions (not hooks) for calendars that support writes, e.g. Local Calendar. Each takes the hass object — get it from useHass(). They require entity control permission, not admin, and WebSocket errors reject unchanged so you can inspect err.code (e.g. 'unauthorized').

import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from 'preact-homeassistant';

const { getHass } = useHass();

await createCalendarEvent(getHass(), 'calendar.family', {
  dtstart: '2026-07-17T09:00:00',
  dtend: '2026-07-17T10:00:00',
  summary: 'Dentist',
});

await updateCalendarEvent(getHass(), 'calendar.family', uid, { ...event, summary: 'Dentist ✅' });

await deleteCalendarEvent(getHass(), 'calendar.family', uid, {
  recurrenceId,   // optional, for one instance of a recurring event
  recurrenceRange, // optional, e.g. 'THISANDFUTURE'
});

CalendarMutationEvent is the payload type: dtstart / dtend are either date-only strings ('2026-07-17', all-day) or ISO datetimes, plus summary and optional description, location, rrule.

Cache utilities

The fetch hooks cache into an in-memory Map owned by the provider, so its lifetime matches the card and it is garbage-collected on teardown. There is intentionally no persistence, TTL, or size cap: freshness comes from entity subscriptions and periodic refetch rather than cache expiry. The read/write helpers are internal — reach the cache through useCachedFetch.

Other utilities

useCallbackStable(fn)

Returns a stable callback ref that always calls the latest fn. Avoids effect re-runs while keeping the closure current.

HAProvider

The context provider registerPreactCard wraps your card in. Export exists so you can render a card outside Home Assistant (Storybook, tests) with a mock hass.

<HAProvider hass={mockHass} subscribeToEntity={() => () => {}}>
  <MyCardContent config={config} />
</HAProvider>

| Prop | Type | Required | Description | |---|---|---|---| | hass | HomeAssistant \| undefined | Yes | The hass object handed to hooks. | | subscribeToEntity | (entityId, cb) => () => void | Yes | Entity subscription plumbing. Return a no-op unsubscribe for static mocks. | | subscribeToHass | (cb) => () => void | No | Notifies useHassValue consumers. Defaults to a no-op, so those hooks return their initial value and never update. | | cache | Cache | No | Inject a cache Map to seed or inspect it. Defaults to a fresh per-provider Map. |

Types

All HA domain types live in src/types/:

  • calendar.tsCalendarEntity, CalendarEvent, CalendarEventWithSource
  • weather.tsWeatherEntity, WeatherForecast, ForecastType
  • sun.tsSunEntity
  • fan.tsFanEntity, FanServices
  • common.tsHomeAssistant, FetchStatus
  • index.tsDomainEntityMap, EntityForId<T>, DomainServiceMap, ServicesForId<T>

Re-exported from the package root:

import type {
  HomeAssistant,
  FetchStatus,
  CalendarEntity,
  CalendarEvent,
  CalendarEventWithSource,
  CalendarMutationEvent,
  WeatherEntity,
  WeatherForecast,
  ForecastType,
  SunEntity,
  FanEntity,
  FanServices,
  EntityForId,
  DomainEntityMap,
  DomainServiceMap,
  ServicesForId,
} from 'preact-homeassistant';

The non-domain types HACardAlign, ElementSize, and ResizeCallback are exported from the root as well, alongside their components/hooks.

Contributing types

The HA domain types in this package are intentionally minimal — only the domains the maintainers have actually needed. If your card needs strict types for another domain (light, climate, media_player, cover, etc.), PRs are very welcome.

  1. Look up the domain in the Home Assistant frontend repo — most domains have a data/<domain>.ts file with TypeScript types.
  2. Add src/types/<domain>.ts. Include an entity interface that extends HassEntityBase / HassEntityAttributeBase from home-assistant-js-websocket, plus a services interface mapping each service name to its data shape (or undefined for services that take no payload beyond entity_id). See src/types/fan.ts for the shape.
  3. In src/types/index.ts, add the entity to DomainEntityMap and the services to DomainServiceMap, and re-export the new types.
  4. Add a quick test under src/__tests__/ if you're feeling thorough.
  5. PR.

Both the entity types and the service types are opt-in: until a domain appears in DomainEntityMap, useEntity('light.foo') falls back to HassEntity; until it appears in DomainServiceMap, useService('light.foo') still works but without per-service autocomplete.

We err toward including only fields that are well-documented; speculative attributes can land later.

Development

pnpm install
pnpm test       # vitest run
pnpm typecheck  # tsc --noEmit
pnpm build      # tsc --noEmit && vite build
pnpm lint       # biome check src
pnpm lint:fix   # biome check --write src

Publishing

Releases are published to npm manually from a local machine (no CI publish):

pnpm test && pnpm build
git tag v0.X.Y && git push origin v0.X.Y
pnpm publish --access public --provenance

The --provenance flag attaches SLSA build attestation. A GitHub release with release notes + the packaged tarball is created automatically when the tag is pushed (see .github/workflows/release.yml).

License

MIT