Skip to content
fayeuiStatic / 0.1.0
LLMs
Components / Static edition

Motion and interactive navigation

Responsive mixed toolbar, scrollable tabs, interactive stepper and keyed content transitions.

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 { useState } from 'react';
import { Tabs } from '@fayegram-ai/faye-ui/navigation/tabs';
import { Stepper } from '@fayegram-ai/faye-ui/navigation/stepper';
import { Stack } from '@fayegram-ai/faye-ui/layout/layout';

export function PlanningSteps() {
  const [step, setStep] = useState('place');
  return <Stack gap="4">
    <Stepper nonLinear activeStep={step} onStepChange={setStep}
      steps={[{ key: 'place', label: 'Choose a place' }, { key: 'pace', label: 'Set your pace' }, { key: 'review', label: 'Review' }]} />
    <Tabs.Root defaultValue="plan">
      <Tabs.ScrollableList aria-label="Planning views">
        <Tabs.Tab value="plan">Plan</Tabs.Tab><Tabs.Tab value="saved">Saved ideas</Tabs.Tab>
      </Tabs.ScrollableList>
      <Tabs.Panel value="plan">Current stage: {step}</Tabs.Panel>
      <Tabs.Panel value="saved">Your saved ideas go here.</Tabs.Panel>
    </Tabs.Root>
  </Stack>;
}

API reference: Tabs, Stepper.

Motion with ownership

Presence, Fade, Slide, Collapse and Scale own ordinary content lifetime. Keep Base UI dialogs, popovers, sheets and their backdrops on their existing lifecycle. Presence forwards its root ref and accepts enterDuration/exitDuration in milliseconds; durations must be finite and nonnegative. CSS token defaults remain the default and reduced-motion policy overrides custom durations.

Slide direction is the starting edge (up/down/left/right/start/end). Logical start/end follow computed text direction. An optional container element/ref moves the content fully beyond that boundary; clip the container if that is the intended effect. Without a container, the default is a small offset. Collapse can animate vertical intrinsic height or horizontal content width; horizontal children own a deliberate width. Scale accepts scaleOrigin.

TransitionGroup children require unique explicit stable keys. Removed children remain inert during exit; restoring a key before completion cancels removal and retains child state. After completed removal, adding the key mounts new state. Stack mode defaults to collapse, so the space occupied by exiting content shrinks. Overlap mode layers route/view content in one grid area. Reordering preserves keyed state; no physics-based position interpolation is implied. Router state and focus destination after removing a focused item remain application decisions.

Scrollable tabs and toolbars

Tabs.ScrollableList adds logical previous/next controls to the existing tab list. It keeps focused tabs visible, measures overflow and works in RTL. Scroll controls use aria-disabled at an edge while remaining focusable, so reaching the edge does not discard keyboard focus. Labels and part styles are configurable. Use Tabs.List for a vertical list.

The collection toolbar mixes a search input, actions, a pressed toggle and a menu. Secondary actions move into the menu at the compact breakpoint. The same action handlers serve both views; focus moves to the persistent More control if its focused secondary action disappears. Menu and roving toolbar focus remain owned by Base UI. This is a recipe: application priorities determine which actions overflow.

Interactive and mobile steps

Stepper is a static overview when onStepChange is omitted. Supplying it renders native buttons, with linear gating by default and an explicit nonLinear option. Applications own activeStep, completion, optional steps, errors and validation. Disabled steps cannot activate. Vertical mode keeps descriptions and errors next to the numbered indicator.

MobileStepper provides dots, progress or text, accessible progress text, custom labels and optional back/next slots. Its progress variant keeps native progress semantics while painting a themed track that animates from the logical start edge. Step hover/press feedback and progress transitions honor system and provider reduced-motion preferences. The progress label isolates its text direction; use formatLabel to supply your own language and wording. activeStep is zero-based and must be within a positive integer steps count. It does not infer whether business validation permits moving forward.

Live examples

Try the controls below, then read or copy the complete example to adapt it for your application.

Responsive collection tools

Choose a collection tool.
For you collections

Create a collection

Current step: details
1 of 3
1 of 3
1 of 3

Motion and continuity

Horizontal details with a fixed content width.
Scale from the top start corner.
Completed slide exits: 0

First

Second

Third

Discover view

Read the complete example
'use client';
import { useState, useRef, useLayoutEffect, useEffect } from 'react';
import { Plus, MoreHorizontal, Pencil, Share2, Download, Heart, ArrowLeft, ArrowRight, Compass, Bookmark, Search } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Card,CardTitle,CardBody,CardActions,Surface } from '@fayegram-ai/faye-ui/core/surface';
import { Text,Heading } from '@fayegram-ai/faye-ui/core/text';
import { Stack,Inline,Grid } from '@fayegram-ai/faye-ui/layout/layout';
import { Button, IconButton } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { Choice } from '../choice';
import { FieldLayout } from '@fayegram-ai/faye-ui/controls/field-layout';
import { Toolbar } from '@fayegram-ai/faye-ui/navigation/toolbar';
import { Tabs } from '@fayegram-ai/faye-ui/navigation/tabs';
import { Stepper } from '@fayegram-ai/faye-ui/navigation/stepper';
import { MobileStepper } from '@fayegram-ai/faye-ui/navigation/mobile-stepper';
import { Menu } from '@fayegram-ai/faye-ui/overlays/menu';
import { Slide,Collapse,Scale } from '@fayegram-ai/faye-ui/motion/presence';
import { TransitionGroup } from '@fayegram-ai/faye-ui/motion/transition-group';
import { ThemeScope } from '@fayegram-ai/faye-ui/provider/provider';
import { useMediaQuery } from '@fayegram-ai/faye-ui/hooks';
const categories=['For you','Outdoors','Architecture','Food and drink','Art and culture','Weekend trips','Quiet places','Your favourites'];
export function CollectionToolbar(){
    const compact=useMediaQuery('(max-width:40rem)'),[open,setOpen]=useState(false),[liked,setLiked]=useState(false),[message,setMessage]=useState('Choose a collection tool.'),[filter,setFilter]=useState('');
    const more=useRef<HTMLButtonElement>(null),lastSecondary=useRef<HTMLElement|null>(null);
    useLayoutEffect(()=>{const previous=lastSecondary.current,document=more.current?.ownerDocument;if(compact&&previous&&!previous.isConnected&&document&&document.activeElement===document.body)more.current?.focus();},[compact]);
    useEffect(()=>{if(!compact)setOpen(false);},[compact]);
    return <Stack gap="3"><Toolbar.Root aria-label="Collection tools" style={{width:'100%'}} onFocusCapture={event=>{if((event.target as HTMLElement).hasAttribute('data-secondary-action'))lastSecondary.current=event.target as HTMLElement;}}>
        <Toolbar.Button onClick={()=>setMessage('New collection selected.')}><Icon icon={Plus}/>New</Toolbar.Button>
        <Toolbar.Input aria-label="Collection toolbar filter" placeholder="Find a collection" value={filter} onChange={event=>setFilter(event.target.value)} style={{flex:'1 1 8rem'}}/>
        <Toolbar.Button render={<IconButton aria-label="Only favourites"><Icon icon={Heart}/></IconButton>} aria-pressed={liked} onClick={()=>setLiked(value=>!value)}/>
        {!compact&&<Toolbar.Group><Toolbar.Button data-secondary-action onClick={()=>setMessage('Rename selected.')}><Icon icon={Pencil}/>Rename</Toolbar.Button><Toolbar.Button data-secondary-action onClick={()=>setMessage('Share selected.')}><Icon icon={Share2}/>Share</Toolbar.Button></Toolbar.Group>}
        <Menu.Root open={open} onOpenChange={setOpen}><Menu.Trigger render={<Toolbar.Button ref={more} render={<IconButton aria-label="More collection tools"><Icon icon={MoreHorizontal}/></IconButton>}/>}/><Menu.Portal><Menu.Positioner><Menu.Popup>{compact&&<><Menu.Item onClick={()=>setMessage('Rename selected.')}><Icon icon={Pencil}/>Rename collection</Menu.Item><Menu.Item onClick={()=>setMessage('Share selected.')}><Icon icon={Share2}/>Share collection</Menu.Item><Menu.Separator/></>}<Menu.Item onClick={()=>setMessage('Download selected.')}><Icon icon={Download}/>Download collection</Menu.Item></Menu.Popup></Menu.Positioner></Menu.Portal></Menu.Root>
    </Toolbar.Root><Text role="status" data-toolbar-result>{message}{filter&&` Filter: ${filter}.`}{liked&&' Favourites only.'}</Text></Stack>;
}
function Draft({name}:{name:string}){const [value,setValue]=useState(name);return <Card data-transition-item={name}><CardTitle>{name}</CardTitle><CardBody><TextField label={`${name} draft`} value={value} onChange={event=>setValue(event.target.value)}/></CardBody></Card>;}
export default function InteractionCompositions(){
    const [show,setShow]=useState(true),[reduced,setReduced]=useState(false),[direction,setDirection]=useState<'start'|'end'|'up'|'down'>('start'),[items,setItems]=useState(['First','Second','Third']),[route,setRoute]=useState('Discover');
    const boundary=useRef<HTMLDivElement>(null),[completed,setCompleted]=useState<string[]>([]),[active,setActive]=useState('details'),[name,setName]=useState(''),[invalid,setInvalid]=useState(false),[nonLinear,setNonLinear]=useState(false),[vertical,setVertical]=useState(false);
    const keys=['details','review','finish'],index=keys.indexOf(active),[exits,setExits]=useState(0);
    const advance=()=>{if(active==='details'&&!name.trim()){setInvalid(true);return;}setInvalid(false);setCompleted(old=>Array.from(new Set([...old,active])));setActive(keys[Math.min(index+1,2)]!);};
    return <ThemeScope motion={reduced?'reduced':'system'}><Stack gap="6" data-interaction-compositions>
        <Heading level={3}>Responsive collection tools</Heading><CollectionToolbar/>
        <Tabs.Root defaultValue={categories[0]}><Tabs.ScrollableList aria-label="Discovery categories">{categories.map((label,i)=><Tabs.Tab key={label} value={label}><Icon icon={i===0?Compass:i===7?Bookmark:Search}/>{label}</Tabs.Tab>)}<Tabs.Indicator/></Tabs.ScrollableList>{categories.map(label=><Tabs.Panel key={label} value={label}>{label} collections</Tabs.Panel>)}</Tabs.Root>
        <Surface data-step-composition><Stack gap="4"><Heading level={3}>Create a collection</Heading><Inline wrap><Button aria-pressed={nonLinear} onClick={()=>setNonLinear(v=>!v)}>Allow any step</Button><Button aria-pressed={vertical} onClick={()=>setVertical(v=>!v)}>Vertical steps</Button></Inline><Stepper label="Collection steps" activeStep={active} orientation={vertical?'vertical':'horizontal'} nonLinear={nonLinear} onStepChange={setActive} steps={keys.map(key=>({key,label:key==='details'?'Details':key==='review'?'Review':'Finish',optional:key==='review',status:key==='details'&&invalid?'error':completed.includes(key)?'complete':key===active?'current':'upcoming',errorMessage:key==='details'&&invalid?'A collection needs a name.':undefined}))}/><TextField label="New collection name" value={name} error={invalid?'Enter a name to continue.':undefined} onChange={event=>{setName(event.target.value);setInvalid(false);}}/><Text data-step-result>Current step: {active}</Text><Inline><Button disabled={index===0} onClick={()=>setActive(keys[index-1]!)}><Icon icon={ArrowLeft} mirrorInRtl/>Previous step</Button><Button disabled={index===2} onClick={advance}>Next step<Icon icon={ArrowRight} mirrorInRtl/></Button></Inline>{(['dots','progress','text'] as const).map(variant=><MobileStepper key={variant} variant={variant} steps={3} activeStep={index} label={`Mobile ${variant} progress`}/>)}</Stack></Surface>
        <Surface data-motion-composition><Stack gap="4"><Heading level={3}>Motion and continuity</Heading><Inline wrap align="end"><Button onClick={()=>setShow(v=>!v)}>Toggle motion content</Button><Button aria-pressed={reduced} onClick={()=>setReduced(v=>!v)}>Reduce example motion</Button><Choice label="Slide edge" value={direction} onValueChange={value=>setDirection(value as typeof direction)} options={['start','end','up','down'].map(value=>({value,label:value}))}/></Inline>
            <Surface ref={boundary} variant="subtle" style={{overflow:'hidden'}} data-slide-boundary><Slide present={show} direction={direction} container={boundary} enterDuration={240} exitDuration={560} onExitComplete={()=>setExits(n=>n+1)} data-directed-slide><Button>Sliding content action</Button></Slide></Surface>
            <Inline wrap><Collapse present={show} orientation="horizontal" enterDuration={240} exitDuration={560} data-horizontal-collapse><Surface style={{width:240}}>Horizontal details with a fixed content width.</Surface></Collapse><Scale present={show} scaleOrigin="top left" enterDuration={240} exitDuration={560} data-origin-scale><Surface>Scale from the top start corner.</Surface></Scale></Inline><Text data-motion-exits>Completed slide exits: {exits}</Text>
            <Inline wrap><Button onClick={()=>setItems(old=>old.filter(item=>item!=='First'))}>Remove first story</Button><Button onClick={()=>setItems(old=>old.filter(item=>item!=='Second'))}>Remove second story</Button><Button onClick={()=>setItems(old=>old.filter(item=>item!=='Third'))}>Remove last story</Button><Button onClick={()=>setItems(['First','Second','Third'])}>Undo story removal</Button><Button onClick={()=>setItems(old=>[...old].reverse())}>Reverse story order</Button></Inline><TransitionGroup layout exitDuration={560} data-story-transitions>{items.map(item=><Draft key={item} name={item}/>)}</TransitionGroup>
            <Inline wrap><Button onClick={()=>setRoute('Discover')}><Icon icon={Compass}/>Discover view</Button><Button onClick={()=>setRoute('Saved')}><Icon icon={Bookmark}/>Saved view</Button></Inline><TransitionGroup mode="overlap" effect="slide" direction="end" exitDuration={400} data-route-transitions><Surface key={route}><Heading level={4}>{route} view</Heading><TextField label={`${route} view note`} defaultValue="A stable route draft"/></Surface></TransitionGroup>
        </Stack></Surface>
    </Stack></ThemeScope>;
}