Analyzing Oat — Ultra-Lightweight Semantic UI Library

Source: knadh/oat @ a7c3a5a

Overview

Oat is a ~8KB (minified + gzipped) zero-dependendency UI component library built with vanilla HTML, CSS, and JavaScript by Kailash Nadh. It was written out of frustration with the complexity and dependency churn of mainstream JavaScript UI frameworks. The project prioritizes semantic HTML, accessibility (ARIA roles, keyboard navigation), and long-term stability over feature breadth. Components like tabs and dropdowns use native WebComponents with progressive enhancement — they render meaningful HTML without JS, and enhance with interactivity when JS loads. The build system is a 30-line Makefile invoking esbuild; no Node.js ecosystem bloat.

How It Works

The library has two independent halves that are concatenated/bundled into a single CSS file and a single JS file:

CSS (6KB min+gz): 22 source files organized into CSS @layer cascades (theme → base → components → animations → utilities). The theme layer defines ~40 CSS custom properties for colors, spacing, typography, radii, shadows, and transitions. The base layer resets and styles native HTML elements (<h1>-<h6>, <p>, <button>, <input>, etc.) using element selectors and ARIA attribute selectors ([role="tab"]) rather than utility classes. Component layers then target complex composites like dialog, menu.buttons, and [popover].

JS (2.2KB min+gz): Five source files. A base class (OtBase) that all WebComponents extend, plus four component-specific files. The JS is a pure IIFE bundle — no module system, no imports at runtime, just window.ot.toast() API and custom element registration.

Note

There are no runtime dependencies. No build step is required to use Oat — just link oat.min.css and oat.min.js in any HTML page.

Architecture

src/
  css/
    00-base.css       # CSS reset + element styling + @layer declarations
    01-theme.css      # :root CSS custom properties (colors, spacing, radii, etc.)
    accordion.css
    alert.css
    animations.css
    avatar.css
    badge.css
    button.css
    card.css
    dialog.css
    dropdown.css
    form.css
    grid.css
    progress.css
    sidebar.css
    skeleton.css
    spinner.css
    table.css
    tabs.css
    toast.css
    tooltip.css
    utilities.css
  js/
    base.js           # OtBase WebComponent superclass
    index.js          # Entry point — registers all components, exposes window.ot API
    dropdown.js       # OtDropdown custom element
    sidebar.js        # Sidebar toggle (no class, pure event delegation)
    tabs.js           # OtTabs custom element
    toast.js          # Toast notification system (module-style functions)
    tooltip.js        # title → data-tooltip progressive enhancement

The entry point registers:

  • <ot-tabs> — keyboard-navigable tabs with ARIA
  • <ot-dropdown> — dropdown menu with arrow-key nav and viewport-flip positioning
  • <ot-toast>, ot.toast() — toast notification API
  • Tooltip enhancement via MutationObserver (converts title attributes)
  • Sidebar toggle via global event delegation
  • command/commandfor polyfill for <dialog> control

The Spine

Entry point for the user: Link oat.min.css + oat.min.js in HTML. No initialization call needed.

Entry point for JS: src/js/index.js imports all modules and attaches window.ot globals. When the <script> executes, it calls customElements.define() for each component. The browser handles registration; when matching elements appear in the DOM (or are already present), the custom element constructors run.

Request lifecycle for a <ot-tabs> interaction:

  1. User clicks a tab button.
  2. Browser fires click on the <ot-tabs> shadow-equivalent container (event bubbles).
  3. OtTabs.handleEvent() dispatches to onclick() via the onclick method naming convention.
  4. onclick() finds the clicked tab index, calls #activate(idx).
  5. #activate() updates aria-selected, tabindex, and hidden on all tabs/panels.
  6. this.emit('ot-tab-change', ...) fires a CustomEvent on the custom element.

Request lifecycle for ot.toast('message'):

  1. toast() creates an <output> element with data-variant.
  2. _show() appends it to a container <div>, calls .showPopover() (Popover API).
  3. Double requestAnimationFrame triggers CSS enter animation.
  4. setTimeout queues removal after duration ms.
  5. _remove() adds data-exiting attribute → CSS transition plays → transitionend fires cleanup.

Key Patterns

Semantic-first, classless styling

CSS targets native elements and ARIA attributes directly:

/* No class required — works on <button> anywhere */
:is(button, [type=submit]) { ... }
 
/* ARIA-driven — just add role="tablist" */
[role="tablist"] { display: flex; }
[role="tab"] { /* tab styling */ }

CSS @layer cascade

Layers provide a clean specificity contract. Theme variables are in the lowest layer; component styles in higher layers can override without specificity battles:

@layer theme, base, components, animations, utilities;

WebComponent base class (OtBase)

All interactive components extend OtBase, which provides:

  • connectedCallback/disconnectedCallback orchestration with init-once guard
  • handleEvent() method naming convention — onclick, onkeydown, ontoggle methods are auto-routed
  • keyNav() — roving keyboard navigation helper (arrow keys, home/end)
  • emit() — fires CustomEvent with bubbles: true, composed: true
  • uid() — generates a random ID string (10-char, base-36)
  • $(selector) / $$(selector) — query helpers scoped to the component

Progressive enhancement via Popover API

Toast containers and dropdowns use the native <div popover> pattern. This gives free backdrop handling, stacking context, click-outside dismissal, and keyboard accessibility without Oat JS implementing any of it.

Command polyfill

A small event listener in base.js handles command/commandfor attributes on buttons, enabling <button commandfor="my-dialog" command="show-modal">Open</button> without a framework. This is a hand-rolled alternative to the command HTML attribute that’s not yet universally supported.

Toast double-RAF animation pattern

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    el.removeAttribute('data-entering');
  });
});

This ensures the browser has computed initial styles (data-entering present) before removing the attribute, so CSS transitions fire correctly. Without this, the “enter” transition wouldn’t play on first load because the class would be removed before the browser painted.

Non-Obvious Details

The @starting-style dialog animations

dialog.css uses the CSS @starting-style at-rule to define the “before-open” state for enter animations. This is a relatively new CSS feature that defines a style for the element at the moment it opens. Combined with the allow-discrete transition syntax, it enables the “opacity 0 + scale 0.95 → opacity 1 + scale 1” dialog open animation without a JS animation library.

Viewport-flip positioning in dropdown

OtDropdown manually calculates and sets top/left on the <menu popover> element on open and on scroll/resize. The Popover API positions menus as position: fixed relative to the viewport, not the trigger. The flip logic (r.bottom + m.height > window.innerHeight ? r.top - m.height : r.bottom) ensures the menu never clips off-screen.

Toast transitionend timeout fallback

toast.js reads getComputedStyle(el).getPropertyValue('--transition') and parses both ms and s units. If transitionend never fires (e.g., user has reduced-motion enabled or the element was hidden), the setTimeout backup fires regardless. This is defensive programming for a cross-browser edge case.

MutationObserver for tooltip enhancement

tooltip.js sets up a MutationObserver on document.body watching childList, subtree, and title attribute changes. This catches dynamically inserted elements with title attributes — not just page load. It also removes the native title attribute to suppress the browser’s default tooltip, replacing it with the custom styled data-tooltip approach.

Touch backdrop dismissal

base.js has a touchstart listener that calls e.preventDefault() on <dialog> elements. This prevents the “ghost tap” problem on touch devices where a tap on the dialog backdrop would propagate to the element below after the dialog closes.

ssidebar.js is not a WebComponent — it’s a single global event listener on document that handles [data-sidebar-toggle] clicks and dismisses on outside clicks. No class, no registration, just side effects.

Assessment

Strengths:

  • Tiny, predictable bundle size — no supply chain risk, no version drift, no hidden bloat.
  • Genuine semantic HTML and ARIA correctness — buttons are <button>, tabs use role="tablist", dialogs are <dialog>.
  • Uses modern browser APIs (Popover, @starting-style, light-dark(), color-scheme) without polyfills where avoidable.
  • Zero tests, zero CI — but the code is simple enough that it arguably doesn’t need a heavy test suite. The author knows what they shipped.
  • Framework-agnostic: works in any HTML context regardless of frontend framework.

Concerns:

  • uid() uses Math.random() — not cryptographically random and guessable. Fine for UI IDs in non-security contexts; notable if IDs are ever used for anything beyond DOM targeting.
  • No versioning tags in git (package.json has "version-0.0.0" placeholder). Publishing uses git describe --tags but there are no tags yet.
  • Sub-v1 with no changelog or migration guide — API surface is small enough that this is manageable, but a stability guarantee would inspire more production confidence.
  • No test suite at all. While the simplicity mitigates risk, even a Playwright smoke test for each component would catch regressions.
  • No accessibility testing in CI (e.g., axe-core, jest-axe).

Recommendations:

  • If adopting: pin to a specific commit SHA rather than latest until v1 ships.
  • If contributing: add Playwright or Playwright-component tests for keyboard nav and ARIA state on <ot-tabs> and <ot-dropdown>.
  • Consider @starting-style browser support — it requires Safari 17.5+ and Chrome 117+. The dialog enter animation will be instant on older browsers, which is graceful degradation rather than a failure, but worth noting.

Tip

The library is well-suited for projects that want a decent-looking UI without a framework, or for embedding a lightweight UI layer inside a larger application that already uses a different primary framework. The shadcn-inspired aesthetic (neutral tones, subtle shadows, clean spacing) hits modern design expectations without effort.