Skip to content

inertiaui/vanilla

Repository files navigation

Inertia UI Vanilla

A lightweight vanilla TypeScript library providing UI utilities for dialogs, animations, focus management, menu navigation, click outside detection, floating element positioning, and common helper functions. Framework-agnostic and designed to integrate seamlessly with Vue, React, or any JavaScript application.

This package is part of the Inertia UI suite. Check out our other packages:

  • Inertia Modal: Turn any Laravel route into a modal or slideover with a single component. No backend changes needed, with support for nested/stacked modals and inter-modal communication. Works with Vue and React.
  • Inertia Table: The most complete data table package for Laravel and Inertia.js. Sorting, searching, and filtering across relationships, bulk actions, CSV/Excel/PDF exports, sticky headers, and much more. Works with Vue and React.

Inertia UI

Installation

npm install @inertiaui/vanilla

Table of Contents

Scroll Locking

The lockScroll function prevents body scroll while dialogs or modals are open, with reference counting support for nested dialogs.

Basic Usage

import { lockScroll } from '@inertiaui/vanilla'

const unlock = lockScroll()

// Later, unlock
unlock()

The function:

  • Sets document.body.style.overflow to 'hidden'
  • Adds padding to compensate for scrollbar width (prevents layout shift)
  • Returns a cleanup function that can only unlock once

Reference Counting

Multiple calls to lockScroll are reference counted. The body scroll is only restored when all locks are released:

import { lockScroll } from '@inertiaui/vanilla'

const unlock1 = lockScroll()
const unlock2 = lockScroll()

// Body is locked

unlock1()
// Body is still locked (one reference remaining)

unlock2()
// Body scroll is restored

Idempotent Unlock

Each cleanup function can only unlock once, preventing accidental double-unlocking:

const unlock = lockScroll()
unlock() // Decrements count
unlock() // No effect
unlock() // No effect

Focus Management

Focus management utilities help create accessible dialogs by trapping focus and managing focusable elements.

createFocusTrap

Creates a focus trap within a container element.

import { createFocusTrap } from '@inertiaui/vanilla'

const cleanup = createFocusTrap(dialogElement)

// Later, remove the focus trap
cleanup()

Options

Option Type Default Description
initialFocus boolean true Focus first element immediately
initialFocusElement HTMLElement | null null Specific element to focus initially
returnFocus boolean true Return focus to previous element on cleanup

Behavior

The focus trap:

  • Listens for Tab key and wraps focus at container boundaries
  • Prevents focus from leaving the container via Tab or Shift+Tab
  • Catches focus that escapes (e.g., via mouse click outside)
  • Optionally focuses the first focusable element on creation
  • Optionally returns focus to the previously focused element on cleanup
  • Supports nesting: when multiple traps are active, only the most recently created trap receives focus. Cleaning up the inner trap restores the outer trap.
const container = document.getElementById('dialog')!
const submitButton = document.getElementById('submit')

const cleanup = createFocusTrap(container, {
    initialFocusElement: submitButton, // Focus submit button instead of first element
})

Focusable Elements

The focus trap recognizes these elements as focusable:

  • a[href]
  • button:not([disabled])
  • textarea:not([disabled])
  • input:not([disabled])
  • select:not([disabled])
  • [tabindex]:not([tabindex="-1"])

Elements with aria-hidden="true" are excluded. (Elements with disabled are already filtered by the selectors above.)

Keyboard Events

onEscapeKey

Registers an Escape key handler.

import { onEscapeKey } from '@inertiaui/vanilla'

const cleanup = onEscapeKey((event) => {
    console.log('Escape pressed!')
})

// Later, remove the handler
cleanup()

Options

Option Type Default Description
preventDefault boolean false Call event.preventDefault()
stopPropagation boolean false Call event.stopPropagation()
const cleanup = onEscapeKey(handleEscape, {
    preventDefault: true,
    stopPropagation: true,
})

Cleanup Pattern

The cleanup function pattern integrates well with framework lifecycle hooks:

// Vue (<script setup>)
const cleanup = onEscapeKey(closeDialog)
onUnmounted(() => cleanup())

// React
useEffect(() => {
    return onEscapeKey(closeDialog)
}, [])

Click Outside

The onClickOutside function detects clicks outside one or more elements and calls a callback. Useful for closing dropdowns, popovers, and modals when the user clicks elsewhere.

Basic Usage

import { onClickOutside } from '@inertiaui/vanilla'

const cleanup = onClickOutside(dropdownElement, (event) => {
    closeDropdown()
})

// Later, remove the listener
cleanup()

Multiple Elements

Pass an array of elements to ignore clicks inside any of them:

const cleanup = onClickOutside([triggerButton, dropdownPanel], () => {
    closeDropdown()
})

Portal Support

Clicks inside elements with the data-inertiaui-portal attribute (or their descendants) are automatically ignored. This prevents portalled content like dropdown menus from being considered "outside":

<div data-inertiaui-portal>
    <!-- Clicks here won't trigger the callback -->
</div>

Same-Tick Protection

The listener registration is deferred by one tick, so the click that triggered the element to open won't immediately close it:

openButton.addEventListener('click', () => {
    dropdown.hidden = false
    // The click on openButton won't trigger the outside handler
    onClickOutside(dropdown, () => { dropdown.hidden = true })
})

Menu Navigation

The createMenuNavigation function adds keyboard navigation to menu containers, implementing the WAI-ARIA Menu Pattern. It supports arrow key navigation, roving tabindex, type-ahead search, and item activation.

Basic Usage

import { createMenuNavigation } from '@inertiaui/vanilla'

const cleanup = createMenuNavigation(menuElement)

// Later, remove navigation
cleanup()

Options

Option Type Default Description
itemSelector string '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])' CSS selector for menu items
orientation 'vertical' | 'horizontal' 'vertical' Arrow key direction
loop boolean true Wrap focus from last to first item
typeAhead boolean true Enable type-ahead character search
onActivate (item: HTMLElement) => void undefined Called when an item is activated via Enter or Space

Keyboard Support

Key Action
ArrowDown / ArrowRight Focus next item (depending on orientation)
ArrowUp / ArrowLeft Focus previous item (depending on orientation)
Home Focus first item
End Focus last item
Enter / Space Activate (click) the focused item
Any character Type-ahead: focus the first item whose text starts with the typed characters

Roving Tabindex

The focused item receives tabindex="0" while all other items receive tabindex="-1". This allows the menu to participate in the page's tab order with a single tab stop:

const cleanup = createMenuNavigation(menuElement)

// First item has tabindex="0", rest have tabindex="-1"
// Arrow keys move focus and update tabindex accordingly

Horizontal Menus

For horizontal menus like toolbars, use orientation: 'horizontal':

const cleanup = createMenuNavigation(toolbar, {
    orientation: 'horizontal',
})
// ArrowRight/ArrowLeft navigate instead of ArrowDown/ArrowUp

Custom Item Selector

Use a custom selector for non-standard menu structures:

const cleanup = createMenuNavigation(container, {
    itemSelector: '.menu-item:not(.disabled)',
})

Full Example

import { createMenuNavigation, onClickOutside, onEscapeKey } from '@inertiaui/vanilla'

function openMenu(menuElement: HTMLElement) {
    menuElement.hidden = false

    const cleanups = [
        createMenuNavigation(menuElement, {
            onActivate: (item) => {
                handleMenuAction(item.dataset.action!)
                closeMenu()
            },
        }),
        onClickOutside(menuElement, closeMenu),
        onEscapeKey(closeMenu),
    ]

    function closeMenu() {
        cleanups.forEach((fn) => fn())
        menuElement.hidden = true
    }
}

Positioning

Utilities for positioning floating elements (dropdowns, tooltips, popovers) relative to a reference element. Uses CSS Anchor Positioning when supported, with an automatic JavaScript fallback.

supportsAnchorPositioning

Check if the browser supports CSS Anchor Positioning:

import { supportsAnchorPositioning } from '@inertiaui/vanilla'

if (supportsAnchorPositioning()) {
    // Browser handles positioning via CSS
} else {
    // JavaScript fallback is used
}

The result is cached after the first call.

computePosition

Position a floating element relative to a reference element:

import { computePosition } from '@inertiaui/vanilla'

const result = computePosition(referenceElement, floatingElement)
// { x: 100, y: 140, placement: 'bottom-start' }

The function applies positioning styles to the floating element automatically (position: fixed with top and left values).

Options

Option Type Default Description
placement Placement 'bottom-start' Where to position the floating element
offset number 0 Distance in pixels between reference and floating element
flip boolean true Flip to opposite side when overflowing viewport

Placements

Twelve placement options are available, combining a side with an optional alignment:

Side Center Start End
top top top-start top-end
bottom bottom bottom-start bottom-end
left left left-start left-end
right right right-start right-end

The start and end alignments are RTL-aware for top and bottom placements.

Flip Behavior

When the floating element would overflow the viewport, it automatically flips to the opposite side:

// If there's no room below, flips to top
const result = computePosition(reference, floating, {
    placement: 'bottom-start',
})
// result.placement may be 'top-start' if it flipped

Disable flipping to keep the element on the specified side:

const result = computePosition(reference, floating, {
    placement: 'bottom-start',
    flip: false,
})

Viewport Clamping

The floating element is always clamped to stay within the viewport with a 4px margin, even after flipping.

autoUpdate

Automatically reposition the floating element when the layout changes:

import { computePosition, autoUpdate } from '@inertiaui/vanilla'

const cleanup = autoUpdate(referenceElement, floatingElement, () => {
    computePosition(referenceElement, floatingElement, {
        placement: 'bottom-start',
        offset: 8,
    })
})

// Later, stop updating
cleanup()

autoUpdate listens for:

  • Window resize events
  • Window scroll events (including nested scrollable containers)
  • Size changes on both the reference and floating elements (via ResizeObserver)

Updates are batched using requestAnimationFrame to avoid layout thrashing.

CSS Anchor Positioning

When the browser supports CSS Anchor Positioning, computePosition uses native CSS for positioning. This provides better performance and handles edge cases like scrolling and resizing without JavaScript recalculation. The autoUpdate cleanup function automatically removes CSS anchor styles when called.

Full Example

import { computePosition, autoUpdate } from '@inertiaui/vanilla'

function setupTooltip(trigger: HTMLElement, tooltip: HTMLElement) {
    tooltip.style.display = 'block'

    const cleanup = autoUpdate(trigger, tooltip, () => {
        computePosition(trigger, tooltip, {
            placement: 'top',
            offset: 8,
        })
    })

    // Initial position
    computePosition(trigger, tooltip, {
        placement: 'top',
        offset: 8,
    })

    return cleanup
}

Accessibility

Accessibility utilities for managing aria-hidden attributes with reference counting support.

markAriaHidden

Marks an element as aria-hidden="true" and returns a cleanup function.

import { markAriaHidden } from '@inertiaui/vanilla'

const cleanup = markAriaHidden('#app')

// Later, restore
cleanup()

Accepts either an element or a CSS selector:

// Using selector
const cleanup1 = markAriaHidden('#app')

// Using element
const element = document.getElementById('app')!
const cleanup2 = markAriaHidden(element)

Reference Counting

Like scroll locking, aria-hidden management uses reference counting for nested dialogs:

import { markAriaHidden } from '@inertiaui/vanilla'

const cleanup1 = markAriaHidden('#app')
const cleanup2 = markAriaHidden('#app')

// Element is aria-hidden="true"

cleanup1()
// Element is still aria-hidden="true" (one reference remaining)

cleanup2()
// Element's aria-hidden is restored to original value

Original Value Preservation

The original aria-hidden value is preserved and restored:

const element = document.getElementById('sidebar')!
element.setAttribute('aria-hidden', 'false')

const cleanup = markAriaHidden(element)
element.getAttribute('aria-hidden') // 'true'

cleanup()
element.getAttribute('aria-hidden') // 'false' (restored)

If the element had no aria-hidden attribute, the attribute is removed on cleanup:

const element = document.getElementById('main')!
// No aria-hidden attribute

const cleanup = markAriaHidden(element)
element.getAttribute('aria-hidden') // 'true'

cleanup()
element.getAttribute('aria-hidden') // null (removed)

Use with Dialogs

When a dialog opens, the main content should be marked as aria-hidden to prevent screen readers from reading background content:

import { markAriaHidden, lockScroll, createFocusTrap, onEscapeKey } from '@inertiaui/vanilla'

function openDialog(dialogElement: HTMLElement) {
    const closeDialog = () => cleanups.forEach(fn => fn())

    const cleanups = [
        markAriaHidden('#app'),
        lockScroll(),
        createFocusTrap(dialogElement),
        onEscapeKey(closeDialog),
    ]

    return closeDialog
}

Animation

The animation module provides a simple wrapper around the Web Animations API with Tailwind CSS-compatible easing functions.

animate

Animate an element using the Web Animations API. Returns a promise that resolves when the animation completes. If the animation is cancelled (e.g., by calling cancelAnimations), the promise resolves with the Animation object instead of rejecting.

import { animate } from '@inertiaui/vanilla'

await animate(element, [
    { transform: 'scale(0.95)', opacity: 0 },
    { transform: 'scale(1)', opacity: 1 }
])

Options

Option Type Default Description
duration number 300 Animation duration in milliseconds
easing string | EasingName 'inOut' Easing function (see below)
fill FillMode 'forwards' Animation fill mode
await animate(element, keyframes, { duration: 200, easing: 'out' })

easings

Pre-defined easing functions matching Tailwind CSS:

import { easings } from '@inertiaui/vanilla'

// Available easings:
easings.linear  // 'linear'
easings.in      // 'cubic-bezier(0.4, 0, 1, 1)'
easings.out     // 'cubic-bezier(0, 0, 0.2, 1)'
easings.inOut   // 'cubic-bezier(0.4, 0, 0.2, 1)'

You can use easing names directly:

await animate(element, keyframes, { easing: 'out' })

Or provide a custom easing string:

await animate(element, keyframes, { easing: 'cubic-bezier(0.68, -0.55, 0.27, 1.55)' })

cancelAnimations

Cancel any running animations on an element:

import { cancelAnimations } from '@inertiaui/vanilla'

cancelAnimations(element)

Full Example

import { animate, cancelAnimations } from '@inertiaui/vanilla'

async function showModal(modal: HTMLElement) {
    modal.hidden = false

    await animate(modal, [
        { transform: 'scale(0.95)', opacity: 0 },
        { transform: 'scale(1)', opacity: 1 }
    ], { duration: 150, easing: 'out' })
}

async function hideModal(modal: HTMLElement) {
    await animate(modal, [
        { transform: 'scale(1)', opacity: 1 },
        { transform: 'scale(0.95)', opacity: 0 }
    ], { duration: 100, easing: 'in' })

    modal.hidden = true
}

function forceHideModal(modal: HTMLElement) {
    cancelAnimations(modal)
    modal.hidden = true
}

Dark Mode Detection

The prefersDarkMode function detects whether the user prefers dark mode, with support for multiple detection strategies.

Basic Usage

import { prefersDarkMode } from '@inertiaui/vanilla'

const isDark = prefersDarkMode()

Strategies

Strategy Behavior
'auto' (default) Checks <html class="dark"> first, then prefers-color-scheme: dark media query
'class' / 'selector' Checks <html class="dark">
'media' Checks prefers-color-scheme: dark media query
Custom function Called directly for full control
// Class-based detection (e.g., Tailwind dark mode)
prefersDarkMode('class')

// Media query only
prefersDarkMode('media')

// Custom logic
prefersDarkMode(() => document.body.dataset.theme === 'dark')

RTL Support

Utilities for detecting and observing right-to-left document direction.

isRtl

Check whether the document is currently in RTL direction:

import { isRtl } from '@inertiaui/vanilla'

if (isRtl()) {
    // Document is right-to-left
}

onRtlChange

Observe changes to the document's dir attribute and invoke a callback whenever it changes. Returns a cleanup function.

import { onRtlChange } from '@inertiaui/vanilla'

const cleanup = onRtlChange((rtl) => {
    console.log('RTL changed:', rtl)
})

// Later, stop observing
cleanup()

Debounce

debounce

Debounce a function using requestAnimationFrame. Ensures the function runs at most once per animation frame.

import { debounce } from '@inertiaui/vanilla'

const handleScroll = debounce(() => {
    updatePosition()
})

window.addEventListener('scroll', handleScroll)

detectFramerate

Detect the browser's current framerate. Returns a Promise that resolves with the detected FPS (capped to the 30–240 range). Falls back to 60 if requestAnimationFrame is unavailable or detection times out.

import { detectFramerate } from '@inertiaui/vanilla'

const fps = await detectFramerate()
console.log(`Running at ${fps} FPS`)

Helpers

generateId

Generates a unique ID using crypto.randomUUID() with a fallback for environments where it's not available.

import { generateId } from '@inertiaui/vanilla'

const id = generateId()
// 'inertiaui_550e8400-e29b-41d4-a716-446655440000'

Custom Prefix

generateId('modal_')
// 'modal_550e8400-e29b-41d4-a716-446655440000'

generateId('dialog-')
// 'dialog-550e8400-e29b-41d4-a716-446655440000'

Fallback

In environments where crypto.randomUUID() is not available, the function falls back to a combination of timestamp and random string:

// Fallback format:
// '{prefix}{timestamp}_{random}'
// 'inertiaui_m5x2k9p_7h3j5k9a2'

Use Cases

Useful for generating unique IDs for:

  • Dialog instances
  • Form elements requiring unique IDs
  • Accessibility attributes (aria-labelledby, aria-describedby)
  • Tracking modal instances
const dialogId = generateId('dialog_')
const titleId = generateId('title_')
const descId = generateId('desc_')

dialog.setAttribute('aria-labelledby', titleId)
dialog.setAttribute('aria-describedby', descId)
title.id = titleId
description.id = descId

blank

A port of Laravel's blank function. Returns true if the value is "empty" — null, undefined, empty string (or whitespace-only), empty array, or empty object.

import { blank } from '@inertiaui/vanilla'

blank(null)        // true
blank(undefined)   // true
blank('')          // true
blank('  ')        // true
blank([])          // true
blank('hello')     // false
blank(0)           // false
blank(false)       // false

onceChildrenRendered

Invokes a callback once the given element has child elements. If the element already has children, the callback fires immediately. Otherwise, it uses a MutationObserver to wait for children to appear.

import { onceChildrenRendered } from '@inertiaui/vanilla'

onceChildrenRendered(containerElement, () => {
    // Children are now present in the DOM
    initializeContent()
})

Object Filtering

except

Returns an object or array without the specified keys/elements.

Objects:

import { except } from '@inertiaui/vanilla'

const obj = { a: 1, b: 2, c: 3 }
except(obj, ['b'])
// { a: 1, c: 3 }

Arrays:

const arr = ['a', 'b', 'c', 'd']
except(arr, ['b', 'd'])
// ['a', 'c']

Case-Insensitive Matching:

const obj = { Name: 1, AGE: 2, city: 3 }
except(obj, ['name', 'age'], true)
// { city: 3 }

const arr = ['Name', 'AGE', 'city']
except(arr, ['name', 'age'], true)
// ['city']

only

Returns an object or array with only the specified keys/elements.

Objects:

import { only } from '@inertiaui/vanilla'

const obj = { a: 1, b: 2, c: 3 }
only(obj, ['a', 'c'])
// { a: 1, c: 3 }

Arrays:

const arr = ['a', 'b', 'c', 'd']
only(arr, ['b', 'd'])
// ['b', 'd']

Case-Insensitive Matching:

const obj = { Name: 1, AGE: 2, city: 3 }
only(obj, ['name', 'city'], true)
// { Name: 1, city: 3 }

rejectNullValues

Removes null values from an object or array.

Objects:

import { rejectNullValues } from '@inertiaui/vanilla'

const obj = { a: 1, b: null, c: 3 }
rejectNullValues(obj)
// { a: 1, c: 3 }

Arrays:

const arr = [1, null, 3, null, 5]
rejectNullValues(arr)
// [1, 3, 5]

Note: rejectNullValues only removes null values, not undefined. Use this when you want to keep undefined values but remove explicit nulls.

String Utilities

kebabCase

Converts a string to kebab-case.

import { kebabCase } from '@inertiaui/vanilla'

kebabCase('camelCase')       // 'camel-case'
kebabCase('PascalCase')      // 'pascal-case'
kebabCase('snake_case')      // 'snake-case'
kebabCase('already-kebab')   // 'already-kebab'

Handling Special Cases:

kebabCase('user123Name')           // 'user123-name'
kebabCase('multiple__underscores') // 'multiple-underscores'
kebabCase('UPPERCASE')             // 'uppercase'
kebabCase('XMLDocument')           // 'xml-document'
kebabCase('hello world')           // 'hello-world'

isStandardDomEvent

Checks if an event name is a standard DOM event.

import { isStandardDomEvent } from '@inertiaui/vanilla'

isStandardDomEvent('onClick')     // true
isStandardDomEvent('onMouseOver') // true
isStandardDomEvent('onKeyDown')   // true
isStandardDomEvent('onCustom')    // false

Supported Event Categories:

  • Mouse events: click, dblclick, mousedown, mouseup, mouseover, mouseout, mousemove, mouseenter, mouseleave
  • Keyboard events: keydown, keyup, keypress
  • Form events: focus, blur, change, input, submit, reset
  • Window events: load, unload, error, resize, scroll
  • Touch events: touchstart, touchend, touchmove, touchcancel
  • Pointer events: pointerdown, pointerup, pointermove, pointerenter, pointerleave, pointercancel
  • Drag events: drag, dragstart, dragend, dragenter, dragleave, dragover, drop
  • Animation events: animationstart, animationend, animationiteration
  • Transition events: transitionstart, transitionend, transitionrun, transitioncancel

Case Insensitive:

isStandardDomEvent('onclick')  // true
isStandardDomEvent('ONCLICK')  // true
isStandardDomEvent('OnClick')  // true

Use Case:

Useful for distinguishing between standard DOM events and custom events when processing event handlers:

const props: Record<string, Function> = {
    onClick: handleClick,
    onMouseOver: handleHover,
    onModalReady: handleModalReady,
    onUserUpdated: handleUserUpdated,
}

const domEvents: Record<string, Function> = {}
const customEvents: Record<string, Function> = {}

for (const [key, value] of Object.entries(props)) {
    if (isStandardDomEvent(key)) {
        domEvents[key] = value
    } else {
        customEvents[key] = value
    }
}

// domEvents: { onClick, onMouseOver }
// customEvents: { onModalReady, onUserUpdated }

URL Utilities

sameUrlPath

Compares two URLs to determine if they have the same origin and pathname, ignoring query strings and hash fragments.

import { sameUrlPath } from '@inertiaui/vanilla'

sameUrlPath('/users/1', '/users/1')           // true
sameUrlPath('/users/1', '/users/1?tab=posts') // true
sameUrlPath('/users/1', '/users/2')           // false
sameUrlPath('/users', '/posts')               // false

Accepts URL objects:

const url1 = new URL('https://example.com/users/1')
const url2 = new URL('https://example.com/users/1?page=2')

sameUrlPath(url1, url2) // true

Handles null/undefined:

sameUrlPath(null, '/users')      // false
sameUrlPath('/users', undefined) // false
sameUrlPath(null, null)          // false

Use Case:

Useful for determining active navigation states or comparing the current route with a link destination:

const isActive = sameUrlPath(window.location.href, linkHref)

Development

Running the dev server

Start Vite to browse the interactive test pages:

npx vite --port 3333

Then open http://localhost:3333 for an overview linking to all test pages.

Running E2E tests

The test suite uses Playwright with Chromium. It automatically starts a Vite dev server:

npm run test:e2e

Run a single spec:

npx playwright test e2e/menu.spec.ts

Run tests matching a name:

npx playwright test -g "ArrowDown"

Test structure

Each feature has a spec file and a matching HTML page:

e2e/click-outside.spec.ts  →  e2e/pages/click-outside.html
e2e/menu.spec.ts           →  e2e/pages/menu.html
e2e/focus-trap.spec.ts     →  e2e/pages/focus-trap.html
...

The mapping is handled by a custom fixture in e2e/test.ts. HTML pages import directly from the TypeScript source via Vite.

TypeScript

This library is written in TypeScript and exports the following types:

import type {
    CleanupFunction,
    FocusTrapOptions,
    EscapeKeyOptions,
    AnimateOptions,
    EasingName,
    MenuNavigationOptions,
    Placement,
    PositionOptions,
    PositionResult,
    DarkModeStrategy,
} from '@inertiaui/vanilla'

License

MIT

About

A lightweight vanilla TypeScript library providing UI utilities for dialogs, animations, focus management, and common helper functions.

Topics

Resources

License

Stars

Watchers

Forks

Contributors