@pyreon/hooks provides a comprehensive set of reactive hooks built on Pyreon's signal-based reactivity system. Each hook returns reactive signals that automatically update your UI when values change. All hooks that attach DOM listeners use onMount/onUnmount for proper lifecycle management, so they must be called inside a Pyreon component.
Installation
npm install @pyreon/hooksbun add @pyreon/hookspnpm add @pyreon/hooksyarn add @pyreon/hooksuseToggle
A simple boolean toggle with convenience methods. Useful for disclosure patterns, visibility toggles, and binary UI state.
Signature
function useToggle(initial?: boolean): UseToggleResultParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
initial | boolean | false | Initial toggle state |
Returns: UseToggleResult
| Property | Type | Description |
|---|---|---|
value | () => boolean | Reactive boolean getter |
toggle | () => void | Flip the current value |
setTrue | () => void | Set to true |
setFalse | () => void | Set to false |
Example
import { useToggle } from '@pyreon/hooks'
const { value, toggle, setTrue, setFalse } = useToggle(false)
value() // false
toggle()
value() // true
setFalse()
value() // false
setTrue()
value() // trueDisclosure Pattern
import { defineComponent } from '@pyreon/core'
import { useToggle } from '@pyreon/hooks'
const Accordion = defineComponent<{ title: string }>((props) => {
const { value: isOpen, toggle } = useToggle(false)
return () => (
<div class="accordion">
<button onClick={toggle} aria-expanded={isOpen()}>
{props.title}
<span class={isOpen() ? 'arrow-up' : 'arrow-down'} />
</button>
{isOpen() && (
<div class="accordion-content" role="region">
{props.children}
</div>
)}
</div>
)
})Modal Visibility
const ModalTrigger = defineComponent(() => {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle()
return () => (
<div>
<button onClick={open}>Open Modal</button>
{isOpen() && <Modal onClose={close} />}
</div>
)
})useCounter
The numeric companion to useToggle. Returns a reactive count signal plus inc / dec / set / reset helpers; when min / max are given, every write (including the initial value) is clamped into range.
Signature
function useCounter(
initial?: number,
options?: { min?: number; max?: number },
): {
count: Signal<number>
inc: (delta?: number) => void
dec: (delta?: number) => void
set: (value: number) => void
reset: () => void
}Example
import { useCounter } from '@pyreon/hooks'
function Quantity() {
const { count, inc, dec } = useCounter(1, { min: 1, max: 99 })
return (
<div>
<button onClick={() => dec()}>−</button>
<span>{count}</span>
<button onClick={() => inc()}>+</button>
</div>
)
}usePrevious
Track the previous value of a reactive getter. Returns undefined on the first read, then returns the prior value each time the source changes.
Signature
function usePrevious<T>(getter: () => T): () => T | undefinedParameters
| Parameter | Type | Description |
|---|---|---|
getter | () => T | A reactive getter (signal or function that reads signals) |
Returns
() => T | undefined -- a reactive getter returning the previous value, or undefined before the first change.
Example
import { usePrevious } from '@pyreon/hooks'
import { signal } from '@pyreon/reactivity'
const count = signal(0)
const prev = usePrevious(count)
prev() // undefined (no previous value yet)
count.set(1)
prev() // 0
count.set(5)
prev() // 1
count.set(5) // same value
prev() // 5 (tracks every call, even if value doesn't change)Animation Direction Example
const Carousel = defineComponent(() => {
const currentSlide = signal(0)
const previousSlide = usePrevious(currentSlide)
const direction = () => {
const prev = previousSlide()
if (prev === undefined) return 'none'
return currentSlide() > prev ? 'forward' : 'backward'
}
return () => (
<div class={`carousel slide-${direction()}`}>
<div class="slide">{slides[currentSlide()]}</div>
<button onClick={() => currentSlide.update((n) => n - 1)}>Prev</button>
<button onClick={() => currentSlide.update((n) => n + 1)}>Next</button>
</div>
)
})Undo Pattern
const Editor = defineComponent(() => {
const text = signal('')
const previousText = usePrevious(text)
const undo = () => {
const prev = previousText()
if (prev !== undefined) {
text.set(prev)
}
}
return () => (
<div>
<textarea value={text()} onInput={(e) => text.set(e.target.value)} />
<button onClick={undo} disabled={previousText() === undefined}>
Undo
</button>
</div>
)
})useDebouncedValue
Return a debounced version of a reactive value. The output signal only updates after the specified delay has elapsed since the last change. The debounce timer is cleaned up on component unmount.
Signature
function useDebouncedValue<T>(getter: () => T, delayMs: number): () => TParameters
| Parameter | Type | Description |
|---|---|---|
getter | () => T | A reactive getter to debounce |
delayMs | number | Debounce delay in milliseconds |
Returns
() => T -- a reactive getter that updates delayMs after the last change to the source.
Example
import { useDebouncedValue } from '@pyreon/hooks'
import { signal } from '@pyreon/reactivity'
const search = signal('')
const debouncedSearch = useDebouncedValue(search, 300)
search.set('h')
search.set('he')
search.set('hel')
search.set('hello')
// debouncedSearch() is still '' at this point
// After 300ms with no more changes, debouncedSearch() becomes 'hello'Search Input Example
const SearchPage = defineComponent(() => {
const query = signal('')
const debouncedQuery = useDebouncedValue(query, 300)
const results = signal<SearchResult[]>([])
// Fetch results when the debounced query changes
effect(async () => {
const q = debouncedQuery()
if (!q) {
results.set([])
return
}
const data = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
results.set(await data.json())
})
return () => (
<div>
<input
type="search"
placeholder="Search..."
value={query()}
onInput={(e) => query.set(e.target.value)}
/>
{query() !== debouncedQuery() && <span class="spinner" />}
<ul>
{results().map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
)
})Auto-Save Example
const AutoSaveEditor = defineComponent(() => {
const content = signal('')
const debouncedContent = useDebouncedValue(content, 2000)
effect(async () => {
const text = debouncedContent()
if (text) {
await fetch('/api/drafts', {
method: 'PUT',
body: JSON.stringify({ content: text }),
})
}
})
return () => (
<textarea
value={content()}
onInput={(e) => content.set(e.target.value)}
placeholder="Start writing... (auto-saves after 2s)"
/>
)
})useHover
Track hover state reactively. Returns a hovered signal and event handler props to spread onto an element.
Signature
function useHover(): UseHoverResultReturns: UseHoverResult
| Property | Type | Description |
|---|---|---|
hovered | () => boolean | Reactive hover state |
props.onMouseEnter | () => void | Handler to set hovered to true |
props.onMouseLeave | () => void | Handler to set hovered to false |
Example
import { useHover } from '@pyreon/hooks'
import { h } from '@pyreon/core'
const { hovered, props } = useHover()
<div
{...props}
class={hovered() ? 'bg-blue-100' : 'bg-gray-100'}
>Hover me</div>Tooltip Example
const TooltipTrigger = defineComponent<{ text: string }>((props) => {
const { hovered, props: hoverProps } = useHover()
return () => (
<span class="tooltip-trigger" {...hoverProps}>
{props.children}
{hovered() && (
<div class="tooltip" role="tooltip">
{props.text}
</div>
)}
</span>
)
})Interactive Card
const HoverCard = defineComponent(() => {
const { hovered, props: hoverProps } = useHover()
return () => (
<div
class="card"
{...hoverProps}
style={{
transform: hovered() ? 'translateY(-4px)' : 'none',
boxShadow: hovered() ? '0 8px 24px rgba(0,0,0,0.15)' : '0 2px 8px rgba(0,0,0,0.08)',
transition: 'all 0.2s ease',
}}
>
<h3>Hover me</h3>
<p>This card lifts on hover</p>
</div>
)
})useFocus
Track focus state reactively. Returns a focused signal and event handler props (onFocus, onBlur) to spread onto an element.
Signature
function useFocus(): UseFocusResultReturns: UseFocusResult
| Property | Type | Description |
|---|---|---|
focused | () => boolean | Reactive focus state |
props.onFocus | () => void | Handler to set focused to true |
props.onBlur | () => void | Handler to set focused to false |
Example
import { useFocus } from '@pyreon/hooks'
import { h } from '@pyreon/core'
const { focused, props } = useFocus()
<input
{...props}
class={focused() ? 'ring-2 ring-blue-500' : 'ring-1 ring-gray-300'}
/>Focus Ring with Label
const FloatingLabelInput = defineComponent<{ label: string }>((props) => {
const { focused, props: focusProps } = useFocus()
return () => (
<div class={`input-wrapper ${focused() ? 'focused' : ''}`}>
<label class={focused() ? 'label-float' : 'label-default'}>{props.label}</label>
<input {...focusProps} />
</div>
)
})useClickOutside
Call a handler function when a click (or touch) occurs outside the referenced element. Listens on both mousedown and touchstart in the capture phase for reliable detection.
Signature
function useClickOutside(getEl: () => HTMLElement | null, handler: () => void): voidParameters
| Parameter | Type | Description |
|---|---|---|
getEl | () => HTMLElement | null | Getter returning the target element |
handler | () => void | Called when a click occurs outside the element |
Example
import { useClickOutside } from '@pyreon/hooks'
let dropdownEl: HTMLElement | null = null
useClickOutside(
() => dropdownEl,
() => {
/* close the dropdown */
},
)Dropdown Menu Example
const DropdownMenu = defineComponent(() => {
const { value: isOpen, toggle, setFalse: close } = useToggle()
let menuEl: HTMLElement | null = null
useClickOutside(() => menuEl, close)
return () => (
<div ref={(el) => (menuEl = el)} class="dropdown">
<button onClick={toggle}>Menu {isOpen() ? '▲' : '▼'}</button>
{isOpen() && (
<ul class="dropdown-menu">
<li>
<a href="/profile">Profile</a>
</li>
<li>
<a href="/settings">Settings</a>
</li>
<li>
<button
onClick={() => {
logout()
close()
}}
>
Logout
</button>
</li>
</ul>
)}
</div>
)
})Popover Example
const Popover = defineComponent<{ content: string }>((props) => {
const { value: isOpen, toggle, setFalse: close } = useToggle()
let popoverEl: HTMLElement | null = null
useClickOutside(() => popoverEl, close)
// Also close on Escape
useKeyboard('Escape', close)
return () => (
<div ref={(el) => (popoverEl = el)} class="popover-wrapper">
<button onClick={toggle}>Info</button>
{isOpen() && (
<div class="popover-content" role="dialog">
{props.content}
</div>
)}
</div>
)
})useKeyboard
Listen for a specific key press and call the handler when it fires. The listener is attached on mount and removed on unmount.
Signature
function useKeyboard(
key: string,
handler: (event: KeyboardEvent) => void,
options?: { event?: 'keydown' | 'keyup'; target?: EventTarget },
): voidParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
key | string | (required) | The KeyboardEvent.key value to match (e.g., "Escape", "Enter", "ArrowDown") |
handler | (event: KeyboardEvent) => void | (required) | Called when the key matches |
options.event | 'keydown' | 'keyup' | 'keydown' | Which keyboard event to listen for |
options.target | EventTarget | document | The target to attach the listener to |
Example
import { useKeyboard } from '@pyreon/hooks'
// Close modal on Escape
useKeyboard('Escape', () => {
closeModal()
})
// Submit on Enter with keyup
useKeyboard(
'Enter',
(e) => {
e.preventDefault()
submitForm()
},
{ event: 'keyup' },
)Keyboard Shortcut Example
const CommandPalette = defineComponent(() => {
const { value: isOpen, toggle, setFalse: close } = useToggle()
// Ctrl+K / Cmd+K to toggle
useKeyboard('k', (e) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault()
toggle()
}
})
// Escape to close
useKeyboard('Escape', close)
return () => (
<div>
{isOpen() && (
<div class="command-palette">
<input placeholder="Type a command..." />
<div class="results">{/* ... */}</div>
</div>
)}
</div>
)
})Arrow Key Navigation
const ListNavigator = defineComponent(() => {
const items = ['Home', 'Products', 'About', 'Contact']
const activeIndex = signal(0)
useKeyboard('ArrowDown', (e) => {
e.preventDefault()
activeIndex.update((i) => Math.min(i + 1, items.length - 1))
})
useKeyboard('ArrowUp', (e) => {
e.preventDefault()
activeIndex.update((i) => Math.max(i - 1, 0))
})
useKeyboard('Enter', () => {
navigate(items[activeIndex()])
})
return () => (
<ul role="listbox">
{items.map((item, i) => (
<li
role="option"
aria-selected={activeIndex() === i}
class={activeIndex() === i ? 'active' : ''}
>
{item}
</li>
))}
</ul>
)
})useFocusTrap
Trap Tab and Shift+Tab navigation within a container element. When the user tabs past the last focusable element, focus wraps to the first, and vice versa. Essential for accessible modals and dialogs. The trap only acts while focus is actually inside its container, so nested traps do not fight each other.
The focusable query is spec-grade: it includes a[href], button, input, select, textarea, audio[controls], video[controls], [contenteditable], details > summary, and any [tabindex]; it filters out display:none / visibility:hidden / [hidden] / inert / disabled / zero-size nodes (via Element.checkVisibility in real browsers); and it orders positive-tabindex elements first (ascending) before the natural / tabindex="0" group.
Signature
function useFocusTrap(
getEl: () => HTMLElement | null,
options?: UseFocusTrapOptions | boolean | (() => boolean),
): void
interface UseFocusTrapOptions {
/** Arm the trap reactively — a getter arms/disarms it without unmounting. Default `true`. */
active?: boolean | (() => boolean)
/** Move focus into the container on activation. Default `false` (no move). */
initialFocus?: boolean | string | HTMLElement | (() => HTMLElement | null)
}Parameters
| Parameter | Type | Description |
|---|---|---|
getEl | () => HTMLElement | null | Getter returning the container element. Read live on every Tab, so the trap is inert while it returns null. |
options | UseFocusTrapOptions | boolean | (() => boolean) | Optional. An options object, or a plain active boolean / getter shorthand. |
Example
import { signal } from '@pyreon/reactivity'
import { useFocusTrap } from '@pyreon/hooks'
const modalRef = signal<HTMLElement | null>(null)
// Single-arg (unchanged): inert while the ref is null, no focus move.
useFocusTrap(() => modalRef())
// Arm reactively + move focus to the first field on open.
useFocusTrap(() => modalRef(), {
active: () => isOpen(),
initialFocus: true, // or a selector like '[name=email]', an element, or a getter
})
// Positional shorthand for `active`:
useFocusTrap(() => modalRef(), () => isOpen())Accessible Modal Example
const Modal = defineComponent<{ onClose: () => void }>((props) => {
let modalEl: HTMLElement | null = null
useFocusTrap(() => modalEl)
useKeyboard('Escape', props.onClose)
const { lock, unlock } = useScrollLock()
// Lock scroll when modal opens, unlock when it closes
onMount(() => {
lock()
})
onUnmount(unlock)
return () => (
<div class="modal-overlay" onClick={props.onClose}>
<div
ref={(el) => (modalEl = el)}
class="modal-content"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<h2>Modal Title</h2>
<p>Modal content here.</p>
<div class="modal-actions">
<button onClick={props.onClose}>Cancel</button>
<button>Confirm</button>
</div>
</div>
</div>
)
})useElementSize
Observe an element's dimensions reactively via ResizeObserver. Takes an initial measurement from getBoundingClientRect on mount, then tracks changes through the observer.
Signature
function useElementSize(getEl: () => HTMLElement | null): () => SizeParameters
| Parameter | Type | Description |
|---|---|---|
getEl | () => HTMLElement | null | Getter returning the element to observe |
Returns
() => Size where Size is { width: number; height: number }. Returns { width: 0, height: 0 } before mount or if the element is null.
Example
import { useElementSize } from '@pyreon/hooks'
let containerEl: HTMLElement | null = null
const size = useElementSize(() => containerEl)
// In a reactive context:
size().width // current width in pixels
size().height // current height in pixelsResponsive Container Example
const ResponsiveGrid = defineComponent(() => {
let containerEl: HTMLElement | null = null
const size = useElementSize(() => containerEl)
const columns = () => {
const w = size().width
if (w >= 1200) return 4
if (w >= 800) return 3
if (w >= 500) return 2
return 1
}
return () => (
<div ref={(el) => (containerEl = el)} class="grid-container">
<div
class="grid"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns()}, 1fr)`,
gap: '16px',
}}
>
{items.map((item) => (
<div class="grid-item">{item.name}</div>
))}
</div>
<p class="debug">
Container: {size().width}x{size().height}px ({columns()} columns)
</p>
</div>
)
})Aspect Ratio Box
const AspectRatioImage = defineComponent<{ ratio: number }>((props) => {
let wrapperEl: HTMLElement | null = null
const size = useElementSize(() => wrapperEl)
return () => (
<div ref={(el) => (wrapperEl = el)} class="aspect-wrapper">
<img
src="/image.jpg"
style={{
width: `${size().width}px`,
height: `${size().width / props.ratio}px`,
objectFit: 'cover',
}}
/>
</div>
)
})useWindowResize
Track window dimensions reactively with built-in throttling to avoid excessive updates during resize. Uses setTimeout-based throttling.
Signature
function useWindowResize(throttleMs?: number): () => WindowSizeParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
throttleMs | number | 200 | Throttle interval in milliseconds |
Returns
() => WindowSize where WindowSize is { width: number; height: number }. Initializes with the current window dimensions (or { width: 0, height: 0 } on the server).
Example
import { useWindowResize } from '@pyreon/hooks'
const windowSize = useWindowResize(200)
windowSize().width // window.innerWidth
windowSize().height // window.innerHeightResponsive Layout Example
const ResponsiveLayout = defineComponent(() => {
const windowSize = useWindowResize(150)
const layout = () => {
if (windowSize().width >= 1024) return 'desktop'
if (windowSize().width >= 768) return 'tablet'
return 'mobile'
}
return () => (
<div class={`layout layout-${layout()}`}>
{layout() === 'desktop' && <Sidebar />}
<main>
<p>
Window: {windowSize().width} x {windowSize().height}
</p>
{props.children}
</main>
</div>
)
})useMediaQuery
Subscribe to a CSS media query and return a reactive boolean that updates when the match state changes. Uses window.matchMedia and the change event.
Signature
function useMediaQuery(query: string): () => booleanParameters
| Parameter | Type | Description |
|---|---|---|
query | string | A CSS media query string (e.g., "(min-width: 768px)") |
Returns
() => boolean -- reactive getter that reflects the current match state.
Example
import { useMediaQuery } from '@pyreon/hooks'
const isWide = useMediaQuery('(min-width: 1024px)')
isWide() // true or false
const isPortrait = useMediaQuery('(orientation: portrait)')
const supportsHover = useMediaQuery('(hover: hover)')
const prefersContrast = useMediaQuery('(prefers-contrast: high)')Responsive Logic Example
const Navigation = defineComponent(() => {
const isMobile = useMediaQuery('(max-width: 767px)')
return () => {
if (isMobile()) {
return <MobileNav />
}
return <DesktopNav />
}
})Responsive Image Source
const ResponsiveImage = defineComponent<{ alt: string }>((props) => {
const isRetina = useMediaQuery('(min-resolution: 2dppx)')
const isWide = useMediaQuery('(min-width: 1024px)')
const src = () => {
const size = isWide() ? 'large' : 'small'
const density = isRetina() ? '@2x' : ''
return `/images/hero-${size}${density}.webp`
}
return () => <img src={src()} alt={props.alt} />
})useBreakpoint
Return the currently active breakpoint name as a reactive signal. Updates on window resize using requestAnimationFrame for smooth performance. The breakpoint is determined by comparing window.innerWidth against the sorted breakpoint thresholds.
Signature
function useBreakpoint(breakpoints?: BreakpointMap): () => stringParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
breakpoints | BreakpointMap | { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200 } | Map of breakpoint names to minimum widths in pixels |
Returns
() => string -- reactive getter returning the name of the currently active breakpoint.
Default Breakpoints
| Name | Min Width |
|---|---|
xs | 0px |
sm | 576px |
md | 768px |
lg | 992px |
xl | 1200px |
xxl | 1400px |
Example
import { useBreakpoint } from '@pyreon/hooks'
const bp = useBreakpoint()
bp() // 'xs' | 'sm' | 'md' | 'lg' | 'xl'Custom Breakpoints
const bp = useBreakpoint({
mobile: 0,
tablet: 768,
desktop: 1024,
wide: 1440,
})
bp() // 'mobile' | 'tablet' | 'desktop' | 'wide'Responsive Component Example
const AdaptiveLayout = defineComponent(() => {
const bp = useBreakpoint()
return () => {
const current = bp()
return (
<div class={`layout-${current}`}>
{(current === 'lg' || current === 'xl') && <Sidebar />}
<main>
<p>Current breakpoint: {current}</p>
{current === 'xs' && <MobileWarning />}
</main>
</div>
)
}
})Grid Column Adjustment
const ProductGrid = defineComponent(() => {
const bp = useBreakpoint()
const columns = () => {
switch (bp()) {
case 'xl':
return 4
case 'lg':
return 3
case 'md':
return 2
default:
return 1
}
}
return () => (
<div style={{ gridTemplateColumns: `repeat(${columns()}, 1fr)` }}>
{products.map((p) => (
<ProductCard product={p} />
))}
</div>
)
})useColorScheme
Return the user's OS color scheme preference as a reactive 'light' or 'dark' signal. Built on top of useMediaQuery('(prefers-color-scheme: dark)').
Signature
function useColorScheme(): () => 'light' | 'dark'Returns
() => 'light' | 'dark' -- reactive getter reflecting the current OS preference.
Example
import { useColorScheme } from '@pyreon/hooks'
const scheme = useColorScheme()
scheme() // 'light' or 'dark'Theme Toggling Example
const ThemeProvider = defineComponent(() => {
const osScheme = useColorScheme()
const manualOverride = signal<'light' | 'dark' | 'auto'>('auto')
const activeTheme = computed(() => {
const override = manualOverride()
if (override !== 'auto') return override
return osScheme()
})
// Sync theme to body class
useHead(() => ({
bodyAttrs: { class: `theme-${activeTheme()}` },
htmlAttrs: { 'data-theme': activeTheme() },
}))
return () => (
<div>
<select
value={manualOverride()}
onChange={(e) => manualOverride.set(e.target.value as 'light' | 'dark' | 'auto')}
>
<option value="auto">System ({osScheme()})</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
{props.children}
</div>
)
})Conditional Styling
const Logo = defineComponent(() => {
const scheme = useColorScheme()
return () => <img src={scheme() === 'dark' ? '/logo-light.svg' : '/logo-dark.svg'} alt="Logo" />
})useReducedMotion
Return true when the user prefers reduced motion. Built on top of useMediaQuery('(prefers-reduced-motion: reduce)'). Use this to respect the user's accessibility preferences by disabling or simplifying animations.
Signature
function useReducedMotion(): () => booleanReturns
() => boolean -- true when the user prefers reduced motion.
Example
import { useReducedMotion } from '@pyreon/hooks'
const prefersReduced = useReducedMotion()
prefersReduced() // true or falseAccessible Animation Example
const AnimatedCard = defineComponent(() => {
const prefersReduced = useReducedMotion()
const { hovered, props: hoverProps } = useHover()
return () => (
<div
{...hoverProps}
style={{
transition: prefersReduced() ? 'none' : 'transform 0.3s ease, box-shadow 0.3s ease',
transform: hovered() && !prefersReduced() ? 'scale(1.05)' : 'scale(1)',
}}
>
Content
</div>
)
})Conditional Animation Library
const FadeIn = defineComponent(() => {
const prefersReduced = useReducedMotion()
return () => (
<div
class="fade-in"
style={{
animationDuration: prefersReduced() ? '0ms' : '500ms',
animationName: prefersReduced() ? 'none' : 'fadeIn',
}}
>
{props.children}
</div>
)
})useScrollLock
Lock page scrolling by setting overflow: hidden on document.body. Uses reference counting so that multiple concurrent locks (e.g., nested modals) work correctly -- scrolling is only restored when all locks are released. Automatically unlocks on component unmount.
Signature
function useScrollLock(): { lock: () => void; unlock: () => void }Returns
| Property | Type | Description |
|---|---|---|
lock | () => void | Lock scrolling (increments lock count) |
unlock | () => void | Unlock scrolling (decrements lock count) |
Example
import { useScrollLock } from '@pyreon/hooks'
const { lock, unlock } = useScrollLock()
lock() // body overflow set to 'hidden'
unlock() // body overflow restoredMultiple calls to lock() from the same hook instance are idempotent -- calling lock() when already locked is a no-op. The same applies to unlock().
Modal Overlay Example
const FullScreenOverlay = defineComponent<{ onClose: () => void }>((props) => {
const { lock, unlock } = useScrollLock()
onMount(() => {
lock()
})
onUnmount(unlock)
return () => (
<div class="overlay" onClick={props.onClose}>
<div class="overlay-content" onClick={(e) => e.stopPropagation()}>
{props.children}
</div>
</div>
)
})Reference Counting
When multiple components lock scrolling simultaneously, the scroll is only restored when all locks are released:
// Modal A locks scroll
const modalA = useScrollLock()
modalA.lock()
// Modal B (nested dialog) also locks scroll
const modalB = useScrollLock()
modalB.lock()
// Closing Modal B does not restore scroll (Modal A is still locked)
modalB.unlock()
// Closing Modal A restores scroll
modalA.unlock()
// Now body overflow is restored to its original valueuseIntersection
Observe element visibility using IntersectionObserver. Returns a reactive signal containing the latest IntersectionObserverEntry, or null before the first observation. The observer is disconnected on unmount.
Signature
function useIntersection(
getEl: () => HTMLElement | null,
options?: IntersectionObserverInit,
): () => IntersectionObserverEntry | nullParameters
| Parameter | Type | Description |
|---|---|---|
getEl | () => HTMLElement | null | Getter returning the element to observe |
options | IntersectionObserverInit | Standard IntersectionObserver options (root, rootMargin, threshold) |
Returns
() => IntersectionObserverEntry | null -- reactive getter returning the latest observation entry.
Example
import { useIntersection } from '@pyreon/hooks'
let sectionEl: HTMLElement | null = null
const entry = useIntersection(() => sectionEl, {
threshold: 0.5,
})
// Check visibility reactively
const isVisible = () => entry()?.isIntersecting ?? false
const ratio = () => entry()?.intersectionRatio ?? 0Lazy Loading Example
const LazyImage = defineComponent<{ src: string; alt: string }>((props) => {
let imgEl: HTMLElement | null = null
const entry = useIntersection(() => imgEl, { rootMargin: '200px' })
const loaded = signal(false)
const shouldLoad = () => loaded() || (entry()?.isIntersecting ?? false)
effect(() => {
if (shouldLoad()) loaded.set(true)
})
return () => (
<div ref={(el) => (imgEl = el)} class="lazy-image-wrapper">
{loaded() ? <img src={props.src} alt={props.alt} /> : <div class="placeholder" />}
</div>
)
})Infinite Scroll Example
const InfiniteList = defineComponent(() => {
const items = signal<Item[]>([])
const page = signal(1)
const isLoading = signal(false)
let sentinelEl: HTMLElement | null = null
const entry = useIntersection(() => sentinelEl, { threshold: 0 })
effect(async () => {
if (entry()?.isIntersecting && !isLoading()) {
isLoading.set(true)
const newItems = await fetchItems(page())
items.update((prev) => [...prev, ...newItems])
page.update((p) => p + 1)
isLoading.set(false)
}
})
return () => (
<div>
{items().map((item) => (
<ItemCard item={item} key={item.id} />
))}
<div ref={(el) => (sentinelEl = el)} class="scroll-sentinel">
{isLoading() && <Spinner />}
</div>
</div>
)
})Scroll-Triggered Animations
const AnimateOnScroll = defineComponent(() => {
let sectionEl: HTMLElement | null = null
const entry = useIntersection(() => sectionEl, { threshold: 0.3 })
const hasAppeared = signal(false)
effect(() => {
if (entry()?.isIntersecting) hasAppeared.set(true)
})
return () => (
<section ref={(el) => (sectionEl = el)} class={hasAppeared() ? 'animate-in' : 'animate-hidden'}>
{props.children}
</section>
)
})Combining Multiple Hooks
Hooks compose naturally. Here are patterns that combine several hooks together.
Accessible Modal (Full Example)
const AccessibleModal = defineComponent<{
title: string
onClose: () => void
}>((props) => {
let modalEl: HTMLElement | null = null
// Focus trap -- keep Tab within the modal
useFocusTrap(() => modalEl)
// Close on Escape
useKeyboard('Escape', props.onClose)
// Close on click outside
useClickOutside(() => modalEl, props.onClose)
// Lock page scroll
const { lock, unlock } = useScrollLock()
onMount(() => {
lock()
})
onUnmount(unlock)
// Respect reduced motion
const prefersReduced = useReducedMotion()
return () => (
<div
class="modal-backdrop"
style={{ animation: prefersReduced() ? 'none' : 'fadeIn 0.2s ease' }}
>
<div
ref={(el) => (modalEl = el)}
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
style={{
animation: prefersReduced() ? 'none' : 'slideUp 0.3s ease',
}}
>
<h2 id="modal-title">{props.title}</h2>
<div class="modal-body">{props.children}</div>
<button onClick={props.onClose}>Close</button>
</div>
</div>
)
})Responsive Dashboard
const Dashboard = defineComponent(() => {
const bp = useBreakpoint()
const scheme = useColorScheme()
const windowSize = useWindowResize()
const prefersReduced = useReducedMotion()
return () => {
const isMobile = bp() === 'xs' || bp() === 'sm'
return (
<div class={`dashboard theme-${scheme()}`}>
{!isMobile && <Sidebar />}
<main>
<p>
{windowSize().width}x{windowSize().height} |{bp()} | {scheme()} |
{prefersReduced() ? 'reduced motion' : 'full motion'}
</p>
{isMobile && <MobileNav />}
{props.children}
</main>
</div>
)
}
})Smart Tooltip with Debounce
const SmartTooltip = defineComponent<{ text: string }>((props) => {
const { hovered, props: hoverProps } = useHover()
// Only show tooltip after hovering for 500ms
const debouncedHover = useDebouncedValue(hovered, 500)
return () => (
<span class="tooltip-trigger" {...hoverProps}>
{props.children}
{debouncedHover() && <div class="tooltip">{props.text}</div>}
</span>
)
})API Reference
| Hook | Signature | Description |
|---|---|---|
useToggle | (initial?) => UseToggleResult | Boolean toggle with toggle, setTrue, setFalse |
useCounter | (initial?, opts?) => UseCounterResult | Numeric counter with inc/dec/set/reset, optional min/max |
usePrevious | (getter) => () => T | undefined | Track the previous value of a reactive getter |
useDebouncedValue | (getter, delayMs) => () => T | Debounce a reactive value |
useHover | () => UseHoverResult | Track hover state with spreadable props |
useFocus | () => UseFocusResult | Track focus/blur state with spreadable props |
useClickOutside | (getEl, handler) => void | Call handler on clicks outside an element |
useKeyboard | (key, handler, options?) => void | Listen for specific key presses |
useFocusTrap | (getEl, options?) => void | Trap Tab focus within a container; optional reactive active + initialFocus |
useElementSize | (getEl) => () => Size | Observe element dimensions via ResizeObserver |
useWindowResize | (debounceMs?) => () => WindowSize | Track window size with debouncing |
useWindowScroll | () => UseWindowScrollResult | Reactive { x, y } scroll offset + scrollTo |
useMediaQuery | (query) => () => boolean | Subscribe to a CSS media query |
useBreakpoint | (breakpoints?) => () => string | Get the active breakpoint name |
useColorScheme | () => () => 'light' | 'dark' | Detect light/dark mode preference |
useReducedMotion | () => () => boolean | Detect reduced-motion preference |
useScrollLock | () => { lock, unlock } | Lock/unlock page scrolling |
useIntersection | (getEl, options?) => () => IntersectionObserverEntry | null | Observe element intersection |
useDocumentVisibility | () => () => 'visible' | 'hidden' | Track tab visibility (Page Visibility API) |
useIdle | (timeoutMs?, opts?) => () => boolean | User-idle detection after timeoutMs of no activity |
Type Exports
| Type | Description |
|---|---|
UseToggleResult | { value: () => boolean; toggle: () => void; setTrue: () => void; setFalse: () => void } |
UseHoverResult | { hovered: () => boolean; props: { onMouseEnter, onMouseLeave } } |
UseFocusResult | { focused: () => boolean; props: { onFocus, onBlur } } |
Size | { width: number; height: number } |
WindowSize | { width: number; height: number } |
BreakpointMap | Record<string, number> |