hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn
useSafeArea
usePresence

usePresence#

usePresence keeps an element mounted while it animates out, then unmounts it. It is the primitive every overlay in this library uses to run a closing animation: on close it holds the element in the DOM, lets a [data-state="closed"] CSS transition or keyframe animation run, and unmounts only once it finishes. Animation is opt-in: with no closing rule the element unmounts immediately, exactly as before.

Demo#

Example#

import { usePresence } from 'hono-preact-ui';
import { useState } from 'preact/hooks';

function Panel() {
  const [open, setOpen] = useState(false);
  const presence = usePresence(open);
  return (
    <div>
      <button onClick={() => setOpen((o) => !o)}>Toggle</button>
      {presence.isPresent ? (
        <div
          ref={presence.ref}
          data-state={presence.status === 'open' ? 'open' : 'closed'}
        >
          Content
        </div>
      ) : null}
    </div>
  );
}
[data-state='open'] {
  animation: panel-in 160ms ease-out;
}
[data-state='closed'] {
  animation: panel-out 160ms ease-in forwards;
}
@keyframes panel-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
}
@keyframes panel-out {
  to {
    opacity: 0;
    transform: translateY(-6px);
  }
}
@media (prefers-reduced-motion: reduce) {
  [data-state='open'],
  [data-state='closed'] {
    animation: none;
  }
}

Signature#

import { usePresence } from 'hono-preact-ui';

function usePresence(
  present: boolean,
  options?: UsePresenceOptions
): UsePresenceResult;

interface UsePresenceOptions {
  onExitComplete?: () => void; // fires when the exit resolves, before unmount
  timeoutCap?: number; // ms cap on the exit wait (default 3000)
}

interface UsePresenceResult {
  isPresent: boolean; // render the element while true (open or animating out)
  status: 'open' | 'closing' | 'closed'; // map to data-state (closing -> "closed")
  ref: (node: Element | null) => void; // attach to the animated element
}

Options#

present is the first positional argument (the desired visibility; flip it to false to start the exit animation). The optional second argument is the options object:

OptionTypeDefaultNotes
onExitComplete() => voidnoneRuns when the exit resolves, immediately before isPresent is false.
timeoutCapnumber3000Safety cap (ms) so a stuck animation can never block teardown.

Gate rendering on isPresent and attach ref to the element that carries the exit animation (or an ancestor of it, but never a sibling) merged with your own ref via mergeRefs. Drive data-state from status (both closing and closed map to "closed", so one [data-state="closed"] rule styles the exit), or equivalently from your own open flag; the library's own components do the latter. It reads Element.getAnimations({ subtree: true }) after a forced reflow, waits for every exit animation to finish (with a timeoutCap safety net), and short-circuits under prefers-reduced-motion. There is no exit on first mount or during SSR.

Transition or keyframes#

The exit can be a keyframe animation (above) or a plain CSS transition, usePresence waits for either. A transition is often simpler: put the resting values and a transition on the element, drive entry with @starting-style, and the exit values on [data-state="closed"].

[data-state] {
  opacity: 1;
  transform: translateY(0);
  transition:
    opacity 160ms ease,
    transform 160ms ease;
}
@starting-style {
  [data-state='open'] {
    opacity: 0;
    transform: translateY(-6px);
  }
}
[data-state='closed'] {
  opacity: 0;
  transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
  [data-state] {
    transition: none;
  }
}

Under the hood, usePresence reads Element.getAnimations({ subtree: true }), which reports both transitions and keyframe animations, and if nothing is running on the first read it waits for a transitionrun/animationstart to bubble up before deciding there is no exit. That subtree/event handling is why the animated element can be a child of the one you attach ref to, and why a child whose data-state flips a render-tick later still animates.

Exit animations must be finite. An animation-iteration-count: infinite exit is ignored (it would never resolve, blocking teardown), so the element finalizes as if there were no exit, use a finite count with forwards. Entry animations may loop freely; only the closing animation is awaited.