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
| Option | Type | Default | Notes |
|---|---|---|---|
present | boolean | none | The desired visibility. Flip it to false to start the exit animation. |
onExitComplete | () => void | none | Runs when the exit resolves, immediately before isPresent is false. |
timeoutCap | number | 3000 | Safety 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.