hono-preact

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

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

OptionTypeDefaultNotes
presentbooleannoneThe desired visibility. Flip it to false to start the exit animation.
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.

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;
  }
}

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.