pyreon

@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.

@pyreon/hooksstable

Installation

npm install @pyreon/hooks
bun add @pyreon/hooks
pnpm add @pyreon/hooks
yarn add @pyreon/hooks

useToggle

A simple boolean toggle with convenience methods. Useful for disclosure patterns, visibility toggles, and binary UI state.

Signature

function useToggle(initial?: boolean): UseToggleResult

Parameters

ParameterTypeDefaultDescription
initialbooleanfalseInitial toggle state

Returns: UseToggleResult

PropertyTypeDescription
value() => booleanReactive boolean getter
toggle() => voidFlip the current value
setTrue() => voidSet to true
setFalse() => voidSet 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() // true
useToggle — disclosure pattern

Disclosure 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>
  )
})
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

useCounter — clamped counter
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 | undefined

Parameters

ParameterTypeDescription
getter() => TA 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)
usePrevious — the previous swatch lags one step

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): () => T

Parameters

ParameterTypeDescription
getter() => TA reactive getter to debounce
delayMsnumberDebounce 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(): UseHoverResult

Returns: UseHoverResult

PropertyTypeDescription
hovered() => booleanReactive hover state
props.onMouseEnter() => voidHandler to set hovered to true
props.onMouseLeave() => voidHandler 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>
useHover — hover to color the tiles

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(): UseFocusResult

Returns: UseFocusResult

PropertyTypeDescription
focused() => booleanReactive focus state
props.onFocus() => voidHandler to set focused to true
props.onBlur() => voidHandler 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'}
/>
useFocus — Tab between the tiles to light them

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): void

Parameters

ParameterTypeDescription
getEl() => HTMLElement | nullGetter returning the target element
handler() => voidCalled when a click occurs outside the element

Example

import { useClickOutside } from '@pyreon/hooks'

let dropdownEl: HTMLElement | null = null

useClickOutside(
  () => dropdownEl,
  () => {
    /* close the dropdown */
  },
)
useClickOutside — press outside to dismiss
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 },
): void

Parameters

ParameterTypeDefaultDescription
keystring(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.targetEventTargetdocumentThe 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

ParameterTypeDescription
getEl() => HTMLElement | nullGetter returning the container element. Read live on every Tab, so the trap is inert while it returns null.
optionsUseFocusTrapOptions | 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): () => Size

Parameters

ParameterTypeDescription
getEl() => HTMLElement | nullGetter returning the element to observe

Returns

() => Size where Size is &#123; width: number; height: number &#125;. Returns &#123; width: 0, height: 0 &#125; 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 pixels

Responsive 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): () => WindowSize

Parameters

ParameterTypeDefaultDescription
throttleMsnumber200Throttle interval in milliseconds

Returns

() => WindowSize where WindowSize is &#123; width: number; height: number &#125;. Initializes with the current window dimensions (or &#123; width: 0, height: 0 &#125; on the server).

Example

import { useWindowResize } from '@pyreon/hooks'

const windowSize = useWindowResize(200)

windowSize().width // window.innerWidth
windowSize().height // window.innerHeight

Responsive 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): () => boolean

Parameters

ParameterTypeDescription
querystringA 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): () => string

Parameters

ParameterTypeDefaultDescription
breakpointsBreakpointMap&#123; xs: 0, sm: 576, md: 768, lg: 992, xl: 1200 &#125;Map of breakpoint names to minimum widths in pixels

Returns

() => string -- reactive getter returning the name of the currently active breakpoint.

Default Breakpoints

NameMin Width
xs0px
sm576px
md768px
lg992px
xl1200px
xxl1400px

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(): () => boolean

Returns

() => boolean -- true when the user prefers reduced motion.

Example

import { useReducedMotion } from '@pyreon/hooks'

const prefersReduced = useReducedMotion()
prefersReduced() // true or false

Accessible 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

PropertyTypeDescription
lock() => voidLock scrolling (increments lock count)
unlock() => voidUnlock scrolling (decrements lock count)

Example

import { useScrollLock } from '@pyreon/hooks'

const { lock, unlock } = useScrollLock()

lock() // body overflow set to 'hidden'
unlock() // body overflow restored

Multiple calls to lock() from the same hook instance are idempotent -- calling lock() when already locked is a no-op. The same applies to unlock().

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 value

useIntersection

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 | null

Parameters

ParameterTypeDescription
getEl() => HTMLElement | nullGetter returning the element to observe
optionsIntersectionObserverInitStandard 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 ?? 0

Lazy 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

HookSignatureDescription
useToggle(initial?) => UseToggleResultBoolean toggle with toggle, setTrue, setFalse
useCounter(initial?, opts?) => UseCounterResultNumeric counter with inc/dec/set/reset, optional min/max
usePrevious(getter) => () => T | undefinedTrack the previous value of a reactive getter
useDebouncedValue(getter, delayMs) => () => TDebounce a reactive value
useHover() => UseHoverResultTrack hover state with spreadable props
useFocus() => UseFocusResultTrack focus/blur state with spreadable props
useClickOutside(getEl, handler) => voidCall handler on clicks outside an element
useKeyboard(key, handler, options?) => voidListen for specific key presses
useFocusTrap(getEl, options?) => voidTrap Tab focus within a container; optional reactive active + initialFocus
useElementSize(getEl) => () => SizeObserve element dimensions via ResizeObserver
useWindowResize(debounceMs?) => () => WindowSizeTrack window size with debouncing
useWindowScroll() => UseWindowScrollResultReactive { x, y } scroll offset + scrollTo
useMediaQuery(query) => () => booleanSubscribe to a CSS media query
useBreakpoint(breakpoints?) => () => stringGet the active breakpoint name
useColorScheme() => () => 'light' | 'dark'Detect light/dark mode preference
useReducedMotion() => () => booleanDetect reduced-motion preference
useScrollLock() => &#123; lock, unlock &#125;Lock/unlock page scrolling
useIntersection(getEl, options?) => () => IntersectionObserverEntry | nullObserve element intersection
useDocumentVisibility() => () => 'visible' | 'hidden'Track tab visibility (Page Visibility API)
useIdle(timeoutMs?, opts?) => () => booleanUser-idle detection after timeoutMs of no activity

Type Exports

TypeDescription
UseToggleResult&#123; value: () => boolean; toggle: () => void; setTrue: () => void; setFalse: () => void &#125;
UseHoverResult&#123; hovered: () => boolean; props: &#123; onMouseEnter, onMouseLeave &#125; &#125;
UseFocusResult&#123; focused: () => boolean; props: &#123; onFocus, onBlur &#125; &#125;
Size&#123; width: number; height: number &#125;
WindowSize&#123; width: number; height: number &#125;
BreakpointMapRecord<string, number>
Hooks