npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

AI agents / LLMs — read llms.txt before 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.txt is the complete markup contract (required children and their order, every data-*, 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 and MutationObserver auto re-scan, teardown-only destroy()
  • 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@latest

Quick 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 close

See 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:

  1. Icons never give way — they keep their box instead of scaling down to fit the text.
  2. Padding and gap shrink first.
  3. The label ellipsizes — it is the only part allowed to shrink, and it never wraps.
  4. 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 mode

Themes

Theming has two layers:

  • style.css — required. Contains the component structure and the default light palette at :root, so the components render correctly with just this file and no data-theme set.
  • theme-*.css — optional. Each provides one palette scoped under a higher-specificity html[data-theme="..."] selector, so it overrides the :root default whenever the matching data-theme is 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 into style.css so importing the components never restyles your page; import it only if you want the reset, alongside style.css/a theme. It also bumps the root font-size on small screens (the components are rem-based, so this scales the whole system up on phones). If you don't import base.css but want that scaling, set a responsive font-size on :root/html yourself.

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 works

Add 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 light

Activate 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-solarized

Themes 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 the AbortController so the same instance can scan() again. It does not disable elements (behavior change — use setDisabled() on Button for that).
  • Markup-disabled elements (data-disabled, or disabled on the inner input) still render disabled on every scan().
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-is

Scope 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 widget

Auto 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 singletons

The first call configures the manager (its arguments match the constructor); later calls return the same instance.

Animations

  • Ripple — radial wave on click
  • Flashbox-shadow glow when setValue is 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 .UIql question 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 id and point the control at it with aria-labelledby so 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--click to 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) sets target="_blank" + a safe rel so 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 hardened rel on a[href] inside a hint (an author's explicit target is 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 listeners

The 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 it

Inside 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   # prettier

The ./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@latest

npm 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" above

On 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