Slab

Headless Web Components

The foundation
is not the finish.

Slab handles the hard parts of a component — keyboard and focus, ARIA, forms, events — on top of the native elements browsers already ship. What it looks like? That's all you, in React, Vue, Svelte, Rails, or plain HTML.

pnpmnpm
pnpm add slab-elements lit
npm install slab-elements lit

Tested in
Chromium · Firefox · WebKit
Types
TypeScript declarations

Why headless

01 Bring your own design

No visual CSS, so no theme to fight. Style every piece with ::part() and every state with :state().

02 The platform first

A Slab dialog is a <dialog>. A switch is a checkbox. We only fill in what browsers don't already do.

03 Built to last

Custom elements are a web standard, and browsers cook low and slow, like a good brisket. Whatever framework you're using in three years will still render these tags.

Make it yours

One element. Any look.

Every switch below is the same <slab-switch>. Only the CSS differs, and none of it ships with Slab.

Usage

Behavior in. Design yours.

Import the elements you need, drop them in like any other tag, and style them however you want.

HTMLReactVueSvelte
<script type="module">
  import 'slab-elements/disclosure.js';
</script>

<slab-disclosure label="Foundation">
  Poured once, built on forever.
</slab-disclosure>

<style>
  slab-disclosure::part(summary) {
    font-weight: 600;
  }

  slab-disclosure[open]::part(summary) {
    color: #b3381a;
  }
</style>

index.html Any bundler (or an import map) takes care of the imports.

import 'slab-elements/disclosure.js';
import { useEffect, useRef } from 'react';

export function Foundation({ onToggle }) {
  const disclosure = useRef(null);

  useEffect(() => {
    const element = disclosure.current;
    const listener = (event) => onToggle(event.newState === 'open');

    element.addEventListener('toggle', listener);
    return () => element.removeEventListener('toggle', listener);
  }, [onToggle]);

  return (
    <slab-disclosure label="Foundation" ref={disclosure}>
      Poured once, built on forever.
    </slab-disclosure>
  );
}

Foundation.jsx The tag is the component. No wrapper package. Events go through a ref.

<script setup>
import 'slab-elements/disclosure.js';
import { ref } from 'vue';

const open = ref(false);
</script>

<template>
  <slab-disclosure
    label="Foundation"
    :open="open"
    @toggle="open = $event.newState === 'open'"
  >
    Poured once, built on forever.
  </slab-disclosure>
</template>

Foundation.vue Let Vue's compiler know slab- tags are custom elements with isCustomElement.

<script>
  import 'slab-elements/disclosure.js';

  let open = $state(false);
</script>

<slab-disclosure
  label="Foundation"
  {open}
  ontoggle={(event) => (open = event.newState === 'open')}
>
  Poured once, built on forever.
</slab-disclosure>

Foundation.svelte Svelte binds properties and events to custom elements like any other element.

32 elements

Pour the foundation once.

Then build your Design System on top of it — in whatever framework you're using this year, and the next one.

Slab is a headless Web Component library, published as `slab-elements`. Each element handles the hard parts — keyboard and focus, ARIA, form participation, events — on top of the native element it maps to. What it looks like is up to you: style it from the outside with `::part()` and `:state()`, or subclass it and make it your own.