Interactive consumer experience
Plan a day with responsive navigation, labeled fields, radio descriptions, visible loading and purposeful transitions.
Try the journey
Plan a day, choose your pace, and create an itinerary. Clear the name and submit to see the error beside the persistent label and hint. Create a valid day to see circular and linear progress, a busy button, and a sliding confirmation. Cancel while loading to retain the draft. Hide or show the landscape for a fade, and navigate to the itinerary and back without losing your edits.
The sidebar becomes a horizontal navigation area on small screens. The preview uses the active documentation theme, density, text direction and reduced-motion preference. Open the accordion and its dialog to inspect longer titles and header spacing.
Components and ownership
This example imports the public package: layout, surfaces, advanced text fields, radio groups with ControlLabel, loading feedback, accordion, dialog, and motion primitives. ControlLabel aligns its control with the first label line and exposes the hint as an accessible description. Spread its callback attributes onto the control.
Application code owns the temporary itinerary and a cancelable local preparation timer. Nothing is booked, no server is contacted, and data lasts only while this example is mounted. Faye Places is the separate full application with on-device persistence, discovery, saved collections and responsive drawers.
Source
The complete React example is below. Its small layout stylesheet arranges the responsive sidebar and content; component appearance and interaction come from Faye UI.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Make a little room for wonder.
Take the scenic route.
A quiet walk, a new favourite corner, and time to enjoy it.
Read the complete example
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { ArrowLeft, ArrowRight, Bookmark, Check, Compass, Info, Leaf, MapPin, Search, X } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Heading, Text } from '@fayegram-ai/faye-ui/core/text';
import { Card, CardBody, CardHeader, CardTitle, CardDescription, CardMedia } from '@fayegram-ai/faye-ui/core/surface';
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 { TextareaField } from '@fayegram-ai/faye-ui/controls/textarea-field';
import { ControlLabel } from '@fayegram-ai/faye-ui/controls/field-layout';
import { Radio } from '@fayegram-ai/faye-ui/controls/radio';
import { RadioGroup } from '@fayegram-ai/faye-ui/controls/radio-group';
import { Accordion } from '@fayegram-ai/faye-ui/navigation/accordion';
import { Badge } from '@fayegram-ai/faye-ui/feedback/badge';
import { CircularProgress } from '@fayegram-ai/faye-ui/feedback/circular-progress';
import { LinearProgress } from '@fayegram-ai/faye-ui/feedback/progress';
import { Fade, Slide } from '@fayegram-ai/faye-ui/motion/presence';
import { TransitionGroup } from '@fayegram-ai/faye-ui/motion/transition-group';
import { PageHeader, EmptyState } from '@fayegram-ai/faye-ui/patterns/content';
import { Modal } from '@fayegram-ai/faye-ui/overlays/modal';
import './consumer-experience.css';
const landscape = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400"><rect width="800" height="400" fill="#e7e6d6"/><circle cx="630" cy="95" r="45" fill="#f8f4e4"/><path d="M0 300Q200 50 450 200T800 120V400H0" fill="#95b2a0"/><path d="M0 300Q150 200 320 310T800 220V400H0" fill="#527a68"/><path d="M550 230Q450 300 570 400H740Q580 300 620 220" fill="#c7ddd9"/></svg>')}`;
type Plan = { name: string; notes: string; pace: string };
export default function ConsumerExperience() {
const id = useId(), nameInput = useRef<HTMLInputElement>(null), heading = useRef<HTMLHeadingElement>(null);
const [view, setView] = useState('plan'), [name, setName] = useState('Sunday by the water'), [notes, setNotes] = useState('Leave time for a good coffee.'), [pace, setPace] = useState('slow');
const [error, setError] = useState(''), [pending, setPending] = useState(false), [progress, setProgress] = useState(0), [saved, setSaved] = useState<Plan | null>(null), [preview, setPreview] = useState(true), [details, setDetails] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | null>(null), request = useRef(0);
const cancel = () => { request.current++; if (timer.current) clearInterval(timer.current); timer.current = null; setPending(false); };
useEffect(() => () => { if (timer.current) clearInterval(timer.current); }, []);
const navigate = (next: string) => { setView(next); requestAnimationFrame(() => heading.current?.focus()); };
return <div className="journey" data-consumer-experience>
<aside className="journey-sidebar"><Text weight="semibold" className="journey-brand"><Icon icon={Compass}/>{' '}<span>Fieldnotes</span></Text><nav aria-label="Fieldnotes navigation">{[['plan', 'Plan a day', Compass], ['saved', 'Your itinerary', Bookmark]].map(([key, label, icon]) => <Button key={String(key)} variant="ghost" fullWidth aria-current={view === key ? 'page' : undefined} onClick={() => navigate(String(key))}><Icon icon={icon as typeof Compass}/>{String(label)}</Button>)}</nav><Text as="p" size="sm" tone="muted">Good days start with a little curiosity.</Text></aside>
<div className="journey-main"><Heading ref={heading} tabIndex={-1} level={2} className="journey-view-label">{view === 'plan' ? 'Make a little room for wonder.' : 'Your next small adventure.'}</Heading>
<TransitionGroup mode="overlap" effect="fade" enterDuration={240} exitDuration={180}>
{view === 'plan' ? <Stack key="plan" gap="6">
<PageHeader headingLevel={3} eyebrow="A day your way" title="Take the scenic route." description="A quiet walk, a new favourite corner, and time to enjoy it."/>
<div className="journey-preview"><Fade present={preview} enterDuration={260} exitDuration={180}><CardMedia ratio={2}><img src={landscape} alt="Green hills and a winding river under a pale sun"/></CardMedia></Fade><Button size="sm" variant="ghost" aria-expanded={preview} onClick={() => setPreview(value => !value)}>{preview ? 'Hide landscape preview' : 'Show landscape preview'}</Button></div>
<form noValidate onSubmit={event => {
event.preventDefault(); if (timer.current) return;
if (!name.trim()) { setError('Give your day a name before continuing.'); nameInput.current?.focus(); return; }
setError(''); setPending(true); setProgress(0); setSaved(null);
const version = ++request.current, draft = { name: name.trim(), notes, pace }; let value = 0;
timer.current = setInterval(() => {
if (request.current !== version) return;
value += 25; setProgress(value);
if (value === 100) { clearInterval(timer.current!); timer.current = null; setSaved(draft); setPending(false); }
}, 400);
}}><Stack gap="5">
<fieldset className="journey-fields" disabled={pending}><Stack gap="5">
<TextField ref={nameInput} label="Give your day a name" required name="day" value={name} onChange={event => { setName(event.target.value); setError(''); }} error={error || undefined} description="Something you will look forward to. Up to 60 characters." maxLength={60} startAdornment={<Icon icon={Search} size="sm"/>} endAdornment={<IconButton type="button" variant="ghost" size="sm" aria-label="Clear day name" disabled={pending || !name} onClick={() => { setName(''); nameInput.current?.focus(); }}><Icon icon={X} size="sm"/></IconButton>}/>
<TextareaField label="A note for your future self" name="notes" value={notes} onChange={event => setNotes(event.target.value)} autosize minRows={2} maxRows={4} maxLength={160} description={`${notes.length}/160 characters · Optional`}/>
<div><Text as="p" weight="medium" id={`${id}-pace`}>Find your pace</Text><RadioGroup value={pace} onValueChange={value => setPace(String(value))} aria-labelledby={`${id}-pace`} name="pace"><Stack gap="2">
<ControlLabel label="Take it slow, with plenty of room for a detour" description="A waterside walk and a favourite café." disabled={pending}>{attrs => <Radio.Root {...attrs} value="slow"><Radio.Indicator/></Radio.Root>}</ControlLabel>
<ControlLabel label="A little more adventure" description="A longer trail and somewhere you have never been." disabled={pending}>{attrs => <Radio.Root {...attrs} value="adventure"><Radio.Indicator/></Radio.Root>}</ControlLabel>
</Stack></RadioGroup></div>
</Stack></fieldset>
<Fade present={pending} data-journey-loading><Stack gap="3"><Inline><CircularProgress size="sm" label="Preparing your day"/><Text role="status">Putting your day together…</Text></Inline><LinearProgress label="Day preparation progress" value={progress}/></Stack></Fade>
<Inline wrap><Button type="submit" variant="solid" tone="accent" pending={pending}>Create my day<Icon icon={ArrowRight} mirrorInRtl/></Button>{pending && <Button type="button" onClick={cancel}>Cancel preparation</Button>}</Inline>
<Slide present={!!saved} direction="up" enterDuration={260} exitDuration={180} data-journey-success><Card><CardHeader><CardTitle><Icon icon={Check} size="sm"/> Your day is ready</CardTitle><CardDescription>{saved?.name}</CardDescription></CardHeader><CardBody><Inline wrap><Badge tone="success">Saved for this visit</Badge><Button type="button" onClick={() => navigate('saved')}>See your itinerary<Icon icon={ArrowRight} mirrorInRtl/></Button></Inline></CardBody></Card></Slide>
</Stack></form>
<Accordion.Root><Accordion.Item value="about"><Accordion.Header><Accordion.Trigger><Icon icon={Info} size="sm"/>What happens when I create my day?</Accordion.Trigger></Accordion.Header><Accordion.Panel><Stack gap="3"><Text as="p" size="sm">Your choices become a small itinerary in this preview. Nothing is booked or sent to a server.</Text><Modal open={details} onOpenChange={setDetails} trigger={<Button>About this preview</Button>} title="A little space for your plans" description="Explore at your own pace." footer={<Button onClick={() => setDetails(false)}>Back to my day</Button>}><Text as="p">This example keeps your draft during navigation and shows validation, cancelable loading, a fading preview and a sliding confirmation. It stores nothing after this page is closed.</Text></Modal></Stack></Accordion.Panel></Accordion.Item></Accordion.Root>
</Stack> : <Stack key="saved" gap="5">{saved ? <><PageHeader headingLevel={3} eyebrow="For another day" title={saved.name} description={saved.notes || 'Follow your curiosity.'}/><Grid columns={{base:1,md:2}} gap="4">{[[MapPin, 'By the water', saved.pace === 'slow' ? 'A gentle riverside stroll.' : 'Take the longer hill trail.'], [Leaf, 'A moment to pause', 'Coffee, a good view, and no hurry.']].map(([icon, title, description]) => <Card key={String(title)}><CardHeader><CardTitle><Icon icon={icon as typeof MapPin} size="sm"/> {String(title)}</CardTitle><CardDescription>{String(description)}</CardDescription></CardHeader></Card>)}</Grid><Button onClick={() => navigate('plan')}><Icon icon={ArrowLeft} mirrorInRtl/>Adjust your day</Button></> : <EmptyState icon={<Icon icon={Bookmark} size="lg"/>} title="A little room for a new plan" description="Choose a pace and a name, then keep your ideas together." action={<Button onClick={() => navigate('plan')}>Plan your first day</Button>}/>}</Stack>}
</TransitionGroup>
</div>
</div>;
}