@popovandrii/ui-elements
v0.5.0
Published
Lightweight TypeScript UI component library — SpinBox, Select with search, Switch, ButtonGroup (radio), Button. Zero dependencies, accessible (ARIA), themeable via CSS custom properties, supports destroy/reinitialize lifecycle.
Maintainers
Readme
AI agents / LLMs — read
llms.txtbefore writing any markup for this library. It is DOM-driven: the CSS class names are the JS binding hooks, and a malformed skeleton binds nothing — silently, with no error and no type error.llms.txtis the complete markup contract (required children and their order, everydata-*, modifiers, events) for all seven components, in one file.
@popovandrii/ui-elements
Lightweight TypeScript UI component library — SpinBox, Select (with search), Switch, ButtonGroup (radio), Button, Toast.
- Zero runtime dependencies
- Vanilla TypeScript — works with any framework or none
- Accessible (ARIA / A11Y) with full keyboard support
- Themeable via CSS custom properties — 4 built-in themes
- SPA-friendly lifecycle: idempotent
scan(), optional root scoping andMutationObserverauto re-scan, teardown-onlydestroy() - Custom DOM events + programmatic
setValue - Ripple and flash animations (opt-out per element)
- Shared caption label (
.UIlabel) above any control - Themed label tooltip (
.UIql) with optional viewport-aware auto-flip - Ships as ES, CJS and UMD bundles with type declarations
Components
| Component | Base class | Event | Docs |
|---|---|---|---|
| SpinBox | UIsp | ui-spinbox-change | SpinBox |
| Select | UIselect | ui-select-change | Select |
| Switch | UIsw | ui-switch-change | Switch |
| ButtonGroup | UIbg | ui-button-group-change | ButtonGroup (radio) |
| Button | UIb | ui-button-change | Button |
| Toast | UItoast | ui-toast-show / ui-toast-dismiss | Toast |
| Nav | UInav | ui-nav-open / ui-nav-close | Nav |
Installation
npm install @popovandrii/ui-elements@latestQuick start
Import the components you need and the base stylesheet:
import { SpinBox, Select, Switch, ButtonGroup, Button } from "@popovandrii/ui-elements";
import "@popovandrii/ui-elements/style.css";
// Each constructor scans the DOM for its base class and wires up matching elements.
new SpinBox();
new Select();
new Switch();
new ButtonGroup();
new Button();Add the matching markup (see each component's docs for full examples):
<!-- data-decimals = number of decimal places to display (0 = integers) -->
<div class="UIsp" data-decimals="0" data-min="0" data-max="10" role="spinbutton" tabindex="0" aria-label="Numeric input">
<button class="UIsp__btn" type="button" aria-label="Decrease value">−</button>
<input class="UIsp__input" id="qty" type="text" value="0" inputmode="decimal">
<button class="UIsp__btn" type="button" aria-label="Increase value">+</button>
</div>Toast is the exception — it's imperative, so there's nothing to scan or mark up. Create a manager and call it from your code:
import { Toast } from "@popovandrii/ui-elements";
const toast = new Toast(); // defaults: top-right, 4000ms auto-dismiss
toast.success("Saved"); // pop a toast
toast.show("Stays until closed", { duration: false }); // sticky / manual closeSee the Toast docs for positions, sticky/pause-on-hover, HTML opt-in and events.
Mobile / viewport
For the components to size correctly on phones, your host page must declare a viewport — this
is the page's responsibility and is not shipped with the package (importing CSS/JS can't
edit your HTML). Add this once to your <head>:
<meta name="viewport" content="width=device-width, initial-scale=1" />Without it, mobile browsers render at a ~980px layout width and scale the page down, so everything looks tiny — most noticeably in portrait. The most common "still looks wrong on mobile after updating" cause is a missing viewport tag.
Compactness on narrow screens
Controls have a fixed height, so content that wraps to a second line is clipped. Instead of leaving that to chance, Button defines what gives way, in order, when it runs out of room — a long label with an icon beside it stops being a special case you patch per element:
- Icons never give way — they keep their box instead of scaling down to fit the text.
- Padding and gap shrink first.
- The label ellipsizes — it is the only part allowed to shrink, and it never wraps.
- The label disappears — opt-in, below
480px.
Steps 1–3 are automatic. Step 4 needs the text in its own element (a bare text node cannot be hidden by CSS), and a choice about what survives:
<!-- one icon + label → `collapse` squares the button below 480px -->
<button class="UIb primary collapse" data-value="save" type="button">
<span class="UIb__icon">💾</span><span class="UIb__label">Save all changes</span>
</button>
<!-- two icons → the library can't guess which one carries the meaning, so the markup says -->
<button class="UIb info g-1" data-value="export" type="button">
<span class="UIb__icon">⬆</span>
<span class="UIb__label ui-mobile-hide">Export report</span>
<span class="UIb__icon ui-mobile-hide">▾</span>
</button>ui-mobile-hide is a global utility (like ui-no-ripple): it hides any element below
480px and works outside a button too. Hiding a label removes it from the accessibility
tree, so Button.scan() mirrors .UIb__label into aria-label — a hand-written
aria-label always wins. Full rules: Button.
There is a single breakpoint on purpose. Splitting HD from 4K buys nothing; the problems
all live around 400px.
Debug mode
Pass true as the second constructor argument to log selector/value warnings:
new ButtonGroup({}, true); // debug modeThemes
Theming has two layers:
style.css— required. Contains the component structure and the defaultlightpalette at:root, so the components render correctly with just this file and nodata-themeset.theme-*.css— optional. Each provides one palette scoped under a higher-specificityhtml[data-theme="..."]selector, so it overrides the:rootdefault whenever the matchingdata-themeis set — regardless of CSS import order.base.css— optional, opt-in. A small page normalize/reset (body, headings, links, form controls, tables…). It is not bundled intostyle.cssso importing the components never restyles your page; import it only if you want the reset, alongsidestyle.css/a theme. It also bumps the rootfont-sizeon small screens (the components are rem-based, so this scales the whole system up on phones). If you don't importbase.cssbut want that scaling, set a responsivefont-sizeon:root/htmlyourself.
When no data-theme is set, style.css also follows the OS via
@media (prefers-color-scheme: dark), so the dark palette applies automatically in dark mode.
Any explicit data-theme always wins over this fallback.
Zero-config (light theme, no data-theme needed):
import "@popovandrii/ui-elements/style.css"; // that's it — light theme worksAdd theme files only when you need other palettes or runtime switching:
import "@popovandrii/ui-elements/style.css"; // required base (also = light default)
// import "@popovandrii/ui-elements/base.css"; // optional opt-in page normalize/reset
// import "@popovandrii/ui-elements/theme-dark.css";
// import "@popovandrii/ui-elements/theme-light-neon.css";
// import "@popovandrii/ui-elements/theme-dark-neon.css";
// import "@popovandrii/ui-elements/theme-light-solarized.css";
// import "@popovandrii/ui-elements/theme-dark-solarized.css";
// import "@popovandrii/ui-elements/theme-light.css"; // only to switch *back* to lightActivate a theme with data-theme
light is the implicit default. To use another theme, load its file and set data-theme on a
parent — usually <html>:
<html data-theme="dark">Because themes are html[data-theme="..."] (higher specificity than :root), the explicit
theme always wins over the default; remove the attribute to fall back to light.
The data-theme values are: light, dark, light-neon, dark-neon, light-solarized,
dark-solarized. The *-solarized pair is a deliberately low-contrast, Solarized-inspired theme.
Switching themes at runtime
Because each theme is scoped by its selector, you can import several and switch between them by changing a single attribute:
document.documentElement.dataset.theme = "dark"; // light | dark | light-neon | dark-neon | light-solarized | dark-solarizedThemes are driven by CSS custom properties, so you can also override individual variables in your own stylesheet instead of shipping a full theme.
Shared CSS modifier classes
Most components share a common set of modifier classes (see each component's docs for the exact list it supports):
- Color:
primary,danger,info,success,warning - Size:
xsm,sm,lg - Border radius:
r-0,r-1/r-round - Gap:
g-0,g-1 - Layout:
expand,between,around,vertical,border - Compactness:
collapse,ui-mobile-hide(see above)
Custom class names
Every component accepts a partial selector map to match your own markup classes. Defaults shown below:
new SpinBox({
main: "UIsp",
btn: "UIsp__btn",
input: "UIsp__input",
disabledBtn: "disabled",
});
new Select({
idPrefix: "UI-option-",
main: "UIselect",
selected: "UIselect-selected",
arrow: "UIselect-arrow",
optionsList: "UIselect-options",
search: "UIselect-options__search",
items: "UIselect-options__items",
flash: "UIselect--flash",
excludedItems: ["divider"], // <li> classes/ids excluded from selection
});
new Switch({ main: "UIsw", label: "UIsw-label" });
new ButtonGroup({ main: "UIbg", btn: "UIbg-btn", input: "UIbg-input" });
new Button({ main: "UIb" });Events
Each component dispatches a bubbling CustomEvent whose detail is { id, value }:
const el = document.getElementById("mySpinbox");
el.addEventListener("ui-spinbox-change", (e) => {
console.log(e.detail); // { id: "mySpinbox", value: "3.1415" }
});| Component | Event name | detail.value |
|---|---|---|
| SpinBox | ui-spinbox-change | current numeric value as string |
| Select | ui-select-change | selected data-value |
| Switch | ui-switch-change | "true" / "false" |
| ButtonGroup | ui-button-group-change | selected input[value] |
| Button | ui-button-change | element's data-value |
Toast and Nav report state rather than a value, so their detail is { id } alone.
See the Toast docs and the Nav docs.
| Component | Event name | detail |
|---|---|---|
| Toast | ui-toast-show / ui-toast-dismiss | { id } |
| Nav | ui-nav-open / ui-nav-close | { id } |
TypeScript typing
type UIChangeDetail = { id: string; value: string };
document.addEventListener("ui-select-change", (e) => {
const { id, value } = (e as CustomEvent<UIChangeDetail>).detail;
});Programmatic setValue
Every component shares one signature: setValue(el, value, options?), where el
is the component's root element and options is { silent?, flash? } — silent: true
suppresses the change event, flash: false skips the flash animation (both default off /
on respectively). Button additionally accepts { label }, which is written into the
.UIb__label slot when the markup has one — so icons around it survive. Without that slot
it replaces the button's whole content, so a button with icons needs a .UIb__label.
const sp = new SpinBox();
sp.setValue(document.getElementById("mySpinbox"), 3.1415);
const sl = new Select();
sl.setValue(document.getElementById("mySelect"), "name"); // matches data-value
const sw = new Switch();
sw.setValue(document.getElementById("mySwitch"), true); // (el, boolean)
const bg = new ButtonGroup();
bg.setValue(document.getElementById("myGroup"), "btncheck2"); // matches input[value]
const btn = new Button();
btn.setValue(document.getElementById("myButton"), "/api/users/42", { label: "Edit profile" });
// Quiet bulk fill — write the value without emitting or flashing:
sl.setValue(document.getElementById("mySelect"), "name", { silent: true, flash: false });Lifecycle (SPA-friendly)
Each instance attaches its listeners through a single AbortController created once in the
constructor. The three building blocks:
scan()— binds every not-yet-bound element. It is idempotent: each control gets a per-type marker (data-uib-bound,data-uisel-bound,data-uisp-bound,data-uisw-bound,data-uibg-bound) and is skipped on later passes. Call it after rendering new markup — no double-binding.destroy()— teardown only: aborts all listeners, disconnects the observer, clears the markers and recreates theAbortControllerso the same instance canscan()again. It does not disable elements (behavior change — usesetDisabled()on Button for that).- Markup-disabled elements (
data-disabled, ordisabledon the inner input) still render disabled on everyscan().
const sl = new Select();
// ...after rendering new selects into the view:
sl.scan(); // additive — existing listeners untouched, new ones bound
// ...on final teardown:
sl.destroy(); // listeners removed; elements left as-isScope to a root
Pass a third options argument to bind only inside a subtree. Useful when several managers of
the same type live on one page (e.g. a parent view plus a child component):
const widget = document.getElementById("cart-widget");
const buttons = new Button({}, false, { root: widget }); // ignores .UIb outside the widgetAuto re-scan with observe
Add observe: true to attach a debounced MutationObserver on the root; new elements are
bound automatically, so you can drop manual scan() calls:
new Button({}, false, { root: appEl, observe: true });Singleton factory
If you'd rather not hold your own single instance per type, use the lazy factories:
import {
getButtonManager,
getSelectManager,
getSpinBoxManager,
getSwitchManager,
getButtonGroupManager,
getToastManager,
resetManagers,
} from "@popovandrii/ui-elements";
getButtonManager({}, false, { observe: true }).scan(); // first call configures, then reused
getToastManager({ position: "bottom-center" }); // imperative — no scan()
resetManagers(); // drop all cached singletonsThe first call configures the manager (its arguments match the constructor); later calls return the same instance.
Animations
- Ripple — radial wave on click
- Flash —
box-shadowglow whensetValueis called
Opt out per element or for any ancestor container:
<div class="UIsp ui-no-ripple ui-no-flash">...</div>
<!-- disable globally -->
<body class="ui-no-ripple ui-no-flash">Caption label (.UIlabel)
A title placed above a control. SpinBox and Switch build their caption in
(.UIsp-label, and .UIsw-label with the label-top modifier); Select and
ButtonGroup have no built-in caption, so they use the shared .UIlabel class.
Drop it as the first child of the control:
<div class="UIselect primary" tabindex="0" role="listbox" aria-labelledby="fruit-cap">
<span class="UIlabel" id="fruit-cap">Fruit</span>
<!-- selected / options … -->
</div>
<div class="UIbg primary border" role="radiogroup" aria-labelledby="view-cap">
<span class="UIlabel" id="view-cap">View mode</span>
<!-- radio inputs + labels … -->
</div>- Layout-safe — the caption is floated out of flow above the control, so it never becomes a flex/grid cell; the host reserves the space with a top margin.
- Themed help icon — a nested
.UIqlquestion icon inherits the host component's color automatically (see below). - Dims with the control — a disabled Select/ButtonGroup fades its caption too.
- A11y — give the caption an
idand point the control at it witharia-labelledbyso screen readers announce it.
Label tooltip (.UIql)
A small question-mark icon with a hover/focus tooltip you can drop into any
component label. The base look and animation are pure CSS; the optional
initQuestionTooltips() helper (below) adds viewport-aware flipping and link
handling. The visible hint text lives in the inner <span>:
<div class="UIsp-label">
Amount
<span class="UIql" tabindex="0" aria-label="Help">
<span class="UIql__text" role="tooltip">Your hint goes here.</span>
</span>
</div>Themed automatically — nested in any component (spin box, switch, select, button, button-group) the circle and bubble inherit that component's color; standalone they fall back to grey. Override the
--ql-*custom properties (--ql-bg,--ql-fg,--ql-border,--ql-tip-bg,--ql-tip-fg) to retheme.Opens on hover and keyboard focus, and a mouse click keeps it open until you click away (
tabindex="0"is required for the focus/click behavior). The bubble grows out of the icon with a small springy morph.Click-to-open opt-in — add
.UIql--clickto drop the hover trigger so the bubble opens only on click (clicking focuses the icon, which opens it; clicking away closes it). Pure CSS, no JS option needed.Stays on screen — the bubble is capped to the viewport width. Near a viewport edge, add
.UIql--right(opens right, for icons near the left edge),.UIql--left(opens left, for icons near the right edge), or.UIql--down(opens below, for icons near the top edge).Rich hint content — the inner
<span>accepts inline HTML:<span class="UIql__text" role="tooltip"> Use <strong>bold</strong>, <em>italics</em> and a <a href="https://example.com">link</a>. See the <a href="/docs">docs</a> for more. </span>Links are clickable — the bubble stays open while the pointer travels onto it, and
initQuestionTooltips()(below) setstarget="_blank"+ a saferelso following one opens in a new tab instead of unloading the host page. Neither the icon nor an in-hint link toggles the surrounding control.Selectable — the hint text can be selected and copied.
Auto-flip the tooltip (optional)
To place those edge modifiers automatically, call initQuestionTooltips()
once. It attaches a few delegated listeners to a root (document by
default), so every .UIql — including ones added to the DOM later — is handled
with no per-element wiring. It:
- before a tooltip opens, measures the room on each side, flips the bubble
inward (
--right/--left/--down) if needed, and caps its width to the available space (re-adapting on resize); - opens hint links in a new tab — sets
target="_blank"and a hardenedrelona[href]inside a hint (an author's explicittargetis kept); - stops a click on the
?(or on an in-hint link) from toggling a surrounding control, e.g. a switch whose label hosts the icon.
import { initQuestionTooltips } from "@popovandrii/ui-elements";
const teardown = initQuestionTooltips(); // whole page
// initQuestionTooltips(myDialog); // or scope to a container
// teardown(); // remove the listenersThe base tooltip works without it, but the helper is recommended whenever hints
sit near a viewport edge or contain links (pure CSS can't detect the edge across
all browsers, nor harden links). It returns a teardown that removes the
listeners. With the UMD build it's UiElements.initQuestionTooltips().
Usage without a bundler (NodeJS + Express, UMD)
Serve the package's dist folder as static assets:
// app.js
app.use(
"/ui-elements/js",
express.static(path.join(__dirname, "node_modules/@popovandrii/ui-elements/dist"))
);<head>
<link rel="stylesheet" href="/ui-elements/js/style.css" />
<link rel="stylesheet" href="/ui-elements/js/theme-dark.css" />
<!-- <link rel="stylesheet" href="/ui-elements/js/theme-light.css" /> -->
<!-- <link rel="stylesheet" href="/ui-elements/js/theme-light-neon.css" /> -->
<!-- <link rel="stylesheet" href="/ui-elements/js/theme-dark-neon.css" /> -->
<!-- <link rel="stylesheet" href="/ui-elements/js/theme-light-solarized.css" /> -->
<!-- <link rel="stylesheet" href="/ui-elements/js/theme-dark-solarized.css" /> -->
<script src="/ui-elements/js/index.umd.js"></script>
</head>
<script>
// UMD exposes a global `UiElements`
new UiElements.SpinBox();
new UiElements.Switch();
new UiElements.Select();
</script>Documentation
Each component has detailed docs covering markup, attributes, events, disabled states and examples:
- SpinBox — numeric input with +/− buttons, min/max, decimal precision
- Select — dropdown with optional search and keyboard navigation
- Switch — toggle based on
<input type="checkbox"> - ButtonGroup (radio) — radio group styled as buttons
- Button — styled button / link with event dispatch
- Toast — transient notifications, no markup to write
- Nav — navigation bar that becomes a modal burger drawer below 576px
The shared label tooltip (.UIql) works with any of them.
Development
This project runs inside Docker (node:24-alpine); Node/npm are not required on the host.
git clone [email protected]:AndreyPopov/ui-elements.git && cd ui-elements
docker compose up -d --build # start the dev container
docker compose exec vite-dev sh # shell into itInside the container:
npm install
npm run dev # vite dev playground on port 5173
npm run build # build to ./dist (node build.js)
npm run test # vitest
npm run lint # eslint
npm run format # prettierThe ./playground folder hosts the browser demo and unit tests.
Publishing
npm install # refresh package-lock.json
npm login
npm publish --access public
npm info @popovandrii/ui-elements@latestnpm publish runs the prepublishOnly script (node build.js) automatically, so a
fresh dist/ is always built before the tarball is packed — no manual build step
needed. (npm pack does not run it; build manually if you pack by hand.)
Releasing
dist/ is git-ignored and never committed — it is generated at publish time by
prepublishOnly. Tags use the vX.Y.Z scheme and point at the merge commit on
main.
# 1. Bump the version on dev (no auto-tag — we tag on main after the merge)
npm version 0.2.0 --no-git-tag-version
git commit -am "Version: 0.2.0"
# 2. Push dev and open a merge request dev -> main on GitLab, then merge it
git push origin dev
# 3. Tag the merged main (annotated) and push the tag
git fetch origin
git tag -a v0.2.0 origin/main -m "Release 0.2.0"
git push origin v0.2.0
# 4. Publish to npm (dist/ is built automatically) — see "Publishing" aboveOn GitLab, create the release from Deploy → Releases → New release, pick the
vX.Y.Z tag and paste the changelog as the release notes.
License
MIT © Andrii Popov
