Skip to content

Composing and Extending

Hydrateless is designed to be built on. Tokens are plain custom properties, styles live in @layer, state is expressed with data-hl-* attributes, and defineEnhancer gives your own components the same lifecycle the built-in ones use.

Customizing tokens

Start with tokens before you write a selector. Most visual changes are a token override, scoped as narrowly as you need:

css
/* Whole app */
:root {
  --hl-radius-md: 0.75rem;
  --hl-primary: light-dark(#7c3aed, #a78bfa);
}

/* One region */
.marketing {
  --hl-font-sans: 'Fraunces', serif;
}

/* One component instance */
.hl-button.cta {
  --hl-primary: light-dark(#db2777, #f472b6);
}

Color tokens are light-dark() pairs; give both sides so your override still adapts to dark mode. See Theming for the full list.

Working with layers

All Hydrateless styles are declared inside @layer reset, tokens, theme, components. Anything you write outside a layer beats them regardless of specificity, so overrides are one class deep and never need !important:

css
.hl-card {
  border-radius: var(--hl-radius-2xl);
}

If your app has its own layers, import Hydrateless into one of them so the ordering is explicit:

css
@layer vendor, app;

@import 'hydrateless' layer(vendor);

@layer app {
  .hl-card {
    border-radius: var(--hl-radius-2xl);
  }
}

See CSS Layers for more.

The data-hl-* conventions

Every attribute the library reads or writes follows a small vocabulary:

AttributeRole
data-hl-<component>Component root; the enhancer's selector (data-hl-tabs, data-hl-dropdown)
data-hl-<component>-<part>A named part inside the root (data-hl-dropdown-trigger, data-hl-command-input)
data-hl-valueThe value an item reports (tabs, accordion items, menu items, options, commands)
data-hl-intentSemantic color: neutral, primary, danger, success, warning, info
data-hl-variantVisual style of a button or badge: solid, soft, outline, ghost, link
data-hl-sizesm, md, lg (and xl for spinners)
data-hl-shapeSkeleton shape: text, circle, rect
data-hl-sideDrawer edge: start, end
data-hl-orientationhorizontal, vertical
data-hl-invalid, data-hl-requiredForm states, paired with aria-invalid and required
data-hl-readySet on a root by its enhancer; CSS no-JS baselines are gated on :not([data-hl-ready])
data-hl-toast-trigger, -intent, -durationDeclarative toast buttons

Rules of thumb when you add your own:

  • Prefix with data-hl- only for attributes a Hydrateless stylesheet or enhancer reads. Use your own prefix for app state so upgrades can't collide.
  • Prefer attributes over modifier classes for state. [data-hl-intent="danger"] composes with .hl-button without a class-naming scheme.
  • Keep ARIA as the source of truth for interactive state (aria-selected, aria-checked, aria-expanded) and style off it. The enhancers already maintain those attributes; don't duplicate them.

Extending a component

Add a variant

Match the existing attribute and add a value:

css
.hl-button[data-hl-variant='glass'] {
  background: color-mix(in oklab, var(--hl-surface) 60%, transparent);
  backdrop-filter: blur(8px);
  border: var(--hl-border-width) solid var(--hl-border);
}
html
<button class="hl-button" data-hl-variant="glass">Glass</button>

Compose primitives

Components are designed to nest. A card header with a dropdown, a field with a combobox, a modal containing a command palette: all work because each component only styles its own class or attribute and reads shared tokens.

html
<div class="hl-card">
  <div class="hl-card-header">
    <h3 class="hl-card-title">Members</h3>
    <div data-hl-dropdown>
      <button
        class="hl-button"
        data-hl-variant="ghost"
        data-hl-dropdown-trigger
        popovertarget="members-menu"
      >
        Manage
      </button>
      <ul id="members-menu" data-hl-dropdown-menu popover>
        <li><button role="menuitem" data-hl-value="invite">Invite</button></li>
      </ul>
    </div>
  </div>
</div>

Restyle from scratch

Because behavior lives in enhancers and ARIA, you can drop the hl-* classes entirely and keep the data-hl-* roots. The enhancer still wires roles, keys, and focus; your stylesheet owns the look, and can still read the tokens.

Writing your own enhancer

defineEnhancer turns a per-root setup function into a full enhancer with the shared lifecycle: root discovery inside a container, idempotent de-duplication, option merging from defaults and data-hl-* attributes, automatic listener and observer teardown, re-enhancement after destroy, SSR safety, and the uniform handle.

ts
import { defineEnhancer, ensureId, setAttrs, Events, Keys } from '@hydrateless/enhancers';

type RatingOptions = {
  max?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
};
type RatingApi = { readonly value: number; setValue: (value: number) => void };

export const enhanceRating = defineEnhancer<RatingOptions, RatingApi>({
  name: 'rating',
  selector: '[data-hl-rating]',
  defaults: { max: 5 },
  // Markup can set these: <div data-hl-rating data-hl-max="10" data-hl-default-value="7">
  attributes: { max: 'number', defaultValue: 'number' },
  setup({ root, options, on, observe, add, emit }) {
    // Read the stars lazily so ones rendered later take part.
    const stars = () => Array.from(root.querySelectorAll<HTMLButtonElement>('button'));
    let value = options.defaultValue ?? 0;

    setAttrs(root, {
      role: 'radiogroup',
      'aria-label': root.getAttribute('aria-label') || 'Rating',
    });
    ensureId(root, 'hl-rating');

    const paint = () => {
      stars().forEach((star, i) => {
        setAttrs(star, { role: 'radio', 'aria-checked': i + 1 === value ? 'true' : 'false' });
        star.tabIndex = i + 1 === value || (value === 0 && i === 0) ? 0 : -1;
      });
    };
    // Re-paint when stars are added or removed; disconnected on destroy.
    observe(root, paint);

    const set = (next: number) => {
      value = Math.max(0, Math.min(options.max!, next));
      paint();
      options.onValueChange?.(value);
      emit(Events.change, { value });
    };

    on(root, 'click', (e) => {
      const index = stars().indexOf((e.target as HTMLElement).closest('button')!);
      if (index !== -1) set(index + 1);
    });

    on<KeyboardEvent>(root, 'keydown', (e) => {
      if (e.key === Keys.ArrowRight || e.key === Keys.ArrowUp) set(value + 1);
      else if (e.key === Keys.ArrowLeft || e.key === Keys.ArrowDown) set(value - 1);
      else return;
      e.preventDefault();
      stars()[value - 1]?.focus();
    });

    root.setAttribute('data-hl-ready', '');
    add(() => root.removeAttribute('data-hl-ready'));

    paint();
    return {
      get value() {
        return value;
      },
      setValue: set,
    };
  },
});

What the context gives you:

MemberPurpose
rootThe matched element.
containerWhat the enhancer was called with (often document).
optionsdefaults, then the root's data-hl-* attributes, then caller options, merged in that order.
on()addEventListener that is removed automatically on destroy.
observe()A MutationObserver on a target (default childList + subtree) that is disconnected on destroy.
add()Register any other disposer (timers, attribute resets).
uid()Generate an id namespaced to the enhancer.
emit()Dispatch a bubbling hl:* CustomEvent from the root; returns false if it was canceled.

The attributes schema maps option names to how their data-hl-* value is parsed: 'boolean', 'number', 'string', a list of allowed strings, or a function from the raw string. See Configuring with Data Attributes for the rules.

Use the shared helpers so your component behaves like the built-in ones: Events and Keys for names, ensureId/setAttrs for ARIA wiring, createTypeahead, nextIndex, nextEnabledIndex, isRtl for logical keyboard handling, parsePlacement/keepPositioned for floating surfaces, paginationRange for page lists, the menu-item helpers (menuItemsOf, activateMenuItem) and createSubmenus if you build a menu-like control.

Calling enhanceRating() with no container in the browser scans document; outside a browser it returns an empty handle, so it is safe in SSR code paths.

Framework wrapper

Wrap your enhancer with the framework useEnhancer hook to get the same lifecycle handling as the shipped components:

tsx
import { useRef } from 'react';
import { useEnhancer } from '@hydrateless/react';
import { enhanceRating } from './rating';

export function Rating({ onValueChange }: { onValueChange?: (v: number) => void }) {
  const ref = useRef<HTMLDivElement>(null);
  // Callbacks always run from the latest render, so they don't need to be deps.
  const api = useEnhancer(ref, enhanceRating, { onValueChange });
  // api.current?.setValue(3)
  return (
    <div ref={ref} data-hl-rating aria-label="Rating">
      {[1, 2, 3, 4, 5].map((n) => (
        <button key={n} type="button" aria-label={`${n} stars`}>
          *
        </button>
      ))}
    </div>
  );
}

See the React, Vue, and Svelte guides for each framework's useEnhancer.

Registering with the auto-loader

The auto-loader only knows the built-in manifest. For a custom enhancer, call it yourself after the DOM is ready, or from the same place you call auto():

js
import { auto } from '@hydrateless/auto';
import { enhanceRating } from './rating';

await auto(document);
enhanceRating(document);

Released under the MIT License.