Skip to content
v2.5.0
English
Русский
Documentation
All templates

Travel search

Destination suggestions, dates and travellers, then flight results with price, stops, airline and time filters, sorting and an empty state.

Code

docs/examples/templates/travel-search.tsx
import { useId, useState, type CSSProperties, type FormEvent, type KeyboardEvent } from 'react';
import {
  Badge,
  Button,
  CalendarContent,
  CalendarDay,
  CalendarDays,
  CalendarHeader,
  CalendarMonth,
  CalendarMonthLabel,
  CalendarMonths,
  CalendarNextButton,
  CalendarPrevButton,
  CalendarViewButton,
  CalendarViewButtonGroup,
  CalendarWeekday,
  CalendarWeekdays,
  CalendarWeeks,
  CalendarYear,
  CalendarYearLabel,
  CalendarYears,
  Card,
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndicator,
  CheckboxInput,
  DateRangeField,
  DateRangeFieldCalendar,
  DateRangeFieldInput,
  DateRangeFieldTriggerIconButton,
  Fieldset,
  FieldsetLegend,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  Option,
  Popover,
  PopoverContent,
  PopoverTrigger,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Select,
  SelectCombobox,
  SelectIcon,
  SelectListbox,
  SelectOption,
  SelectValue,
  Separator,
  Sheet,
  SheetClose,
  SheetContent,
  SheetHandle,
  Slider,
  SliderRange,
  SliderThumb,
  SliderThumbIndicator,
  SliderThumbInput,
  SliderTrack,
  Text,
  TextField,
  TextFieldInput,
  VisuallyHidden,
} from '@vega-ui/react';
import { ArrowRight, MapPin, Plane, SlidersHorizontal, Users, XIcon } from '@vega-ui/icons';
import './travel-search.css';

type Sort = 'best' | 'cheapest' | 'fastest' | 'earliest';
type Time = 'any' | 'morning' | 'afternoon' | 'evening';

interface Filters {
  price: number[]
  stops: number[]
  airlines: string[]
  time: Time
}

interface Flight {
  id: string
  airline: string
  depart: string
  arrive: string
  duration: number
  stops: number
  via?: string
  price: number
}

const ORIGIN = { city: 'Berlin', code: 'BER' };
const PRICE_MIN = 50;
const PRICE_MAX = 300;

const destinations = [
  { city: 'Lisbon', country: 'Portugal', code: 'LIS' },
  { city: 'London', country: 'United Kingdom', code: 'LHR' },
  { city: 'Barcelona', country: 'Spain', code: 'BCN' },
  { city: 'Rome', country: 'Italy', code: 'FCO' },
  { city: 'Paris', country: 'France', code: 'CDG' },
  { city: 'Porto', country: 'Portugal', code: 'OPO' },
  { city: 'Madrid', country: 'Spain', code: 'MAD' },
  { city: 'Athens', country: 'Greece', code: 'ATH' },
  { city: 'Amsterdam', country: 'Netherlands', code: 'AMS' },
  { city: 'Reykjavík', country: 'Iceland', code: 'KEF' },
];

const flights: Flight[] = [
  { id: 'tp-531', airline: 'TAP Air Portugal', depart: '09:15', arrive: '11:55', duration: 220, stops: 0, price: 186 },
  { id: 'u2-4521', airline: 'easyJet', depart: '13:30', arrive: '16:10', duration: 220, stops: 0, price: 94 },
  { id: 'fr-8104', airline: 'Ryanair', depart: '21:20', arrive: '23:55', duration: 215, stops: 0, price: 68 },
  { id: 'lh-1870', airline: 'Lufthansa', depart: '06:40', arrive: '11:05', duration: 325, stops: 1, via: 'FRA', price: 212 },
  { id: 'ib-3651', airline: 'Iberia', depart: '10:05', arrive: '15:40', duration: 395, stops: 1, via: 'MAD', price: 154 },
  { id: 'lh-1932', airline: 'Lufthansa', depart: '17:45', arrive: '22:10', duration: 325, stops: 1, via: 'MUC', price: 238 },
  { id: 'tp-537', airline: 'TAP Air Portugal', depart: '19:00', arrive: '21:40', duration: 220, stops: 0, price: 204 },
  { id: 'ib-3117', airline: 'Iberia', depart: '07:30', arrive: '16:25', duration: 595, stops: 2, via: 'MAD, OPO', price: 122 },
];

type Destination = (typeof destinations)[number];

const airlines = [...new Set(flights.map((flight) => flight.airline))].sort();

const stopOptions = [
  { value: 0, label: 'Nonstop' },
  { value: 1, label: '1 stop' },
  { value: 2, label: '2+ stops' },
];

const times: { value: Time, label: string, from: number, to: number }[] = [
  { value: 'any', label: 'Any', from: 0, to: 24 },
  { value: 'morning', label: 'Morning', from: 5, to: 12 },
  { value: 'afternoon', label: 'Afternoon', from: 12, to: 18 },
  { value: 'evening', label: 'Evening', from: 18, to: 24 },
];

const sorts: { value: Sort, label: string }[] = [
  { value: 'best', label: 'Best' },
  { value: 'cheapest', label: 'Cheapest' },
  { value: 'fastest', label: 'Fastest' },
  { value: 'earliest', label: 'Earliest departure' },
];

const defaultFilters: Filters = { price: [PRICE_MIN, PRICE_MAX], stops: [0, 1, 2], airlines, time: 'any' };

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
const shortDate = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });

const field = { display: 'grid', gap: 'var(--spacing-3)', alignContent: 'start', minWidth: 0 } as const;
const secondary = { color: 'var(--label-secondary)' } as const;
const tabular = { fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' } as const;
const closeButton = { flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties;

const formatDuration = (minutes: number) => `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, '0')} min`;

const formatStops = (flight: Flight) => {
  if (flight.stops === 0) return 'Nonstop';
  if (flight.stops === 1) return `1 stop · ${flight.via}`;

  return `${flight.stops} stops · ${flight.via}`;
};

const formatRange = ([start, end]: string[]) => {
  if (!start || !end) return 'Any dates';

  return `${shortDate.format(new Date(start))} – ${shortDate.format(new Date(end))}`;
};

const formatTravellers = (adults: number, children: number) => {
  const parts = [`${adults} ${adults === 1 ? 'adult' : 'adults'}`];

  if (children > 0) parts.push(`${children} ${children === 1 ? 'child' : 'children'}`);

  return parts.join(', ');
};

const getHour = (time: string) => Number(time.slice(0, 2));

const matchesFilters = (flight: Flight, filters: Filters) => {
  const time = times.find((item) => item.value === filters.time) ?? times[0];
  const hour = getHour(flight.depart);

  if (flight.price < filters.price[0] || flight.price > filters.price[1]) return false;
  if (!filters.stops.includes(Math.min(flight.stops, 2))) return false;
  if (!filters.airlines.includes(flight.airline)) return false;

  return hour >= time.from && hour < time.to;
};

const getScore = (flight: Flight) => flight.price + flight.duration * 0.4;

const compare: Record<Sort, (a: Flight, b: Flight) => number> = {
  best: (a, b) => getScore(a) - getScore(b),
  cheapest: (a, b) => a.price - b.price,
  fastest: (a, b) => a.duration - b.duration,
  earliest: (a, b) => a.depart.localeCompare(b.depart),
};

const countActive = (filters: Filters) => {
  let count = 0;

  if (filters.price[0] !== PRICE_MIN || filters.price[1] !== PRICE_MAX) count += 1;
  if (filters.stops.length !== defaultFilters.stops.length) count += 1;
  if (filters.airlines.length !== airlines.length) count += 1;
  if (filters.time !== 'any') count += 1;

  return count;
};

const focusField = (form: HTMLFormElement, name: string) => {
  const element = form.elements.namedItem(name);
  if (element instanceof HTMLElement) element.focus();
};

const toggle = <T,>(list: T[], value: T, checked: boolean) => (checked ? [...list, value] : list.filter((item) => item !== value));

interface FiltersProps {
  prefix: string
  filters: Filters
  onChange(filters: Filters): void
}

const FlightFilters = ({ prefix, filters, onChange }: FiltersProps) => (
  <div style={{ display: 'grid', gap: 'var(--spacing-12)' }}>
    <div style={{ ...field, gap: 'var(--spacing-5)' }}>
      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-6)' }}>
        <Label id={`${prefix}-price`}>Price</Label>
        <Text size={2} aria-hidden='true' style={{ ...secondary, ...tabular }}>{`${currency.format(filters.price[0])} – ${currency.format(filters.price[1])}`}</Text>
      </div>
      <Slider
        min={PRICE_MIN}
        max={PRICE_MAX}
        step={10}
        minStepsBetweenThumbs={1}
        value={filters.price}
        onChangeValue={(price) => onChange({ ...filters, price })}
        role='group'
        aria-labelledby={`${prefix}-price`}
      >
        <SliderTrack>
          <SliderRange />
        </SliderTrack>
        <SliderThumb index={0}>
          <SliderThumbInput aria-label='Minimum price' aria-valuetext={currency.format(filters.price[0])} />
          <SliderThumbIndicator />
        </SliderThumb>
        <SliderThumb index={1}>
          <SliderThumbInput aria-label='Maximum price' aria-valuetext={currency.format(filters.price[1])} />
          <SliderThumbIndicator />
        </SliderThumb>
      </Slider>
    </div>

    <Fieldset className='travelFieldset' style={{ gap: 'var(--spacing-5)' }}>
      <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-3)' }}><Text size={3} fontWeight={500}>Stops</Text></FieldsetLegend>
      {stopOptions.map((option) => (
        <Checkbox key={option.value} className='travelCheck'>
          <CheckboxInput
            name={`${prefix}-stops`}
            value={option.value}
            checked={filters.stops.includes(option.value)}
            onChange={(event) => onChange({ ...filters, stops: toggle(filters.stops, option.value, event.currentTarget.checked) })}
          />
          <CheckboxIndicator>
            <CheckboxCheckedIcon />
          </CheckboxIndicator>
          {option.label}
        </Checkbox>
      ))}
    </Fieldset>

    <Fieldset className='travelFieldset' style={{ gap: 'var(--spacing-5)' }}>
      <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-3)' }}><Text size={3} fontWeight={500}>Airlines</Text></FieldsetLegend>
      {airlines.map((airline) => {
        const count = flights.filter((flight) => flight.airline === airline).length;

        return (
          <Checkbox key={airline} className='travelCheck'>
            <CheckboxInput
              name={`${prefix}-airlines`}
              value={airline}
              checked={filters.airlines.includes(airline)}
              onChange={(event) => onChange({ ...filters, airlines: toggle(filters.airlines, airline, event.currentTarget.checked) })}
            />
            <CheckboxIndicator>
              <CheckboxCheckedIcon />
            </CheckboxIndicator>
            <span style={{ flex: 1, minWidth: 0 }}>{airline}</span>
            <Text size={2} aria-hidden='true' style={{ ...secondary, ...tabular }}>{count}</Text>
            <VisuallyHidden>{`, ${count} ${count === 1 ? 'flight' : 'flights'}`}</VisuallyHidden>
          </Checkbox>
        );
      })}
    </Fieldset>

    <div style={field}>
      <Label id={`${prefix}-time`}>Departure time</Label>
      <SegmentedControl
        size='xs'
        name={`${prefix}-time`}
        role='radiogroup'
        aria-labelledby={`${prefix}-time`}
        value={filters.time}
        className='travelTime'
        onChange={(event) => onChange({ ...filters, time: event.target.value as Time })}
      >
        {times.map((time) => (
          <SegmentedControlItem key={time.value} value={time.value}>
            <SegmentedControlItemInput aria-describedby={`${prefix}-time-${time.value}`} />
            {time.label}
          </SegmentedControlItem>
        ))}
        <SegmentedControlIndicator />
      </SegmentedControl>
      <div className='travelTimeHints'>
        {times.map((time) => (
          <HelperText key={time.value} id={`${prefix}-time-${time.value}`}>
            {time.value === 'any' ? 'All day' : `${String(time.from).padStart(2, '0')}–${time.to}`}
          </HelperText>
        ))}
      </div>
    </div>
  </div>
);

export default function TravelSearch() {
  const id = useId();
  const listId = `${id}-destinations`;
  const [query, setQuery] = useState('Lisbon');
  const [destination, setDestination] = useState<Destination | null>(destinations[0]);
  const [suggestOpen, setSuggestOpen] = useState(false);
  const [active, setActive] = useState(0);
  const [dates, setDates] = useState<[string, string]>(['2026-12-12', '2026-12-19']);
  const [adults, setAdults] = useState(2);
  const [children, setChildren] = useState(0);
  const [travellersOpen, setTravellersOpen] = useState(false);
  const [errors, setErrors] = useState<{ destination?: string, dates?: string }>({});
  const [search, setSearch] = useState({ destination: destinations[0], dates, travellers: 2, label: formatTravellers(2, 0) });
  const [filters, setFilters] = useState<Filters>(defaultFilters);
  const [sort, setSort] = useState<Sort>('best');
  const [filtersOpen, setFiltersOpen] = useState(false);

  const term = query.trim().toLowerCase();
  const matches = destinations
    .filter((item) => `${item.city} ${item.country} ${item.code}`.toLowerCase().includes(term))
    .slice(0, 6);
  const expanded = suggestOpen && matches.length > 0;

  const visible = flights.filter((flight) => matchesFilters(flight, filters)).sort(compare[sort]);
  const cheapest = visible.reduce<Flight | undefined>((best, flight) => (!best || flight.price < best.price ? flight : best), undefined);
  const recommended = visible.reduce<Flight | undefined>((best, flight) => (!best || getScore(flight) < getScore(best) ? flight : best), undefined);
  const activeFilters = countActive(filters);

  const changeQuery = (value: string) => {
    setQuery(value);
    setActive(0);
    setSuggestOpen(value.trim() !== '');
    setErrors({ ...errors, destination: undefined });

    const exact = destinations.find((item) => item.city.toLowerCase() === value.trim().toLowerCase());
    setDestination(exact ?? null);
  };

  const choose = (item: Destination) => {
    setQuery(item.city);
    setDestination(item);
    setSuggestOpen(false);
    setErrors({ ...errors, destination: undefined });
  };

  const onQueryKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'ArrowDown' && !expanded) {
      event.preventDefault();
      setSuggestOpen(true);
      return;
    }

    if (!expanded) return;

    if (event.key === 'ArrowDown') {
      event.preventDefault();
      setActive((active + 1) % matches.length);
      return;
    }

    if (event.key === 'ArrowUp') {
      event.preventDefault();
      setActive((active - 1 + matches.length) % matches.length);
      return;
    }

    if (event.key === 'Enter') {
      event.preventDefault();
      choose(matches[active]);
      return;
    }

    if (event.key === 'Escape') {
      event.preventDefault();
      setSuggestOpen(false);
    }
  };

  const changeDates = (next: [string, string]) => {
    setDates(next);
    setErrors({ ...errors, dates: undefined });
  };

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const next: typeof errors = {};

    if (!destination) next.destination = 'Choose a destination from the list.';
    if (!dates[0] || !dates[1]) next.dates = 'Choose departure and return dates.';

    setErrors(next);

    if (!destination) {
      focusField(form, 'to');
      return;
    }

    if (next.dates) {
      focusField(form, 'dates');
      return;
    }

    setSearch({ destination, dates, travellers: adults + children, label: formatTravellers(adults, children) });
    setFilters(defaultFilters);
  };

  const resetFilters = () => setFilters(defaultFilters);

  return (
    <div className='travel' style={{ display: 'grid', gap: 'var(--spacing-16)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <Card asChild style={{ alignItems: 'stretch', gap: 'var(--spacing-10)', minWidth: 0 }}>
        <form noValidate role='search' aria-labelledby={`${id}-title`} onSubmit={submit}>
          <Heading id={`${id}-title`} as='h1' size={6} fontWeight={700}>Find flights</Heading>
          <div className='travelSearchRow'>
            <div style={field}>
              <Label htmlFor={`${id}-from`}>From</Label>
              <TextField size='xs'>
                <TextFieldInput id={`${id}-from`} name='from' value={`${ORIGIN.city} (${ORIGIN.code})`} readOnly />
              </TextField>
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-to`}>To</Label>
              <Popover placement='bottom-start' open={expanded} onChangeOpen={setSuggestOpen}>
                <PopoverTrigger asChild>
                  <TextField size='xs' error={errors.destination !== undefined}>
                    <TextFieldInput
                      id={`${id}-to`}
                      name='to'
                      role='combobox'
                      autoComplete='off'
                      spellCheck={false}
                      placeholder='City or airport'
                      value={query}
                      aria-autocomplete='list'
                      aria-expanded={expanded}
                      aria-controls={expanded ? listId : undefined}
                      aria-activedescendant={expanded ? `${listId}-${matches[active].code}` : undefined}
                      aria-invalid={errors.destination !== undefined}
                      aria-describedby={errors.destination ? `${id}-to-error` : undefined}
                      onChange={(event) => changeQuery(event.currentTarget.value)}
                      onKeyDown={onQueryKeyDown}
                    />
                  </TextField>
                </PopoverTrigger>
                <PopoverContent className='travelSuggestions'>
                  <div role='listbox' id={listId} aria-label='Destinations'>
                    {matches.map((item, index) => (
                      <Option
                        key={item.code}
                        id={`${listId}-${item.code}`}
                        size='sm'
                        active={index === active}
                        selected={destination?.code === item.code}
                        onMouseDown={(event) => event.preventDefault()}
                        onPointerMove={() => setActive(index)}
                        onClick={() => choose(item)}
                        style={{ gap: 'var(--spacing-6)', '--option-height': 'var(--spacing-22)' } as CSSProperties}
                      >
                        <Icon size='sm' aria-hidden='true' style={{ flexShrink: 0, color: 'var(--label-secondary)' }}><MapPin /></Icon>
                        <span style={{ display: 'grid', flex: 1, minWidth: 0 }}>
                          <Text size={3}>{item.city}</Text>
                          <Text size={1} style={secondary}>{item.country}</Text>
                        </span>
                        <Text size={2} style={{ ...secondary, ...tabular }}>{item.code}</Text>
                      </Option>
                    ))}
                  </div>
                </PopoverContent>
              </Popover>
              {errors.destination && <HelperText id={`${id}-to-error`} error>{errors.destination}</HelperText>}
            </div>
            <div style={field}>
              <Label htmlFor={`${id}-dates`}>Dates</Label>
              <DateRangeField
                size='xs'
                format='MM/dd/yyyy'
                separator='/'
                min='2026-11-04'
                error={errors.dates !== undefined}
                value={dates}
                onChangeValue={changeDates}
              >
                <DateRangeFieldInput
                  id={`${id}-dates`}
                  name='dates'
                  placeholder='mm/dd/yyyy – mm/dd/yyyy'
                  aria-invalid={errors.dates !== undefined}
                  aria-describedby={errors.dates ? `${id}-dates-error` : undefined}
                  style={tabular}
                />
                <Popover placement='bottom-end'>
                  <PopoverTrigger asChild>
                    <DateRangeFieldTriggerIconButton aria-label='Choose travel dates' />
                  </PopoverTrigger>
                  <PopoverContent>
                    <DateRangeFieldCalendar>
                      <CalendarHeader>
                        <CalendarPrevButton aria-label='Previous month' />
                        <CalendarViewButtonGroup>
                          <CalendarViewButton view='month'><CalendarMonthLabel /></CalendarViewButton>
                          <CalendarViewButton view='year'><CalendarYearLabel /></CalendarViewButton>
                        </CalendarViewButtonGroup>
                        <CalendarNextButton aria-label='Next month' />
                      </CalendarHeader>
                      <CalendarContent>
                        <CalendarDays>
                          <CalendarWeekdays>
                            <CalendarWeekday />
                          </CalendarWeekdays>
                          <CalendarWeeks>
                            <CalendarDay />
                          </CalendarWeeks>
                        </CalendarDays>
                        <CalendarMonths>
                          <CalendarMonth />
                        </CalendarMonths>
                        <CalendarYears>
                          <CalendarYear />
                        </CalendarYears>
                      </CalendarContent>
                    </DateRangeFieldCalendar>
                  </PopoverContent>
                </Popover>
              </DateRangeField>
              {errors.dates && <HelperText id={`${id}-dates-error`} error>{errors.dates}</HelperText>}
            </div>
            <div style={field}>
              <Label id={`${id}-travellers-label`}>Travellers</Label>
              <Popover role='dialog' placement='bottom-start' open={travellersOpen} onChangeOpen={setTravellersOpen}>
                <PopoverTrigger className='travelPicker' aria-labelledby={`${id}-travellers-label ${id}-travellers-value`}>
                  <Icon size='xs' aria-hidden='true' style={{ flexShrink: 0, color: 'var(--label-secondary)' }}><Users /></Icon>
                  <span id={`${id}-travellers-value`} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formatTravellers(adults, children)}</span>
                </PopoverTrigger>
                <PopoverContent aria-labelledby={`${id}-travellers-title`}>
                  <div style={{ display: 'grid', gap: 'var(--spacing-10)', width: 260 }}>
                    <Heading id={`${id}-travellers-title`} as='h2' size={3} fontWeight={600}>Travellers</Heading>
                    <div className='travelCounter'>
                      <span style={{ display: 'grid' }}>
                        <Label htmlFor={`${id}-adults`}>Adults</Label>
                        <Text id={`${id}-adults-hint`} size={1} style={secondary}>12 years and older</Text>
                      </span>
                      <NumberField size='xs' min={1} max={9} value={adults} onChangeValue={(next) => setAdults(next ?? 1)} style={{ width: 112 }}>
                        <NumberFieldDecrementButton aria-label='Remove an adult' />
                        <NumberFieldInput id={`${id}-adults`} aria-describedby={`${id}-adults-hint`} />
                        <NumberFieldIncrementButton aria-label='Add an adult' />
                      </NumberField>
                    </div>
                    <div className='travelCounter'>
                      <span style={{ display: 'grid' }}>
                        <Label htmlFor={`${id}-children`}>Children</Label>
                        <Text id={`${id}-children-hint`} size={1} style={secondary}>2 to 11 years</Text>
                      </span>
                      <NumberField size='xs' min={0} max={8} value={children} onChangeValue={(next) => setChildren(next ?? 0)} style={{ width: 112 }}>
                        <NumberFieldDecrementButton aria-label='Remove a child' />
                        <NumberFieldInput id={`${id}-children`} aria-describedby={`${id}-children-hint`} />
                        <NumberFieldIncrementButton aria-label='Add a child' />
                      </NumberField>
                    </div>
                    <Button size='xs' onClick={() => setTravellersOpen(false)} style={{ justifySelf: 'end' }}>Done</Button>
                  </div>
                </PopoverContent>
              </Popover>
            </div>
            <Button size='xs' type='submit' className='travelSubmit'>Search</Button>
          </div>
        </form>
      </Card>

      <div className='travelResults'>
        <aside aria-labelledby={`${id}-filters`} className='travelFilters'>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${id}-filters`} as='h2' size={4} fontWeight={600}>Filters</Heading>
            <Button size='xs' variant='secondary' appearance='ghost' disabled={activeFilters === 0} onClick={resetFilters}>Reset</Button>
          </div>
          <FlightFilters prefix={`${id}-aside`} filters={filters} onChange={setFilters} />
        </aside>

        <section aria-labelledby={`${id}-results`} style={{ display: 'grid', alignContent: 'start', gap: 'var(--spacing-8)', minWidth: 0 }}>
          <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--spacing-6) var(--spacing-8)' }}>
            <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
              <Heading id={`${id}-results`} as='h2' size={5} fontWeight={600} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-3)' }}>
                {ORIGIN.city}
                <Icon size='sm' aria-hidden='true' className='travelArrow'><ArrowRight /></Icon>
                <VisuallyHidden>to</VisuallyHidden>
                {search.destination.city}
              </Heading>
              <Text size={2} role='status' style={{ ...secondary, fontVariantNumeric: 'tabular-nums' }}>
                {`${formatRange(search.dates)} · ${search.label} · ${visible.length} of ${flights.length} flights`}
              </Text>
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <Button size='xs' variant='secondary' appearance='ghost' className='travelFiltersTrigger' onClick={() => setFiltersOpen(true)} style={{ gap: 'var(--spacing-3)' }}>
                <Icon size='xs' aria-hidden='true'><SlidersHorizontal /></Icon>
                {activeFilters > 0 ? `Filters · ${activeFilters}` : 'Filters'}
              </Button>
              <Select size='xs' value={sort} onChangeValue={(next) => setSort((next ?? 'best') as Sort)}>
                <SelectCombobox aria-label='Sort flights' style={{ minWidth: 150 }}>
                  <SelectValue />
                  <SelectIcon />
                </SelectCombobox>
                <SelectListbox>
                  {sorts.map((item) => (
                    <SelectOption key={item.value} value={item.value}>{item.label}</SelectOption>
                  ))}
                </SelectListbox>
              </Select>
            </div>
          </div>

          {visible.length === 0 && (
            <div style={{ display: 'grid', justifyItems: 'start', gap: 'var(--spacing-6)', padding: 'var(--spacing-12)', borderRadius: 'var(--radius-4)', background: 'var(--fills-quaternary)' }}>
              <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
                <Text size={3} fontWeight={500}>No flights match your filters</Text>
                <Text size={2} style={secondary}>Widen the price range, allow more stops or include more airlines.</Text>
              </div>
              <Button size='xs' variant='secondary' appearance='ghost' onClick={resetFilters}>Reset filters</Button>
            </div>
          )}

          {visible.length > 0 && (
            <ul aria-label='Flights' style={{ display: 'grid', gap: 'var(--spacing-6)', margin: 0, padding: 0, listStyle: 'none' }}>
              {visible.map((flight) => (
                <li key={flight.id}>
                  <Card size='sm' className='travelFlight'>
                    <div className='travelFlightMain'>
                      <span aria-hidden='true' className='travelAirline'><Icon size='sm'><Plane /></Icon></span>
                      <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
                        <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-2) var(--spacing-4)' }}>
                          <Text size={2} style={secondary}>{flight.airline}</Text>
                          {flight === recommended && <Badge size='xs' variant='info'>Best</Badge>}
                          {flight === cheapest && <Badge size='xs' variant='success'>Cheapest</Badge>}
                        </div>
                        <Text size={5} fontWeight={600} style={tabular}>{`${flight.depart} – ${flight.arrive}`}</Text>
                        <Text size={2} style={{ ...secondary, fontVariantNumeric: 'tabular-nums' }}>
                          {`${ORIGIN.code} – ${search.destination.code} · ${formatDuration(flight.duration)} · ${formatStops(flight)}`}
                        </Text>
                      </div>
                    </div>
                    <div className='travelFlightPrice'>
                      <span style={{ display: 'grid', justifyItems: 'end' }}>
                        <Text size={5} fontWeight={700} style={tabular}>{currency.format(flight.price)}</Text>
                        <Text size={1} style={{ ...secondary, ...tabular }}>
                          {search.travellers > 1 ? `${currency.format(flight.price * search.travellers)} total` : 'per traveller'}
                        </Text>
                      </span>
                      <Button size='xs' variant='secondary' appearance='ghost' aria-label={`Select ${flight.airline} ${flight.depart} flight`}>Select</Button>
                    </div>
                  </Card>
                </li>
              ))}
            </ul>
          )}
        </section>
      </div>

      <Sheet open={filtersOpen} onChangeOpen={setFiltersOpen}>
        <SheetContent aria-labelledby={`${id}-sheet-title`}>
          <SheetHandle aria-label='Resize sheet' />
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)', marginBlockEnd: 'var(--spacing-10)' }}>
            <Heading id={`${id}-sheet-title`} as='h2' size={4} fontWeight={600}>Filters</Heading>
            <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <Button size='xs' variant='secondary' appearance='ghost' disabled={activeFilters === 0} onClick={resetFilters}>Reset</Button>
              <SheetClose asChild>
                <IconButton size='xs' aria-label='Close' variant='secondary' appearance='ghost' style={closeButton}>
                  <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
                </IconButton>
              </SheetClose>
            </div>
          </div>
          <FlightFilters prefix={`${id}-sheet`} filters={filters} onChange={setFilters} />
          <Separator style={{ marginBlock: 'var(--spacing-10)' }} />
          <SheetClose asChild>
            <Button size='sm' fullWidth style={tabular}>{visible.length === 0 ? 'No flights to show' : `Show ${visible.length} ${visible.length === 1 ? 'flight' : 'flights'}`}</Button>
          </SheetClose>
        </SheetContent>
      </Sheet>
    </div>
  );
}

Components used