@versini/ui-hooks
v6.2.3
Published
Readme
@versini/ui-hooks
A collection of useful React hooks built with TypeScript for UI component development.
This package provides reusable React hooks that are commonly needed when building UI components, including unique ID generation, click outside detection, keyboard shortcuts, local storage management, viewport tracking, and many other utilities.
Table of Contents
Features
- 🎯 UI-Focused: Hooks specifically designed for UI component development
- ♿ Accessibility: Hooks that enhance accessibility (unique IDs, focus management)
- 🔧 TypeScript: Fully typed with comprehensive type definitions
- 🌲 Tree-shakeable: Import only the hooks you need
- ⚡ Performance: Optimized hooks with minimal overhead
- 📱 Responsive: Hooks for viewport and visual viewport tracking
- ⌨️ Keyboard: Hotkey and keyboard shortcut management
- 💾 Storage: Local storage integration with React state
Installation
npm install @versini/ui-hooksNote: While this package contains React hooks without styling, when used alongside the UI component packages it assumes TailwindCSS and the
@versini/ui-stylesplugin are configured. See the installation documentation for complete setup instructions.
Usage
Each hook is exported from its own subpath for optimal tree-shaking:
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
import { useClickOutside } from "@versini/ui-hooks/use-click-outside";
import { useHotkeys, getHotkeyHandler } from "@versini/ui-hooks/use-hotkeys";
import { useHaptic } from "@versini/ui-hooks/use-haptic";
import { useLocalStorage } from "@versini/ui-hooks/use-local-storage";
import { useViewportSize } from "@versini/ui-hooks/use-viewport-size";
import { useVisualViewportSize } from "@versini/ui-hooks/use-visual-viewport-size";
import { useInViewport } from "@versini/ui-hooks/use-in-viewport";
import { useResizeObserver } from "@versini/ui-hooks/use-resize-observer";
import { useInterval } from "@versini/ui-hooks/use-interval";
import { useIsMounted } from "@versini/ui-hooks/use-is-mounted";
import { useMergeRefs } from "@versini/ui-hooks/use-merge-refs";
import { useUncontrolled } from "@versini/ui-hooks/use-uncontrolled";Available Hooks
Core Utility Hooks
useUniqueId- Generate unique IDs for accessibilityuseIsMounted- Check if component is mounteduseMergeRefs- Merge multiple React refsuseUncontrolled- Manage controlled/uncontrolled state
Interaction Hooks
useClickOutside- Detect clicks outside an elementuseHotkeys- Handle keyboard shortcuts and hotkeysuseHaptic- Provide haptic feedback for mobile devices
Storage Hooks
useLocalStorage- Sync state with localStorage
Viewport and Size Hooks
useViewportSize- Track browser viewport dimensionsuseVisualViewportSize- Track visual viewport (mobile-friendly)useInViewport- Detect if element is visible in viewportuseResizeObserver- Observe element size changes
Timer Hooks
useInterval- Manage intervals with start/stop controls
API Reference
useUniqueId
Generates a unique ID string for use in components.
const id = useUniqueId(prefix?: string): stringParameters:
prefix(optional): String prefix for the generated ID
Returns: A unique ID string
useClickOutside
Triggers a callback when clicking outside the target element.
const ref = useClickOutside<T>(
handler: () => void,
events?: string[] | null,
nodes?: (HTMLElement | null)[]
): RefObject<T>Parameters:
handler: Function called when clicked outsideevents: Array of events to listen to (default:["mousedown", "touchstart"])nodes: Array of additional nodes to check against
Returns: Ref to attach to the target element
useHotkeys
Handle keyboard shortcuts and hotkeys.
useHotkeys(
hotkeys: HotkeyItem[],
tagsToIgnore?: string[],
triggerOnContentEditable?: boolean
): voidParameters:
hotkeys: Array of[shortcut, handler, options?]tuplestagsToIgnore: HTML tags to ignore (default:["INPUT", "TEXTAREA", "SELECT"])triggerOnContentEditable: Whether to trigger on contentEditable elements
useHaptic
Provide haptic feedback for mobile devices using the Vibration API.
const { haptic } = useHaptic(): { haptic: (count?: number) => void }Parameters:
count(optional): Number of haptic pulses to trigger (default: 1)
Returns: Object with haptic function to trigger feedback
Example:
import { useHaptic } from "@versini/ui-hooks/use-haptic";
function HapticButton() {
const { haptic } = useHaptic();
return (
<button onClick={() => haptic(1)}>Tap me (with haptic feedback)</button>
);
}Notes:
- Uses
navigator.vibrate, and does nothing where that is unavailable - Haptic duration: 50ms per pulse
- Interval between pulses: 120ms
- Multiple pulses create a vibration pattern for better UX
- iOS is not covered by this hook. Safari does not implement the Vibration
API, and the workaround that used to stand in for it — clicking a hidden
<input type="checkbox" switch>— no longer works now that WebKit requires a trusted event on the control itself. To get haptics on iOS, put theswitchattribute on the real control the user taps, the way@versini/ui-toggledoes; a scripted click cannot reach the Taptic engine.
useLocalStorage
Manage state synchronized with localStorage.
const [value, setValue, resetValue, removeValue] = useLocalStorage<T>({
key: string,
initialValue?: T
}): [T, (value: T) => void, () => void, () => void]Parameters:
key: localStorage keyinitialValue: Default value if not found in storage
Returns: Tuple of [value, setValue, resetValue, removeValue]
useViewportSize
Track browser viewport dimensions.
const { width, height } = useViewportSize(): { width: number, height: number }Returns: Object with current viewport width and height
useVisualViewportSize
Track visual viewport dimensions (accounts for mobile keyboards, zoom).
const { width, height } = useVisualViewportSize(): { width: number, height: number }Returns: Object with current visual viewport width and height
useInViewport
Detect if an element is visible in the viewport.
const { ref, inViewport } = useInViewport<T>(): { ref: RefCallback<T>, inViewport: boolean }Returns: Object with ref to attach to element and visibility boolean
useResizeObserver
Observe element size changes using ResizeObserver API.
const [ref, rect] = useResizeObserver<T>(options?: ResizeObserverOptions): [RefObject<T>, ObserverRect]Parameters:
options: ResizeObserver configuration options
Returns: Tuple of [ref, rect] where rect contains dimensions
useInterval
Manage intervals with start/stop controls.
const { start, stop, active } = useInterval(
fn: () => void,
interval: number
): { start: () => void, stop: () => void, active: boolean }Parameters:
fn: Function to execute at each intervalinterval: Interval time in milliseconds
Returns: Object with start/stop functions and active state
useIsMounted
Check if component is currently mounted.
const isMounted = useIsMounted(): () => booleanReturns: Function that returns true if component is mounted
useMergeRefs
Merge multiple React refs into a single ref callback.
const mergedRef = useMergeRefs<T>(refs: Array<React.Ref<T>>): RefCallback<T>Parameters:
refs: Array of refs to merge
Returns: Single ref callback that forwards to all provided refs
useUncontrolled
Manage controlled/uncontrolled component state patterns.
const [value, setValue, isControlled] = useUncontrolled<T>({
value?: T,
defaultValue?: T,
finalValue?: T,
onChange?: (value: T) => void,
initialControlledDelay?: number
}): [T, (value: T) => void, boolean]Parameters:
value: Controlled valuedefaultValue: Initial uncontrolled valuefinalValue: Fallback value when others are undefinedonChange: Change handler for controlled modeinitialControlledDelay: Delay before controlled mode activates
Returns: Tuple of [value, setValue, isControlled]
Comprehensive Examples
Accessible Form Field
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
function FormField({ label, helpText, error, ...props }) {
const fieldId = useUniqueId("field");
const helperId = useUniqueId("helper");
const errorId = useUniqueId("error");
return (
<div>
<label htmlFor={fieldId}>{label}</label>
<input
id={fieldId}
aria-describedby={[helpText && helperId, error && errorId]
.filter(Boolean)
.join(" ")}
aria-invalid={!!error}
{...props}
/>
{helpText && <div id={helperId}>{helpText}</div>}
{error && (
<div id={errorId} role="alert">
{error}
</div>
)}
</div>
);
}Modal with Click Outside and Hotkeys
import { useClickOutside } from "@versini/ui-hooks/use-click-outside";
import { useHotkeys } from "@versini/ui-hooks/use-hotkeys";
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
function Modal({ isOpen, onClose, title, children }) {
const titleId = useUniqueId("modal-title");
const descId = useUniqueId("modal-desc");
const ref = useClickOutside(() => onClose());
useHotkeys([["Escape", onClose]]);
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div
ref={ref}
role="dialog"
aria-labelledby={titleId}
aria-describedby={descId}
className="modal"
>
<h2 id={titleId}>{title}</h2>
<div id={descId}>{children}</div>
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Responsive Component with Viewport Tracking
import { useViewportSize } from "@versini/ui-hooks/use-viewport-size";
import { useVisualViewportSize } from "@versini/ui-hooks/use-visual-viewport-size";
function ResponsiveComponent() {
const viewport = useViewportSize();
const visualViewport = useVisualViewportSize();
const isMobile = viewport.width < 768;
const keyboardVisible = visualViewport.height < viewport.height;
return (
<div>
<p>
Viewport: {viewport.width}x{viewport.height}
</p>
<p>
Visual Viewport: {visualViewport.width}x{visualViewport.height}
</p>
<p>Device: {isMobile ? "Mobile" : "Desktop"}</p>
{keyboardVisible && <p>Virtual keyboard is visible</p>}
</div>
);
}Auto-Save with Local Storage and Intervals
import { useLocalStorage } from "@versini/ui-hooks/use-local-storage";
import { useInterval } from "@versini/ui-hooks/use-interval";
import { useIsMounted } from "@versini/ui-hooks/use-is-mounted";
function AutoSaveEditor() {
const [content, setContent] = useLocalStorage({
key: "editor-content",
initialValue: ""
});
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const isMounted = useIsMounted();
const { start, stop, active } = useInterval(() => {
if (isMounted() && content) {
// Auto-save logic here
setLastSaved(new Date());
}
}, 30000); // Auto-save every 30 seconds
useEffect(() => {
if (content) {
start();
} else {
stop();
}
}, [content, start, stop]);
return (
<div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Start typing..."
/>
<div>
Auto-save: {active ? "Active" : "Inactive"}
{lastSaved && ` - Last saved: ${lastSaved.toLocaleTimeString()}`}
</div>
</div>
);
}Lazy Loading with Intersection Observer
import { useInViewport } from "@versini/ui-hooks/use-in-viewport";
function LazyImage({ src, alt, placeholder }) {
const { ref, inViewport } = useInViewport();
const [loaded, setLoaded] = useState(false);
return (
<div ref={ref}>
{inViewport && !loaded && (
<img src={placeholder} alt={alt} style={{ filter: "blur(5px)" }} />
)}
{inViewport && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ display: loaded ? "block" : "none" }}
/>
)}
</div>
);
}Resizable Panel with Size Tracking
import { useResizeObserver } from "@versini/ui-hooks/use-resize-observer";
function ResizablePanel({ children }) {
const [ref, rect] = useResizeObserver();
return (
<div
ref={ref}
style={{
resize: "both",
overflow: "auto",
border: "1px solid #ccc",
minWidth: 200,
minHeight: 100
}}
>
<div>
Size: {Math.round(rect.width)}x{Math.round(rect.height)}
</div>
{children}
</div>
);
}Haptic Feedback for Interactive UI
import { useHaptic } from "@versini/ui-hooks/use-haptic";
function InteractiveCounter() {
const [count, setCount] = useState(0);
const { haptic } = useHaptic();
const increment = () => {
setCount((c) => c + 1);
haptic(1); // Single pulse
};
const decrement = () => {
setCount((c) => c - 1);
haptic(1); // Single pulse
};
const reset = () => {
setCount(0);
haptic(2); // Double pulse for emphasis
};
const celebrate = () => {
haptic(3); // Triple pulse for celebration
};
return (
<div>
<h2>Count: {count}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
{count >= 10 && <button onClick={celebrate}>🎉 Celebrate!</button>}
</div>
);
}Advanced Controlled/Uncontrolled Input
import { useUncontrolled } from "@versini/ui-hooks/use-uncontrolled";
import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
function AdvancedInput({ value, defaultValue, onChange, label, ...props }) {
const [inputValue, setInputValue, isControlled] = useUncontrolled({
value,
defaultValue,
finalValue: "",
onChange
});
const inputId = useUniqueId("input");
return (
<div>
<label htmlFor={inputId}>
{label} {isControlled ? "(Controlled)" : "(Uncontrolled)"}
</label>
<input
id={inputId}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
{...props}
/>
</div>
);
}License
MIT
