Consumer interactions and navigation
Icon dismiss controls, queued feedback, marked sliders, responsive navigation and purposeful interactions.
Purpose and composition
These examples use the public Faye UI package to deliver the remaining ordinary UI functions from the Material UI comparison. The implementation and APIs belong to Faye UI. Explore the interactive weekend example below with keyboard, pointer, and touch.
Common dismiss controls
Dialog.Close, Drawer.Close, Popover.Close, AlertDialog.Close, SwipeDrawer.Close, and Toast.Close supply an X icon and a localized accessible name when no content is supplied. Their default frames are square, with a 44px touch target on coarse pointers. Supply aria-label for a context-specific name, children for text or custom artwork, or render for a custom control. Provider part settings, refs, events and class/style overrides continue to apply. Modal.closeProps exposes that same configurable control. Footer actions such as “Done” remain explicit text actions.
Sliders with marks and restricted values
Slider.Marks belongs inside Slider.Control, alongside the track. Each mark has a numeric value and an optional label. Marks are decorative; give every thumb an accessible name. Slider.Root.scale and formatValue transform displayed and announced values while forms submit the raw numeric value. A nonlinear example maps raw exponents to storage sizes.
Slider.DiscreteRoot accepts an ascending allowedValues array and scalar or range values. Its physical positions follow the numeric domain, including uneven gaps. Arrow keys advance to the next allowed value; Home/End and pointer interaction respect the domain. Forms submit actual values. It supports controlled/uncontrolled values, disabled state, orientation, styles and refs. minStepsBetweenValues counts allowed choices between range thumbs. This restricted composition uses non-crossing thumbs; use ordinary Slider.Root when push/swap behavior is required. Uncontrolled native form reset restores defaultValue, including canceled-reset handling. Controlled resets remain application-owned.
Consecutive notifications
Call useToastQueue inside Toast.Provider, then enqueue normal toast options. Only one queued notification enters the provider at a time. The next enters after the previous is removed, including its exit transition. Pending messages have no running timeout. clear() discards pending messages and closes the active one; pendingCount reports waiting messages. IDs must be unique among queued messages. Use a dedicated provider for an independent queue. Toaster.dismissOnClickAway is an explicit optional policy; its default is false. It dismisses one visible notification on a primary outside press without canceling that interaction. Avoid click-away for messages requiring an explicit decision.
Navigation and layouts
NavigationDrawer pushes content on desktop and switches to a modal drawer below 48rem. mode="mini" retains miniNavigation when collapsed. Supply accessible icon links for the mini rail. Logical edge follows RTL. The application owns open; closing returns focus to the toggle. Its viewport subscription and portal use the provider document, including an embedded document. Mobile and desktop trigger/close props are configurable separately. Widths can be overridden with --_navigation-width and --_mini-width. Its responsive threshold is shared by its CSS and media-query subscription.
CollapsedBreadcrumbs keeps leading/trailing items and puts hidden ancestors in a keyboard-operated menu. maxItems, beforeCollapse, afterCollapse, separator, accessible labels, profiles and normal navigation props are configurable. Escape returns focus to the expansion control. Hidden items retain actual links; path segments without links are descriptive disabled menu items.
useScrollTrigger observes the window or a stable mounted targetRef. It reports offset, direction, threshold crossing and a hysteresis-aware trigger. Compose ScrollAppBar with hidden={triggered} and elevated={scrolled}. Focus within reveals the bar. scrollToTop() respects operating-system reduced motion and a Faye UI theme's zero-duration motion policy; its optional reducedMotion flag also supports custom policies outside a Faye UI theme. Subscriptions and animation frames clean up on unmount.
FloatingActions exposes controlled/default open state, hover activation, logical/vertical direction, disabled actions and trigger/positioner/popup props. Its default launcher uses an icon button whose plus rotates into a close mark while open. The short transition respects system and provider reduced-motion preferences; custom trigger artwork supplied through triggerProps.children keeps its own behavior. Horizontal docks preserve readable action widths and scroll when constrained. Keyboard navigation brings the focused action into view, and pointer/touch scrolling remains available. Menu owns keyboard navigation, dismissal and focus restoration. items[].icon supplies decorative artwork independently from labels. Direction changes keyboard orientation; radial artwork is not required for these functions.
Touch, badges and timelines
TouchTooltip adds long-press activation to the same tooltip hover/focus/Escape/outside-dismissal model. A normal tap activates the child control; a completed hold previews its description and suppresses the following activation. Movement beyond tolerance, pointer cancellation and leaving the trigger cancel a hold. Delay, release timeout, tolerance, controlled state, disabled state and part props are configurable. Essential instructions should always remain visible elsewhere.
BadgeIndicator.position accepts all logical corners; legacy start/end retain their top-corner meaning. overlap="circular" moves the indicator inward along round artwork. Counts, dots, maximums and accessible labels retain their existing behavior.
Timeline.position supports start/end, alternate and alternate-reverse sides. TimelineItem.opposite adds metadata opposite the entry. Narrow containers reflow to one readable column without changing DOM reading order. Timestamp semantics and custom markers remain available. Timeline is an additional MUI Lab comparison.
Verification boundary
Functional tests exercise actual interactions and form/focus behavior. Visual review covers theme, density, RTL, narrow layouts, long headings and icon frames. Linux browser qualification does not certify every physical device or assistive-technology combination. Specialist grid/chart/date engines remain separate scope.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
A little room for the weekend
Explore, save, and make a plan at your own pace.
Choose the pace
Consecutive notifications
Navigation that follows your reading
Start with the morning light
Move through the day at your own pace. The navigation returns when you scroll back or focus its controls.
Take the path beside the water
Move through the day at your own pace. The navigation returns when you scroll back or focus its controls.
Leave room for a longer lunch
Move through the day at your own pace. The navigation returns when you scroll back or focus its controls.
Find a place to watch the sunset
Move through the day at your own pace. The navigation returns when you scroll back or focus its controls.
Make room from any edge
Little signals, thoughtful placement
A day, in order
- Morning walkBy the coast
- A picnic with friendsIn the garden
- Sunset and a quiet momentNear the water
Quick actions, your way
Read the complete example
'use client';
import { Choice } from '../choice';
import { useRef, useState } from 'react';
import { ArrowUp, Bell, Bookmark, Compass, Heart, MapPin, Plus, Share } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Card, CardHeader, CardTitle, CardDescription, CardBody } from '@fayegram-ai/faye-ui/core/surface';
import { Button, IconButton } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { Slider } from '@fayegram-ai/faye-ui/controls/slider';
import { Stack, Inline } from '@fayegram-ai/faye-ui/layout/layout';
import { BadgeIndicator } from '@fayegram-ai/faye-ui/feedback/badge-indicator';
import { Toast } from '@fayegram-ai/faye-ui/feedback/toast';
import { Toaster, useToastQueue } from '@fayegram-ai/faye-ui/patterns/toaster';
import { NavigationDrawer } from '@fayegram-ai/faye-ui/patterns/navigation-drawer';
import { CollapsedBreadcrumbs } from '@fayegram-ai/faye-ui/patterns/collapsed-breadcrumbs';
import { ScrollAppBar } from '@fayegram-ai/faye-ui/patterns/application-navigation';
import { FloatingActions } from '@fayegram-ai/faye-ui/patterns/floating-actions';
import { TouchTooltip } from '@fayegram-ai/faye-ui/overlays/tooltip';
import { Drawer } from '@fayegram-ai/faye-ui/overlays/drawer';
import { Timeline, TimelineItem } from '@fayegram-ai/faye-ui/display/timeline';
import { useScrollTrigger } from '@fayegram-ai/faye-ui/hooks/scroll-trigger';
import { ThemeScope } from '@fayegram-ai/faye-ui/provider/provider';
import { Section, Result } from '../example';
import './functional-completion.css';
function QueuedSaves() {
const queue = useToastQueue(), [clickAway,setClickAway] = useState(false), [undone,setUndone] = useState(0);
return <Section title="Consecutive notifications"><Inline wrap><Button onClick={() => ['Coastal walk','Garden picnic','Evening concert'].forEach(title => queue.enqueue({title:`Saved: ${title}`,type:'success',timeout:0,actionProps:{children:'Undo save',onClick:() => setUndone(v => v+1)}}))}><Icon icon={Bookmark}/>Save three plans</Button><Button onClick={queue.clear}>Clear queued saves</Button><Button aria-pressed={clickAway} onClick={() => setClickAway(v => !v)}>Click-away dismissal</Button></Inline><Result><span data-queue-count>{queue.pendingCount} waiting</span> · <span data-queue-undo>{undone} {undone === 1 ? 'save' : 'saves'} undone</span></Result><Toaster dismissOnClickAway={clickAway}/></Section>;
}
const allowed = [5,15,30,60,120];
export default function FunctionalCompletion() {
const [open,setOpen] = useState(false), [mini,setMini] = useState(false), [duration,setDuration] = useState(15), [range,setRange] = useState<readonly number[]>([15,60]);
const [navigationEdge,setNavigationEdge] = useState<'start'|'end'>('start'), [timelinePosition,setTimelinePosition] = useState<'start'|'end'|'alternate'|'alternate-reverse'>('alternate');
const [storyReduced,setStoryReduced] = useState(false);
const [highlight,setHighlight] = useState(false);
const [explored,setExplored] = useState(false), [readBadges,setReadBadges] = useState<string[]>([]);
const [submitted,setSubmitted] = useState('Choose a duration'), [cancelReset,setCancelReset] = useState(false), [tapCount,setTapCount] = useState(0);
const [dock,setDock] = useState(false), [dockOpen,setDockOpen] = useState(false), [direction,setDirection] = useState<'up'|'down'|'start'|'end'>('up'), [selected,setSelected] = useState('No quick action selected');
const scrollRef = useRef<HTMLDivElement>(null), scroll = useScrollTrigger({targetRef:scrollRef,threshold:40,hysteresis:8});
const navigation = <nav aria-label="Weekend destinations" className="faye-ui-weekend-links"><a href="#quiet-plan"><Icon icon={Compass}/>Discover</a><a href="#quiet-plan"><Icon icon={Bookmark}/>Your saved plans</a><a href="#quiet-plan"><Icon icon={Heart}/>Favorites</a></nav>;
const miniNavigation = <nav aria-label="Compact weekend destinations" className="faye-ui-weekend-mini">{[{label:'Discover',icon:Compass},{label:'Your saved plans',icon:Bookmark},{label:'Favorites',icon:Heart}].map(item => <a key={item.label} href="#quiet-plan" aria-label={item.label} title={item.label}><Icon icon={item.icon}/></a>)}</nav>;
return <Stack gap="6" data-functional-completion>
<Section title="A little room for the weekend"><Inline wrap align="end"><Button aria-pressed={mini} onClick={() => setMini(v => !v)}>Mini navigation</Button><Choice label="Navigation edge" value={navigationEdge} onValueChange={value => setNavigationEdge(value as typeof navigationEdge)} options={['start','end'].map(value=>({value,label:value}))}/></Inline><p className="faye-ui-weekend-intro">Explore, save, and make a plan at your own pace.</p>
<NavigationDrawer open={open} onOpenChange={setOpen} edge={navigationEdge} mode={mini ? 'mini' : 'persistent'} title="Your weekends" toggleLabel="Weekend navigation" closeLabel="Close weekend navigation" navigation={navigation} miniNavigation={miniNavigation}>
<Stack gap="4"><CollapsedBreadcrumbs label="Weekend path" items={[{label:'Home',href:'#home'},{label:'Discover',href:'#discover'},{label:'Nearby',href:'#nearby'},{label:'Outside',href:'#outside'},{label:'Coastal walk',href:'#quiet-plan'}]}/>
<Card id="quiet-plan"><CardHeader><CardTitle>Follow the coast</CardTitle><CardDescription>A gentle route, a warm drink, and time to notice the view.</CardDescription></CardHeader><CardBody><Stack gap="4"><TextField label="Give your plan a name" description="A name helps you find this day again." defaultValue="A quiet Saturday" startAdornment={<Icon icon={MapPin}/>}/><Inline wrap><Button variant="solid" tone="accent" onClick={() => setExplored(true)}><Icon icon={Compass}/>Explore this route</Button><TouchTooltip content="Save this plan for later. On touch, hold to preview without saving."><IconButton aria-label="Save for later" onClick={() => setTapCount(v => v+1)}><Icon icon={Bookmark}/></IconButton></TouchTooltip><output data-touch-taps>{tapCount} {tapCount === 1 ? 'plan' : 'plans'} saved</output></Inline>{explored && <Result>Coastal route selected</Result>}</Stack></CardBody></Card>
</Stack>
</NavigationDrawer>
</Section>
<Section title="Choose the pace"><form data-capability-form onSubmit={event => {event.preventDefault();const form=new FormData(event.currentTarget);setSubmitted(`Duration ${form.get('duration')} · Range ${form.getAll('window').join(',')}`);}} onReset={event => {if (cancelReset) {event.preventDefault();setCancelReset(false);} else {setDuration(15);setRange([15,60]);}}}><Stack gap="5">
<label>Walking time in minutes</label><Slider.DiscreteRoot name="duration" allowedValues={allowed} value={duration} onValueChange={setDuration} formatValue={v => `${v} minutes`}><Slider.Value/><Slider.Control><Slider.Track><Slider.Indicator/><Slider.Thumb aria-label="Walking time"/></Slider.Track><Slider.Marks marks={[{value:5,label:'5 min'},{value:30,label:'30 min'},{value:120,label:'2 hr'}]}/></Slider.Control></Slider.DiscreteRoot>
<Slider.DiscreteRoot name="window" allowedValues={allowed} value={range} onValueChange={setRange} minStepsBetweenValues={1} formatValue={v => `${v} minutes`}><Slider.Value/><Slider.Control><Slider.Track><Slider.Indicator/><Slider.Thumb index={0} aria-label="Earliest walk"/><Slider.Thumb index={1} aria-label="Latest walk"/></Slider.Track></Slider.Control></Slider.DiscreteRoot>
<Slider.DiscreteRoot name="uncontrolled" allowedValues={[1,5,20,100]} defaultValue={5} variants={highlight ? {tone:'accent'} : undefined}><Slider.Control><Slider.Track><Slider.Indicator/><Slider.Thumb aria-label="Uncontrolled choices"/></Slider.Track></Slider.Control></Slider.DiscreteRoot>
<Slider.Root name="space" min={0} max={10} defaultValue={5} scale={v => 2**v} formatValue={v => `${v} KB`}><Slider.Value/><Slider.Control><Slider.Track><Slider.Indicator/><Slider.Thumb aria-label="Offline storage"/></Slider.Track></Slider.Control></Slider.Root>
<Inline wrap><Button type="submit">Apply walking time</Button><Button type="reset">Reset walking time</Button><Button aria-pressed={cancelReset} onClick={() => setCancelReset(v => !v)}>Cancel next reset</Button><Button aria-pressed={highlight} onClick={() => setHighlight(v => !v)}>Highlight walking controls</Button></Inline><Result>{submitted}</Result>
</Stack></form></Section>
<Toast.Provider><QueuedSaves/></Toast.Provider>
<Section title="Navigation that follows your reading"><Button aria-pressed={storyReduced} onClick={() => setStoryReduced(v => !v)}>Reduce story motion</Button><ThemeScope motion={storyReduced ? 'reduced' : 'system'}><div ref={scrollRef} className="faye-ui-weekend-scroll" tabIndex={0} aria-label="Scrollable weekend story"><ScrollAppBar hidden={scroll.triggered} elevated={scroll.scrolled}><strong>A slow Saturday</strong><IconButton aria-label="Back to story start" variant="ghost" onClick={() => scroll.scrollToTop()}><Icon icon={ArrowUp}/></IconButton></ScrollAppBar><div className="faye-ui-weekend-story">{['Start with the morning light','Take the path beside the water','Leave room for a longer lunch','Find a place to watch the sunset'].map(title => <section key={title}><h4>{title}</h4><p>Move through the day at your own pace. The navigation returns when you scroll back or focus its controls.</p></section>)}</div></div></ThemeScope><Inline wrap><Button onClick={() => scroll.scrollToTop()}><Icon icon={ArrowUp}/>Back to the beginning</Button><output data-scroll-state>{scroll.triggered ? 'Header resting' : 'Header visible'} · {Math.round(scroll.offset)} px</output></Inline></Section>
<Section title="Make room from any edge"><Inline wrap>{(['top','bottom','start','end'] as const).map(edge => <Drawer.Root key={edge}><Drawer.Trigger render={<Button/>}>Plan from {edge}</Drawer.Trigger><Drawer.Portal><Drawer.Backdrop/><Drawer.Popup edge={edge}><Drawer.Header><Drawer.Title>Make room for your next journey</Drawer.Title><Drawer.Close aria-label={`Close ${edge} plan`}/><Drawer.Description>Keep the details close, with space to breathe.</Drawer.Description></Drawer.Header><Drawer.Body><TextField label="A note for the day" description="This stays in the example." placeholder="Bring a picnic blanket"/></Drawer.Body><Drawer.Actions><Drawer.Close render={<Button/>}>Done planning</Drawer.Close></Drawer.Actions></Drawer.Popup></Drawer.Portal></Drawer.Root>)}</Inline></Section>
<Section title="Little signals, thoughtful placement"><Inline wrap>{(['top-start','top-end','bottom-start','bottom-end'] as const).map((position,index) => <Stack key={position} gap="2" align="center"><BadgeIndicator count={readBadges.includes(position) ? 0 : index+1} label={`${index+1} ${index === 0 ? 'update' : 'updates'}`} position={position} overlap="circular"><IconButton aria-label={`Updates ${position}`} aria-pressed={readBadges.includes(position)} onClick={() => setReadBadges(values => values.includes(position) ? values.filter(value => value !== position) : [...values,position])} className="faye-ui-weekend-round"><Icon icon={Bell}/></IconButton></BadgeIndicator><small>{position}</small></Stack>)}</Inline></Section>
<Section title="A day, in order"><Choice label="Timeline position" value={timelinePosition} onValueChange={value => setTimelinePosition(value as typeof timelinePosition)} options={['end','start','alternate','alternate-reverse'].map(value=>({value,label:value}))}/><Timeline position={timelinePosition} aria-label="Weekend itinerary"><TimelineItem timestamp="09:00" dateTime="09:00" opposite="By the coast" tone="accent">Morning walk</TimelineItem><TimelineItem timestamp="12:30" dateTime="12:30" opposite="In the garden" tone="success">A picnic with friends</TimelineItem><TimelineItem timestamp="18:00" dateTime="18:00" opposite="Near the water">Sunset and a quiet moment</TimelineItem></Timeline></Section>
<Section title="Quick actions, your way"><Inline wrap align="end"><Button aria-pressed={dock} onClick={() => setDock(v => !v)}>Show quick actions</Button><Button aria-pressed={dockOpen} onClick={() => {setDock(true);setDockOpen(v => !v);}}>Open controlled actions</Button><Choice label="Action direction" value={direction} onValueChange={value => setDirection(value as typeof direction)} options={['up','down','start','end'].map(value=>({value,label:value}))}/></Inline>{dock && <FloatingActions label="Weekend quick actions" open={dockOpen} onOpenChange={setDockOpen} direction={direction} openOnHover items={[{id:'save',label:'Save route',icon:<Icon icon={Bookmark}/>,onSelect:() => setSelected('Route saved')},{id:'share',label:'Share plan',icon:<Icon icon={Share}/>,onSelect:() => setSelected('Share selected')},{id:'new',label:'New plan',icon:<Icon icon={Plus}/>,disabled:true,onSelect:() => setSelected('Unexpected action')}]}/>}<Result>{selected}</Result></Section>
</Stack>;
}