A lightweight vanilla TypeScript library providing UI utilities for dialogs, animations, focus management, 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/vanillaThe 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)
}, [])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
}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 = descIdReturns 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)This library is written in TypeScript and exports the following types:
import type {
CleanupFunction,
FocusTrapOptions,
EscapeKeyOptions,
AnimateOptions,
EasingName,
} from '@inertiaui/vanilla'MIT
