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.
pnpm add slab-elements lit
npm install slab-elements lit
Any framework, or none
Your styles · your markup
Slab custom elements
- 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.
-
As it ships
The CSS
/* Nothing. That's the point. */ -
Brick
The CSS
slab-switch { align-items: center; display: flex; gap: 1rem; justify-content: space-between; } slab-switch::part(input) { appearance: none; background: linear-gradient(#1c1c1c, #1c1c1c) no-repeat 0.25rem 50% / 1.125rem 1.125rem, #fff; block-size: 1.75rem; border: 2px solid #1c1c1c; inline-size: 3.25rem; margin: 0; transition: background 150ms; } slab-switch:state(checked)::part(input) { background: linear-gradient(#fff, #fff) no-repeat calc(100% - 0.25rem) 50% / 1.125rem 1.125rem, #d9492a; } slab-switch::part(input):focus-visible { outline: 3px solid #d9492a; outline-offset: 2px; } -
Soft
The CSS
slab-switch { align-items: center; display: flex; font-family: system-ui, sans-serif; gap: 0.75rem; } slab-switch::part(switch) { display: flex; order: -1; } slab-switch::part(input) { appearance: none; background: radial-gradient(circle, #fff 0 0.55rem, transparent 0.6rem) no-repeat 0.2rem 50% / 1.3rem 1.3rem, #767676; block-size: 1.7rem; border-radius: 999px; inline-size: 3rem; margin: 0; transition: background 200ms; } slab-switch:state(checked)::part(input) { background: radial-gradient(circle, #fff 0 0.55rem, transparent 0.6rem) no-repeat calc(100% - 0.2rem) 50% / 1.3rem 1.3rem, #2563eb; } slab-switch::part(input):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; } -
Thin
The CSS
slab-switch { align-items: center; display: flex; gap: 1rem; justify-content: space-between; } slab-switch::part(switch) { align-items: center; display: flex; position: relative; } /* The track. The input is still what's clicked and focused. */ slab-switch::part(input) { appearance: none; background: #767676; block-size: 0.875rem; border-radius: 999px; inline-size: 2.5rem; margin: 0; } /* The thumb, on the input's wrapper. */ slab-switch::part(switch)::after { background: #fff; block-size: 1.375rem; border-radius: 50%; box-shadow: 0 1px 3px rgb(0 0 0 / 50%); content: ''; inline-size: 1.375rem; inset-inline-start: -0.125rem; pointer-events: none; position: absolute; transition: translate 150ms; } slab-switch:state(checked)::part(input) { background: #9d8fd6; } slab-switch:state(checked)::part(switch)::after { background: #5b3fc4; translate: 1.375rem; } slab-switch::part(input):focus-visible { outline: 2px solid #5b3fc4; outline-offset: 6px; }
Usage
Behavior in. Design yours.
Import the elements you need, drop them in like any other tag, and style them however you want.
<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
- <slab-alert> [role=alert]
- <slab-dialog> <dialog> (modal)
- <slab-drawer> <dialog> (non-modal)
- <slab-popover> [popover]
- <slab-toast> [role=status]
- <slab-tooltip> [role=tooltip]
- <slab-accordion> group of <details>
- <slab-disclosure> <details>
- <slab-breadcrumb> <nav> + <ol>
- <slab-menu> [role=menu] + [popover]
- <slab-pagination> <nav> + <button>s
- <slab-tabs> [role=tablist]
- <slab-toolbar> [role=toolbar]
- <slab-tree> [role=tree]
- <slab-combobox> <input role=combobox>
- <slab-listbox> [role=listbox]
- <slab-select> [role=combobox] + [role=listbox]
- <slab-checkbox> <input type=checkbox>
- <slab-checkbox-group> [role=group]
- <slab-date-field> <input type=date>
- <slab-field> <label>
- <slab-file-drop> <input type=file>
- <slab-form> <form>
- <slab-number> <input type=number>
- <slab-radio-group> [role=radiogroup]
- <slab-slider> <input type=range>
- <slab-switch> <input type=checkbox role=switch>
- <slab-text-field> <input>
- <slab-textarea> <textarea>
- <slab-calendar> <table role=grid>
- <slab-meter> <meter>
- <slab-progress> <progress>
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.