react-bits-ui
v1.0.0
Published
Zero-dependency headless UI state factories and utilities — clsx, debounce, throttle, createToggle, createCounter, createTabs, createAccordion, createValidator
Maintainers
Readme
react-bits-ui
Zero-dependency headless UI state factories and utilities for React (and any framework).
No runtime dependencies. No JSX required. Works with useSyncExternalStore or plain callbacks. ESM + CJS dual package. Full TypeScript types.
Install
npm install react-bits-uiWhat's included
| Export | Description |
|---|---|
| clsx / cx | Merge class names — strings, arrays, objects |
| formatBytes | Format byte count as human-readable string |
| mergeProps | Merge React prop objects (className, style, handlers) |
| debounce | Delay function invocation — .cancel() / .flush() |
| throttle | Limit invocation rate |
| validators | Built-in rule factories for form validation |
| createValidator | Compose field validation schemas |
| createToggle | Boolean toggle state machine |
| createCounter | Numeric counter with min/max/step |
| createDisclosure | Open/close state (modal, popover, drawer) |
| createTabs | Tab selection with next/prev wrap-around |
| createAccordion | Expand/collapse panels (single or multi) |
Usage with React
All state factories expose a subscribe(fn) method compatible with
useSyncExternalStore:
import { useSyncExternalStore } from 'react';
import { createToggle, createTabs, createAccordion } from 'react-bits-ui';
// ── Toggle ─────────────────────────────────────────────────────────────────
const darkMode = createToggle(false);
function ThemeButton() {
const isDark = useSyncExternalStore(darkMode.subscribe, () => darkMode.value);
return (
<button onClick={() => darkMode.toggle()}>
{isDark ? 'Light mode' : 'Dark mode'}
</button>
);
}
// ── Tabs ───────────────────────────────────────────────────────────────────
const tabs = createTabs(['overview', 'details', 'reviews']);
function ProductTabs() {
const active = useSyncExternalStore(tabs.subscribe, () => tabs.active);
return (
<div>
<nav>
{tabs.keys.map(key => (
<button
key={key}
className={clsx('tab', { 'tab--active': active === key })}
onClick={() => tabs.select(key)}
>
{key}
</button>
))}
</nav>
<section>{/* render active panel */}</section>
</div>
);
}
// ── Disclosure (Modal) ────────────────────────────────────────────────────
const modal = createDisclosure();
function App() {
const isOpen = useSyncExternalStore(modal.subscribe, () => modal.isOpen);
return (
<>
<button onClick={() => modal.open()}>Open</button>
{isOpen && (
<dialog open>
<p>Hello from the modal.</p>
<button onClick={() => modal.close()}>Close</button>
</dialog>
)}
</>
);
}Or use a simple useState wrapper:
import { useState, useCallback } from 'react';
import { createToggle } from 'react-bits-ui';
function useToggle(initial = false) {
const [t] = useState(() => createToggle(initial));
const [value, setValue] = useState(t.value);
return {
value,
toggle: useCallback(() => setValue(t.toggle()), [t]),
on: useCallback(() => setValue(t.on()), [t]),
off: useCallback(() => setValue(t.off()), [t]),
};
}API
clsx(...inputs) / cx(...inputs)
Merge class names. Accepts any combination of strings, arrays, and objects (object key is included when its value is truthy).
clsx('btn', { active: true, disabled: false }, ['sm'])
// → 'btn active sm'
clsx('a', null, undefined, false, 'b')
// → 'a b'formatBytes(bytes, decimals?)
formatBytes(0) // '0 B'
formatBytes(1024) // '1 KB'
formatBytes(1536, 1) // '1.5 KB'
formatBytes(1024 ** 3) // '1 GB'mergeProps(...propsList)
mergeProps(
{ className: 'btn', onClick: logClick, style: { color: 'red' } },
{ className: 'btn--primary', onClick: trackClick, style: { fontSize: 14 } },
)
// → {
// className: 'btn btn--primary',
// style: { color: 'red', fontSize: 14 },
// onClick: chains both handlers,
// }debounce(fn, delay?)
const save = debounce(value => api.save(value), 400);
input.addEventListener('input', e => save(e.target.value));
save.cancel(); // discard pending call
save.flush('final'); // call immediatelythrottle(fn, limit?)
const onScroll = throttle(() => updateHeader(), 100);
window.addEventListener('scroll', onScroll);validators
Rule factories — each returns (value) => string | null.
import { validators } from 'react-bits-ui';
validators.required() // value must be truthy
validators.minLength(3) // string must be ≥ 3 chars
validators.maxLength(100) // string must be ≤ 100 chars
validators.email() // basic e-mail format
validators.pattern(/^\d{4}$/) // must match RegExp
validators.min(0) // number >= 0
validators.max(100) // number <= 100
validators.number() // must be a finite numbercreateValidator(rules)
const v = createValidator({
username: [validators.required(), validators.minLength(3), validators.maxLength(20)],
email: [validators.required(), validators.email()],
age: [validators.required(), validators.number(), validators.min(18)],
});
const { isValid, errors } = v.validate({
username: 'al',
email: 'not-an-email',
age: 15,
});
// isValid → false
// errors → { username: 'Min 3 characters', email: 'Invalid email', age: 'Min value is 18' }createToggle(initial?)
const t = createToggle(false);
t.toggle() // true
t.off() // false
t.on() // true
t.set(0) // false (coerces to boolean)
const unsub = t.subscribe(value => console.log('changed:', value));
unsub(); // stop listeningcreateCounter(initial?, opts?)
const c = createCounter(0, { min: 0, max: 10, step: 2 });
c.increment() // 2
c.increment() // 4
c.decrement() // 2
c.reset() // 0
c.set(9) // 9 (clamped to max: 10)createDisclosure(initial?)
const modal = createDisclosure();
modal.open() // isOpen → true
modal.close() // isOpen → false
modal.toggle() // flips
modal.subscribe(isOpen => render(isOpen));createTabs(keys, defaultKey?)
const tabs = createTabs(['home', 'about', 'contact'], 'home');
tabs.select('about') // 'about'
tabs.next() // 'contact'
tabs.next() // 'home' (wraps around)
tabs.prev() // 'contact'
tabs.isActive('contact') // true
tabs.keys // ['home', 'about', 'contact'] (copy)createAccordion(keys, opts?)
// Single-open (default)
const acc = createAccordion(['a', 'b', 'c']);
acc.toggle('a') // expands 'a'
acc.toggle('b') // collapses 'a', expands 'b'
// Multi-open
const multi = createAccordion(['a', 'b', 'c'], { multiple: true, defaultExpanded: ['a'] });
multi.expand('b')
multi.expandAll()
multi.collapseAll()
multi.isExpanded('b') // false after collapseAllCommonJS
const {
clsx, cx, formatBytes, mergeProps,
debounce, throttle, validators, createValidator,
createToggle, createCounter, createDisclosure,
createTabs, createAccordion,
} = require('react-bits-ui');License
MIT
