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.
npm install @inertiaui/vanilla- Scroll Locking
- Focus Management
- Keyboard Events
- Click Outside
- Menu Navigation
- Positioning
- Accessibility
- Animation
- Dark Mode Detection
- RTL Support
- Debounce
- Helpers
The lockScroll function prevents body scroll while dialogs or modals are open, with reference counting support for nested dialogs.
import { lockScroll } from '@inertiaui/vanilla'
const unlock = lockScroll()
// Later, unlock
unlock()The function:
- Sets
document.body.style.overflowto'hidden' - Adds padding to compensate for scrollbar width (prevents layout shift)
- Returns a cleanup function that can only unlock once
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 restoredEach cleanup function can only unlock once, preventing accidental double-unlocking:
const unlock = lockScroll()
unlock() // Decrements count
unlock() // No effect
unlock() // No effectFocus management utilities help create accessible dialogs by trapping focus and managing focusable elements.
Creates a focus trap within a container element.
import { createFocusTrap } from '@inertiaui/vanilla'
const cleanup = createFocusTrap(dialogElement)
// Later, remove the focus trap
cleanup()| 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 |
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
})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.)
Registers an Escape key handler.
import { onEscapeKey } from '@inertiaui/vanilla'
const cleanup = onEscapeKey((event) => {
console.log('Escape pressed!')
})
// Later, remove the handler
cleanup()| Option | Type | Default | Description |
|---|---|---|---|
preventDefault |
boolean |
false |
Call event.preventDefault() |
stopPropagation |
boolean |
false |
Call event.stopPropagation() |
const cleanup = onEscapeKey(handleEscape, {
preventDefault: true,
stopPropagation: true,
})The cleanup function pattern integrates well with framework lifecycle hooks:
// Vue (<script setup>)
const cleanup = onEscapeKey(closeDialog)
onUnmounted(() => cleanup())
// React
useEffect(() => {
return onEscapeKey(closeDialog)
}, [])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.
import { onClickOutside } from '@inertiaui/vanilla'
const cleanup = onClickOutside(dropdownElement, (event) => {
closeDropdown()
})
// Later, remove the listener
cleanup()Pass an array of elements to ignore clicks inside any of them:
const cleanup = onClickOutside([triggerButton, dropdownPanel], () => {
closeDropdown()
})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>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 })
})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.
import { createMenuNavigation } from '@inertiaui/vanilla'
const cleanup = createMenuNavigation(menuElement)
// Later, remove navigation
cleanup()| 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 |
| 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 |
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 accordinglyFor horizontal menus like toolbars, use orientation: 'horizontal':
const cleanup = createMenuNavigation(toolbar, {
orientation: 'horizontal',
})
// ArrowRight/ArrowLeft navigate instead of ArrowDown/ArrowUpUse a custom selector for non-standard menu structures:
const cleanup = createMenuNavigation(container, {
itemSelector: '.menu-item:not(.disabled)',
})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
}
}Utilities for positioning floating elements (dropdowns, tooltips, popovers) relative to a reference element. Uses CSS Anchor Positioning when supported, with an automatic JavaScript fallback.
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.
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).
| 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 |
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.
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 flippedDisable flipping to keep the element on the specified side:
const result = computePosition(reference, floating, {
placement: 'bottom-start',
flip: false,
})The floating element is always clamped to stay within the viewport with a 4px margin, even after flipping.
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
resizeevents - Window
scrollevents (including nested scrollable containers) - Size changes on both the reference and floating elements (via
ResizeObserver)
Updates are batched using requestAnimationFrame to avoid layout thrashing.
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.
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 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)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 valueThe 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)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
}The animation module provides a simple wrapper around the Web Animations API with Tailwind CSS-compatible easing functions.
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 }
])| 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' })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)' })Cancel any running animations on an element:
import { cancelAnimations } from '@inertiaui/vanilla'
cancelAnimations(element)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
}The prefersDarkMode function detects whether the user prefers dark mode, with support for multiple detection strategies.
import { prefersDarkMode } from '@inertiaui/vanilla'
const isDark = prefersDarkMode()| 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')Utilities for detecting and observing right-to-left document direction.
Check whether the document is currently in RTL direction:
import { isRtl } from '@inertiaui/vanilla'
if (isRtl()) {
// Document is right-to-left
}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 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)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`)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'generateId('modal_')
// 'modal_550e8400-e29b-41d4-a716-446655440000'
generateId('dialog-')
// 'dialog-550e8400-e29b-41d4-a716-446655440000'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'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 = descIdA 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) // falseInvokes 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()
})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']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 }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:
rejectNullValuesonly removesnullvalues, notundefined. Use this when you want to keepundefinedvalues but remove explicit nulls.
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'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') // falseSupported 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') // trueUse 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 }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') // falseAccepts 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) // trueHandles null/undefined:
sameUrlPath(null, '/users') // false
sameUrlPath('/users', undefined) // false
sameUrlPath(null, null) // falseUse Case:
Useful for determining active navigation states or comparing the current route with a link destination:
const isActive = sameUrlPath(window.location.href, linkHref)Start Vite to browse the interactive test pages:
npx vite --port 3333Then open http://localhost:3333 for an overview linking to all test pages.
The test suite uses Playwright with Chromium. It automatically starts a Vite dev server:
npm run test:e2eRun a single spec:
npx playwright test e2e/menu.spec.tsRun tests matching a name:
npx playwright test -g "ArrowDown"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.
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'MIT
