Headless Core — Overview
@andersseen/headless-components is the foundation tier of this stack (see
the root README’s “Package Roles”): pure TypeScript state machines with no
DOM rendering and no styles. It powers
@andersseen/web-components and
@andersseen/vanilla-components internally, and is also
published standalone for teams building their own design system on top of it.
- ✅ Pure TypeScript, works with any framework (Stencil, Angular, React, Vue, vanilla JS)
- ✅ Reactive state, accessibility, and keyboard navigation
- ❌ Does not render any DOM
- ❌ Does not include any styles
Install
Section titled “Install”pnpm add @andersseen/headless-componentsThe pattern
Section titled “The pattern”Every component factory follows the same shape: create*(config) returns an
object exposing a reactive state snapshot, a subscribe function,
actions (setters), queries (derived getters), one or more prop-getters
you spread onto your own markup, and event handlers you wire to DOM events.
import { createButton } from '@andersseen/headless-components';
const button = createButton({ disabled: false, onClick: e => console.log('clicked'),});
button.state; // readonly snapshot: { disabled, loading, type }button.getButtonProps(); // props to spread on your <button>button.actions.setLoading(true); // mutate → notifies subscribersbutton.queries.isDisabled(); // derived booleanbutton.handleClick(event); // event handler (guards disabled/loading)getButtonProps() returns exactly what a UI layer spreads onto its element —
for a button that’s:
{ "type": "button", "disabled": false, "tabindex": 0, "aria-disabled": false, "aria-busy": false, "data-state": "active", "data-disabled": false,}Method names vary per component (a dropdown has getTriggerProps() /
getMenuProps() / handleKeyDown(), tabs has getTabProps(id), etc.), but the
five buckets — state, subscribe, actions, queries, prop-getters +
handlers — are consistent across all of them.
Reactivity: subscribe
Section titled “Reactivity: subscribe”The core is reactive: subscribe(cb) fires on every state change and returns an
unsubscribe function. This is what makes one factory work in any framework
without an adapter — you bridge it to whatever re-render primitive your
framework has.
const unsubscribe = button.subscribe(state => { console.log('button state changed:', state);});// …laterunsubscribe();// React: bridge subscribe → a re-renderimport { useSyncExternalStore, useRef } from 'react';import { createButton } from '@andersseen/headless-components';
function useButton(config) { const ref = useRef(null); ref.current ??= createButton(config); const button = ref.current; useSyncExternalStore(button.subscribe, () => button.state); return button;}Live example
Section titled “Live example”This is the actual, unmodified return value of
createButton({ disabled: false }) called live on this page — nothing rendered,
just the plain object a UI layer would spread onto its own <button>:
Available factories
Section titled “Available factories”Eighteen component factories, each powering the matching styled component in
@andersseen/web-components. Import from the package root or a per-component
subpath (@andersseen/headless-components/button).
| Factory | Powers |
|---|---|
createButton | Button |
createAccordion | Accordion |
createTabs | Tabs |
createDropdown | Dropdown |
createModal | Modal |
createDrawer | Drawer |
createTooltip | Tooltip |
createToastManager | Toast |
createInput | Input |
createSelect | Select |
createAlert | Alert |
createNavbar | Navbar |
createSidebar | Sidebar |
createBreadcrumb | Breadcrumb |
createCarousel | Carousel |
createMenuList | Menu List |
createContextMenu | Context Menu |
createMenuSelection | shared menu-disclosure logic (internal to dropdown / menu-list / context-menu) |
Primitives
Section titled “Primitives”Alongside the component factories, the package exports the low-level building blocks they’re made of:
| Export | What it is |
|---|---|
StateStore / createStore | The reactive core — a tiny setState/subscribe store. Freezes snapshots and only notifies on actual change. |
createMachine | A finite state-machine primitive (states, on transitions, guards, effects) for modelling more complex flows. |
createIdGenerator | Collision-free id helper for wiring aria-controls / aria-labelledby. |
Keys / KeyboardKey | Named keyboard constants (Keys.Enter, Keys.ArrowDown, …) used by the keyboard handlers. |
AriaAttributes, DataAttributes | The shared prop-getter return types (aria-* and data-state/data-disabled/data-orientation). |
import { createStore } from '@andersseen/headless-components';
const store = createStore({ count: 0 });const stop = store.subscribe((state, prev) => { console.log(prev.count, '→', state.count);});store.setState({ count: 1 }); // logs "0 → 1"store.setState({ count: 1 }); // no-op: value unchanged, subscribers not calledstop();Usage with Stencil
Section titled “Usage with Stencil”import { Component, State, h } from '@stencil/core';import { createButton, type ButtonReturn,} from '@andersseen/headless-components/button';
@Component({ tag: 'my-button', shadow: true })export class MyButton { @State() private buttonLogic: ButtonReturn;
componentWillLoad() { this.buttonLogic = createButton({ onClick: e => this.handleClick(e) }); }
private handleClick = (e: MouseEvent) => console.log('Button clicked!');
render() { const props = this.buttonLogic.getButtonProps(); return ( <button {...props} onClick={e => this.buttonLogic.handleClick(e)}> <slot /> </button> ); }}Usage with Angular
Section titled “Usage with Angular”import { Component, OnInit } from '@angular/core';import { createButton, type ButtonReturn,} from '@andersseen/headless-components/button';
@Component({ selector: 'app-button', template: ` <button [attr.type]="props.type" [disabled]="props.disabled" (click)="button.handleClick($event)" > <ng-content></ng-content> </button> `,})export class ButtonComponent implements OnInit { button!: ButtonReturn; props: any;
ngOnInit() { this.button = createButton({ onClick: () => console.log('clicked') }); this.props = this.button.getButtonProps(); // re-read props on change: this.button.subscribe(() => (this.props = this.button.getButtonProps())); }}