Motion and presence
Deliberate transitions, interruption, exit completion and reduced motion.
Quick start
After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.
'use client';
import { useId, useState } from 'react';
import { Fade } from '@fayegram-ai/faye-ui/motion/presence';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
export function MoreInformation() {
const id = useId();
const [visible, setVisible] = useState(true);
return <>
<Button aria-expanded={visible} aria-controls={id} onClick={() => setVisible(value => !value)}>
{visible ? 'Hide details' : 'Show details'}
</Button>
<Fade id={id} present={visible} keepMounted enterDuration={180} exitDuration={120}>
<p>Leave a little time for a good coffee.</p>
</Fade>
</>;
}API reference: Presence, Button.
Usage contract
Presence is for ordinary content. It hides inactive content from interaction and respects shared motion tokens. Dialogs, menus and drawers already include their own transitions.
Presence, Fade, Slide, Scale, and Collapse use motion.enter / motion.ease-enter for entry and motion.exit / motion.ease-exit for exit. appear opts into an initial entrance. keepMounted retains hidden, inert content after exit. A forwarded ref follows the actual DOM mount: it points to retained hidden content and becomes null when that content is removed, including when the retention policy changes. Reopening cancels pending exit completion; unmounting cancels scheduled frames and callbacks. Reduced-motion preferences remove transitions while preserving visible state feedback.
Customization
Use semantic tokens for visual changes, public component parts for structural customization, and explicit props for instance behavior. The surrounding site lets you compare light and dark, different brands and density.
Source and verification
The examples below import actual public package entries. Full source is downloadable. Source coverage does not establish keyboard, hydration or browser compatibility; those require the application test suite.
Keyed lists and reordering
TransitionGroup retains exiting keyed children and animates the spacing with their exit. Its stack mode uses animated item margins so the final unmount does not remove a leftover grid gap. Keep stable keys to preserve drafts and focus when items move.
Set layout to animate keyed position changes in stack mode. Position is captured before React moves the element, so reversing an in-progress reorder starts from the current visible position. This uses motion.normal and motion.ease; it adds no physics engine. Overlap mode continues to use the selected presence effect without layout animation. Both system and scoped reduced motion suppress the movement.
Preview controls
Compare all effects or choose one, adjust the duration, select a slide edge or collapse axis, and use Replay preview to run an entrance again. Toggle transitions reverses an in-progress transition. Preview cells keep their space while content is hidden, so the controls stay in place. Reduce motion previews the quieter state; the system preference is always respected.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Interruptible presence transitions
Read the complete example
'use client';
import { useRef, useState } from 'react';
import { Sparkles } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Presence, Fade, Slide, Collapse, Scale, type PresenceProps } from '@fayegram-ai/faye-ui/motion/presence';
import { useReducedMotion } from '@fayegram-ai/faye-ui/hooks';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
import { Grid, Inline } from '@fayegram-ai/faye-ui/layout/layout';
import { Section, Result } from '../example';
import { Choice } from '../choice';
import './motion.css';
const effects = {Presence:(props:PresenceProps)=><Presence {...props}/>,Fade:(props:PresenceProps)=><Fade {...props}/>,Slide:(props:PresenceProps)=><Slide {...props}/>,Scale:(props:PresenceProps)=><Scale {...props}/>,Collapse:(props:PresenceProps)=><Collapse {...props}/>} as const;
export default function Motion() {
const [show,setShow]=useState(true),[replay,setReplay]=useState(0),[effect,setEffect]=useState('all'),[speed,setSpeed]=useState('400'),[direction,setDirection]=useState<PresenceProps['direction']>('start'),[orientation,setOrientation]=useState<'vertical'|'horizontal'>('vertical'),[preference,setPreference]=useState('system');
const slideStage=useRef<HTMLDivElement>(null),reduced=useReducedMotion()||preference==='reduced';
return <Section title="Interruptible presence transitions">
<Grid minItemWidth="12rem" gap="3">
<Choice label="Preview effect" value={effect} onValueChange={setEffect} options={[{value:'all',label:'Compare all effects'},...Object.keys(effects).map(value=>({value,label:value}))]}/>
<Choice label="Motion speed" value={speed} onValueChange={setSpeed} options={[{value:'120',label:'Fast · 120 ms'},{value:'400',label:'Normal · 400 ms'},{value:'750',label:'Slow · 750 ms'}]}/>
<Choice label="Slide direction" value={direction!} onValueChange={value=>setDirection(value as PresenceProps['direction'])} options={['start','end','up','down','left','right'].map(value=>({value,label:value.charAt(0).toUpperCase()+value.slice(1)}))}/>
<Choice label="Collapse orientation" value={orientation} onValueChange={value=>setOrientation(value as typeof orientation)} options={[{value:'vertical',label:'Vertical'},{value:'horizontal',label:'Horizontal'}]}/>
<Choice label="Motion preference" value={preference} onValueChange={setPreference} options={[{value:'system',label:'Use system preference'},{value:'reduced',label:'Reduce motion'}]}/>
</Grid>
<Inline wrap><Button aria-pressed={show} onClick={()=>setShow(v=>!v)}>Toggle transitions</Button><Button onClick={()=>{setShow(true);setReplay(n=>n+1)}}>Replay preview</Button></Inline>
<Result>Reduced motion: {reduced?'requested':'not requested'}</Result>
<div data-faye-ui-motion={preference==='reduced'?'reduced':undefined}><Grid columns={{base:1,sm:2}} gap="4" className="motion-preview-grid">
{Object.entries(effects).filter(([name])=>effect==='all'||name===effect).map(([name,Component])=><div key={name} className="motion-preview-slot"><span className="motion-preview-label">{name}</span><div className="motion-preview-stage" ref={name==='Slide'?slideStage:undefined}>
<Component key={`${name}-${replay}`} present={show} appear={replay>0} keepMounted={name==='Presence'||name==='Collapse'} enterDuration={Number(speed)} exitDuration={Number(speed)} direction={direction} orientation={orientation} container={name==='Slide'?slideStage:undefined}><div className="motion-preview-card"><Icon icon={Sparkles}/><span>Saved for later</span></div></Component>
</div></div>)}
</Grid></div>
</Section>;
}