Utility to animate the presence of an element based on the existence of data or lack thereof.
| Stage | Category | Version | Last Updated | Demo |
|---|---|---|---|---|
| 3 | Animation | 1.0.0-next.6 (next) | Nov 11, 2026 | Demo → |
npm i @solid-primitives/presence@nextA small SolidJS utility to animate the presence of an element. Inspired by & directly forked from use-presence.
The problem
There are two problems that you have to solve when animating the presence of an element:
- During enter animations, you have to render an initial state where the element is hidden and only after the latest state has propagated to the DOM, you can can animate the final state that the element should animate towards.
- Exit animations are a bit tricky in SolidJS, since this typically means that a component unmounts. However when the component has already unmounted, you can't animate it anymore. A workaround is often to keep the element mounted, but that keeps unnecessary elements around and can hurt accessibility, as hidden interactive elements might still be focusable.
This solution
This utility provides a lightweight solution where the animating element is only mounted the minimum of time, while making sure the animation is fully visible to the user. The rendering is left to the user to support all kinds of styling solutions.
How to use it
createPresence boolean example
const FirstExample = () => { const [showStuff, setShowStuff] = createSignal(true); const { isVisible, isMounted } = createPresence(showStuff, { transitionDuration: 500, });
return ( <div style={{ padding: "2em", margin: "2em", "border-radius": "2em", "box-shadow": "-5px 0px 10px rgba(0, 0, 0, 0.2)", }} > <button onclick={() => setShowStuff(!showStuff())}>{`${ showStuff() ? "Hide" : "Show" } stuff`}</button> <Show when={isMounted()}> <div style={{ transition: "all .5s ease", opacity: isVisible() ? "1" : "0", transform: isVisible() ? "translateX(0)" : "translateX(50px)", }} > I am the stuff! </div> </Show> </div> );};createPresence switching example
The first argument of createPresence is a signal accessor of arbitrary type. This allows you to use it with any kind of data, not just booleans. This is useful if you want to animate between different items. If you utilize the returned mountedItem property, you can get the data which should be currently mounted regardless of the animation state
const SecondExample = () => { const items = ["foo", "bar", "baz", "qux"]; const [activeItem, setActiveItem] = createSignal(items[0]); const presence = createPresence(activeItem, { transitionDuration: 500, });
return ( <div style={{ padding: "2em", margin: "2em", "border-radius": "2em", "box-shadow": "-5px 0px 10px rgba(0, 0, 0, 0.2)", }} > <For each={items}> {item => ( <button onClick={() => setActiveItem(p => (p === item ? undefined : item))}> {item} </button> )} </For> <Show when={presence.isMounted()}> <div style={{ transition: "all .5s linear", ...(presence.isEntering() && { opacity: "0", transform: "translateX(-25px)", }), ...(presence.isExiting() && { opacity: "0", transform: "translateX(25px)", }), ...(presence.isVisible() && { opacity: "1", transform: "translateX(0)", }), }} > {presence.mountedItem()} </div> </Show> </div> );};createPresence with a store
createPresence's first argument must be an Accessor — a function. Reading a store property directly (store.panelOpen) passes its current value once and won't update reactively; wrap the access in a function (() => store.panelOpen) so createPresence can subscribe to changes:
const ThirdExample = () => { const [store, setStore] = createStore({ panelOpen: true });
// ✅ wrapped in an accessor const { isVisible, isMounted } = createPresence(() => store.panelOpen, { transitionDuration: 500, });
// ❌ createPresence(store.panelOpen, { transitionDuration: 500 }) // — this is a boolean, not an Accessor<boolean>, and won't type-check
return ( <div> <button onclick={() => setStore("panelOpen", open => !open)}> {store.panelOpen ? "Hide" : "Show"} panel </button> <Show when={isMounted()}> <div style={{ transition: "all .5s ease", opacity: isVisible() ? "1" : "0", }} > I am the panel! </div> </Show> </div> );};Ending transitions on transitionend / animationend
By default a transition is considered over once its duration has passed. If the duration lives in CSS, or the animation is driven by JavaScript, you can end the current enter or exit transition yourself with done. Hand it to the element's onTransitionEnd / onAnimationEnd, or call it when your animation finishes. The duration then only acts as a fallback timeout, in case the event never fires (for example when the transition is cancelled or prefers-reduced-motion turns it off).
// Typetype PresenceResult<TItem> = { // ... done: (event?: Event) => void; bindDone: () => (event?: Event) => void;};
// Exampleconst Panel = () => { const [open, setOpen] = createSignal(false); const presence = createPresence(open, { // fallback in case no transitionend event arrives transitionDuration: 1000, });
return ( <> <button onClick={() => setOpen(o => !o)}>Toggle</button> <Show when={presence.isMounted()}> <div class={{ panel: true, visible: presence.isVisible() }} onTransitionEnd={presence.done} onAnimationEnd={presence.done} > The transition is over as soon as the element's own transition is. </div> </Show> </> );};- When given an event,
doneignores it unless it was fired by the element the handler is attached to, so transitions of descendants bubbling up don't end the transition early. transitionendfires once per transitioned property. If several properties transition with different durations, filter for the longest one:onTransitionEnd={e => e.propertyName === "transform" && presence.done(e)}.- Calling
donewhile no transition is running does nothing. doneends whichever transition is running when it is called. A late call, for example from a JS animation that was started for an earlier transition, ends the newer one. The element's owntransitionend/animationendbelong to the running transition, so handingdoneto them is safe.- When the item is swapped,
doneends the exit of the old item, after which the new item mounts and enters as usual. - Call it from event handlers or other callbacks, not from inside a component body, memo or effect.
Completion callbacks of JavaScript animations
A JavaScript animation can finish after the transition it was started for has been superseded (the item changed back, or changed again). Use bindDone() to get a done bound to the transition that is running now: once the item has changed again, calling it does nothing, so a late callback cannot end a newer transition.
const FadingPanel = (props: { open: boolean }) => { let el!: HTMLDivElement; // the duration is the fallback in case the animation never finishes const presence = createPresence(() => props.open, { transitionDuration: 1000 });
createEffect( () => presence.isExiting(), exiting => { if (!exiting) return; const finish = presence.bindDone(); const animation = el.animate([{ opacity: 1 }, { opacity: 0 }], 300); animation.finished.then(() => finish(), () => {}); return () => animation.cancel(); }, );
return ( <Show when={presence.isMounted()}> <div ref={el}>Fades out with the Web Animations API.</div> </Show> );};bindDone() binds to the state as of the last flush, so call it once the transition has started (as above, in an effect on isEntering() / isExiting()). Called right after changing the item, before that change is flushed, it binds to the previous transition and its callback is ignored; the duration then still ends the transition.
createPresence options API
function createPresence<TItem>( item: Accessor<TItem | undefined>, options: Options,): PresenceResult<TItem>;
type Options = { /** Duration in milliseconds used both for enter and exit transitions. */ transitionDuration: MaybeAccessor<number>; /** Duration in milliseconds used for enter transitions (overrides `transitionDuration` if provided). */ enterDuration: MaybeAccessor<number>; /** Duration in milliseconds used for exit transitions (overrides `transitionDuration` if provided). */ exitDuration: MaybeAccessor<number>; /** Opt-in to animating the entering of an element if `isVisible` is `true` during the initial mount. */ initialEnter?: boolean;};
type PresenceResult<TItem> = { /** Should the component be returned from render? */ isMounted: Accessor<boolean>; /** The item that is currently mounted. */ mountedItem: Accessor<TItem | undefined>; /** Should the component have its visible styles applied? */ isVisible: Accessor<boolean>; /** Is the component either entering or exiting currently? */ isAnimating: Accessor<boolean>; /** Is the component entering currently? */ isEntering: Accessor<boolean>; /** Is the component exiting currently? */ isExiting: Accessor<boolean>; /** Ends the current enter or exit transition now; the duration then only acts as a fallback. */ done: (event?: Event) => void; /** Returns a `done` bound to the current transition; it does nothing once the item has changed again. */ bindDone: () => (event?: Event) => void;};Changelog
See CHANGELOG.md