edbnedbn/ui
Get Started
WelcomeChangelogLicenseOverview
Components
ButtonAlert DialogPopoverDropdown Menu
Motion
Motion ProviderSpring PresetsHooks
ComponentsBlocks
HomeDocsUtilitiesHooks

On this page

Accessibility Hooks

A collection of React hooks for accessibility, focus management, and motion preferences. Used internally by all edbn/ui components.

Installation

All hooks live in the hooks/ directory. Install them using the CLI or copy the source code manually.

Install hooks directly into your project using the shadcn CLI. Pick the ones you need.

Install all hooks
npx shadcn@latest add https://ui.edbn.me/r/use-reduced-motion.json
npx shadcn@latest add https://ui.edbn.me/r/use-prevent-scroll.json
npx shadcn@latest add https://ui.edbn.me/r/use-focus-trap.json
npx shadcn@latest add https://ui.edbn.me/r/use-low-power-device.json
npx shadcn@latest add https://ui.edbn.me/r/use-click-outside.json
npx shadcn@latest add https://ui.edbn.me/r/use-stable-id.json
npx shadcn@latest add https://ui.edbn.me/r/use-controllable-state.json
npx shadcn@latest add https://ui.edbn.me/r/use-merged-refs.json
npx shadcn@latest add https://ui.edbn.me/r/use-mobile.json

useReducedMotion

Detects the user's prefers-reduced-motion preference with support for manual overrides.

TypeScript
import { useReducedMotion } from "@/hooks/useReducedMotion"

function AnimatedComponent() {
  const { prefersReducedMotion, setOverride, clearOverride } = useReducedMotion()
  
  if (prefersReducedMotion) {
    return <div>Static content</div>
  }
  
  return (
    <motion.div animate={{ opacity: 1 }}>
      Animated content
    </motion.div>
  )
}

// Override user preference temporarily
function SettingsPanel() {
  const { setOverride, clearOverride } = useReducedMotion()
  
  return (
    <div>
      <button onClick={() => setOverride(true)}>Disable animations</button>
      <button onClick={() => setOverride(false)}>Enable animations</button>
      <button onClick={clearOverride}>Use system preference</button>
    </div>
  )
}
28 lines

usePreventScroll

Locks body scroll when modals or sheets are open. Supports multiple simultaneous instances.

TypeScript
import { usePreventScroll } from "@/hooks/usePreventScroll"

function Modal({ isOpen, children }) {
  // Automatically locks scroll when isOpen is true
  usePreventScroll(isOpen)
  
  if (!isOpen) return null
  
  return (
    <div className="fixed inset-0 bg-black/50">
      <div className="modal-content">
        {children}
      </div>
    </div>
  )
}

// Multiple modals work correctly
function App() {
  const [modalA, setModalA] = useState(false)
  const [modalB, setModalB] = useState(false)
  
  // Scroll is locked if ANY modal is open
  // Scroll is restored only when ALL modals close
  return (
    <>
      <Modal isOpen={modalA}>Modal A</Modal>
      <Modal isOpen={modalB}>Modal B</Modal>
    </>
  )
}
31 lines

useFocusTrap

Traps keyboard focus within a container. Essential for accessible modal dialogs.

TypeScript
import { useFocusTrap } from "@/hooks/useFocusTrap"
import { useRef } from "react"

function Dialog({ isOpen, onClose, children }) {
  const containerRef = useRef<HTMLDivElement>(null)
  
  useFocusTrap(containerRef, {
    enabled: isOpen,
    autoFocus: true,     // Focus first element on open
    restoreFocus: true,  // Return focus on close
    onEscape: onClose,   // Close on Escape key
  })
  
  if (!isOpen) return null
  
  return (
    <div ref={containerRef} role="dialog" aria-modal="true">
      <h2>Dialog Title</h2>
      <p>Dialog content with focusable elements...</p>
      <button onClick={onClose}>Close</button>
    </div>
  )
}
23 lines

useLowPowerDevice

Detects low-power devices using Battery API and hardware detection.

TypeScript
import { useLowPowerDevice } from "@/hooks/useLowPowerDevice"

function OptimizedComponent() {
  const { 
    isLowPower, 
    shouldReduceAnimations,
    batteryLevel,
    isCharging,
    hardwareConcurrency
  } = useLowPowerDevice()
  
  return (
    <div>
      <p>Low power: {isLowPower ? "Yes" : "No"}</p>
      <p>Should reduce: {shouldReduceAnimations ? "Yes" : "No"}</p>
      <p>Battery: {batteryLevel !== null ? `${batteryLevel * 100}%` : "N/A"}</p>
      <p>Charging: {isCharging ? "Yes" : "No"}</p>
      <p>CPU Cores: {hardwareConcurrency}</p>
    </div>
  )
}

// Adaptive animations
function AnimatedCard() {
  const { shouldReduceAnimations } = useLowPowerDevice()
  
  return (
    <motion.div
      animate={{ scale: 1 }}
      transition={{
        // Use simpler animation on low-power devices
        type: shouldReduceAnimations ? "tween" : "spring",
        duration: shouldReduceAnimations ? 0.2 : undefined,
      }}
    >
      Card content
    </motion.div>
  )
}
39 lines

Dependencies

These hooks have minimal dependencies - most only require React. The useLowPowerDevice hook may require browser APIs like the Battery API.

useClickOutside

Detects clicks outside a referenced element. Useful for closing dropdowns and popovers.

TypeScript
import useClickOutside from "@/hooks/useClickOutside"
import { useRef } from "react"

function Dropdown({ isOpen, onClose, children }) {
  const ref = useRef<HTMLDivElement>(null)
  
  useClickOutside(ref, () => {
    if (isOpen) onClose()
  })
  
  if (!isOpen) return null
  
  return (
    <div ref={ref} className="dropdown">
      {children}
    </div>
  )
}

useStableId

Generates SSR-safe unique IDs for accessibility attributes like aria-labelledby and aria-describedby. Uses React 18's useId when available.

TypeScript
import { useStableId, createIdGenerator } from "@/hooks/useStableId"

function Dialog() {
  const titleId = useStableId("dialog-title")
  const descId = useStableId("dialog-desc")
  
  return (
    <div 
      role="dialog" 
      aria-labelledby={titleId} 
      aria-describedby={descId}
    >
      <h2 id={titleId}>Dialog Title</h2>
      <p id={descId}>Dialog description text.</p>
    </div>
  )
}

// For multiple related IDs
function Form() {
  const generateId = createIdGenerator("form")
  const emailId = generateId("email")
  const passwordId = generateId("password")
  
  return (
    <form>
      <label htmlFor={emailId}>Email</label>
      <input id={emailId} type="email" />
      <label htmlFor={passwordId}>Password</label>
      <input id={passwordId} type="password" />
    </form>
  )
}
33 lines

useControllableState

Enables components to work in both controlled and uncontrolled modes. Includes a specialized useControllableBoolean for open/close states.

TypeScript
import { useControllableState, useControllableBoolean } from "@/hooks/useControllableState"

// Generic controllable state
function Input({ value, defaultValue, onChange }) {
  const [internalValue, setInternalValue] = useControllableState({
    value,
    defaultValue,
    onChange,
  })
  
  return (
    <input 
      value={internalValue} 
      onChange={(e) => setInternalValue(e.target.value)} 
    />
  )
}

// Boolean state for dialogs/popovers
function Dialog({ open, defaultOpen, onOpenChange }) {
  const { isOpen, open: openDialog, close, toggle } = useControllableBoolean({
    value: open,
    defaultValue: defaultOpen,
    onChange: onOpenChange,
  })
  
  return (
    <>
      <button onClick={toggle}>Toggle Dialog</button>
      {isOpen && <div role="dialog">Dialog content</div>}
    </>
  )
}
33 lines

useMergedRefs

Merges multiple refs into a single callback ref. Essential for components that need to forward refs while also using them internally.

TypeScript
import { useMergedRefs, mergeRefs } from "@/hooks/useMergedRefs"
import { forwardRef, useRef } from "react"

const FancyInput = forwardRef<HTMLInputElement, Props>((props, forwardedRef) => {
  const localRef = useRef<HTMLInputElement>(null)
  const mergedRef = useMergedRefs(forwardedRef, localRef)
  
  // Both refs now point to the same element
  const handleClick = () => {
    localRef.current?.focus()
  }
  
  return <input ref={mergedRef} {...props} />
})

// Non-hook version for class components or callbacks
function Component() {
  const ref1 = useRef(null)
  const ref2 = useRef(null)
  
  return <div ref={mergeRefs(ref1, ref2)} />
}
22 lines

SSR Safety

All hooks are SSR-safe and return sensible defaults during server rendering. They use useSyncExternalStore for hydration safety.