Skip to main content
Urbicon UI
Back to Recipes

Filter Sidebar

A listings page whose filter rail is persistent on desktop and a slide-in overlay behind a funnel button on mobile, built on Sidebar mode="responsive". Search, property type, rent range, bedrooms and amenities narrow the result grid live, with no Apply step.

Built with Sidebar Input RadioGroup RadioItem Slider SegmentGroup SegmentItem Checkbox Card Button Badge

Live preview

ListingsPage.svelte

Filter from the rail and the grid follows; resize the window across 1024px and the rail becomes an overlay behind the funnel button.
10 homes

Sunlit loft near the canal

Apartment
Kreuzberg 2 beds
Balcony Pets allowed
€1,650 / month

Studio above the bakery

Studio
Neukölln Studio
Furnished
€890 / month

Altbau with herringbone floors

Apartment
Prenzlauer Berg 3 beds
Balcony Parking
€2,200 / month

Garden house on a quiet Hof

House
Pankow 4 beds
Parking Pets allowed Balcony
€2,650 / month

Compact flat by the U-Bahn

Apartment
Wedding 1 bed
Furnished
€1,180 / month

Furnished studio, all-inclusive

Studio
Mitte Studio
Furnished Balcony
€1,350 / month

Family house with a yard

House
Köpenick 4 beds
Parking Pets allowed
€2,400 / month

Bright two-room by the park

Apartment
Friedrichshain 2 beds
Balcony
€1,490 / month

Top-floor flat with elevator

Apartment
Charlottenburg 2 beds
Parking Furnished
€1,780 / month

Cozy pet-friendly single

Apartment
Neukölln 1 bed
Pets allowed
€990 / month
<script lang="ts">
  import {
    Sidebar, Input, RadioGroup, RadioItem, SegmentGroup, SegmentItem,
    Slider, Checkbox, Card, Badge, Button,
    FunnelIcon, SearchIcon, CloseIcon, BedIcon, MapPinIcon
  } from '@urbicon-ui/blocks';

  type ListingType = 'apartment' | 'house' | 'studio';
  interface Listing {
    id: string; title: string; neighborhood: string;
    type: ListingType; rent: number; beds: number; amenities: string[];
  }

  const RENT_MIN = 400;
  const RENT_MAX = 3000;
  const AMENITIES = [
    { id: 'balcony', label: 'Balcony' },
    { id: 'parking', label: 'Parking' },
    { id: 'pets', label: 'Pets allowed' },
    { id: 'furnished', label: 'Furnished' }
  ];
  const TYPE_LABEL: Record<ListingType, string> = {
    apartment: 'Apartment', house: 'House', studio: 'Studio'
  };

  const listings: Listing[] = [/* … your data … */];

  // Filter state — each control binds to one field.
  let filtersOpen = $state(false);   // mobile overlay only; desktop rail is always visible
  let search = $state('');
  let propertyType = $state('any');  // RadioGroup
  let rentRange = $state<[number, number]>([RENT_MIN, RENT_MAX]); // range Slider
  let bedrooms = $state('any');      // SegmentGroup — 'any' | '1' | '2' | '3' (3 = 3+)
  let amenities = $state<string[]>([]); // Checkbox group

  function toggleAmenity(id: string) {
    amenities = amenities.includes(id) ? amenities.filter((a) => a !== id) : [...amenities, id];
  }

  const activeCount = $derived(
    (search ? 1 : 0) +
      (propertyType !== 'any' ? 1 : 0) +
      (rentRange[0] > RENT_MIN || rentRange[1] < RENT_MAX ? 1 : 0) +
      (bedrooms !== 'any' ? 1 : 0) +
      amenities.length
  );

  // Live client-side filtering — recomputes on every control change, no Apply step.
  const results = $derived(
    listings.filter((l) => {
      if (search && !(l.title + ' ' + l.neighborhood).toLowerCase().includes(search.toLowerCase())) return false;
      if (propertyType !== 'any' && l.type !== propertyType) return false;
      if (l.rent < rentRange[0] || l.rent > rentRange[1]) return false;
      if (bedrooms !== 'any') {
        const n = Number(bedrooms);
        if (n === 3 ? l.beds < 3 : l.beds !== n) return false;
      }
      if (amenities.length && !amenities.every((a) => l.amenities.includes(a))) return false;
      return true;
    })
  );

  function reset() {
    search = '';
    propertyType = 'any';
    rentRange = [RENT_MIN, RENT_MAX];
    bedrooms = 'any';
    amenities = [];
  }

  function bedsLabel(beds: number): string {
    if (beds === 0) return 'Studio';
    return beds + ' bed' + (beds > 1 ? 's' : '');
  }

  function amenityLabel(id: string): string {
    return AMENITIES.find((a) => a.id === id)?.label ?? id;
  }
</script>

<!-- Page shell: the Sidebar is position: fixed — a persistent left rail on
     desktop (no backdrop, no focus-trap), a backdropped overlay below 1024px.
     The rail does not push its siblings, so the main region is offset by
     hand: lg:pl-72 matches width="18rem". -->
<div class="relative min-h-screen">
  <Sidebar bind:open={filtersOpen} mode="responsive" width="18rem">
    {#snippet header()}
      <div class="flex w-full items-center justify-between py-3">
        <span class="text-text-primary flex items-center gap-2 font-semibold">
          <FunnelIcon size={18} /> Filters
        </span>
        <!-- Close only exists on the mobile overlay; the desktop rail can't be dismissed. -->
        <Button
          variant="ghost"
          intent="neutral"
          size="xs"
          class="lg:hidden"
          onclick={() => (filtersOpen = false)}
          aria-label="Close filters"
        >
          <CloseIcon size={16} />
        </Button>
      </div>
    {/snippet}

    <div class="space-y-6 p-5">
      <Input placeholder="Search title or area" bind:value={search} clearable aria-label="Search listings">
        {#snippet leftIcon()}<SearchIcon size={16} />{/snippet}
      </Input>

      <RadioGroup label="Property type" bind:value={propertyType}>
        <RadioItem value="any" label="Any" />
        <RadioItem value="apartment" label="Apartment" />
        <RadioItem value="house" label="House" />
        <RadioItem value="studio" label="Studio" />
      </RadioGroup>

      <Slider
        label="Monthly rent"
        range
        min={RENT_MIN}
        max={RENT_MAX}
        step={50}
        bind:value={rentRange}
        showValue
        formatValue={(v) =>
          Array.isArray(v)
            ? '' + v[0].toLocaleString('en-US') + ' – €' + v[1].toLocaleString('en-US')
            : '' + v}
      />

      <div class="space-y-2">
        <span class="text-text-secondary text-sm font-medium">Bedrooms</span>
        <SegmentGroup bind:value={bedrooms} size="sm" ariaLabel="Bedrooms" fullWidth>
          <SegmentItem value="any">Any</SegmentItem>
          <SegmentItem value="1">1</SegmentItem>
          <SegmentItem value="2">2</SegmentItem>
          <SegmentItem value="3">3+</SegmentItem>
        </SegmentGroup>
      </div>

      <fieldset class="flex flex-col items-start gap-2.5">
        <legend class="text-text-secondary mb-1 text-sm font-medium">Amenities</legend>
        {#each AMENITIES as amenity (amenity.id)}
          <Checkbox
            label={amenity.label}
            checked={amenities.includes(amenity.id)}
            onCheckedChange={() => toggleAmenity(amenity.id)}
          />
        {/each}
      </fieldset>
    </div>

    {#snippet footer()}
      <div class="flex items-center gap-2 p-4">
        <Button
          variant="outlined"
          intent="neutral"
          class="flex-1"
          onclick={reset}
          disabled={activeCount === 0}
        >
          Reset{activeCount ? ' (' + activeCount + ')' : ''}
        </Button>
        <!-- Mobile-only "done" affordance — closes the overlay. Desktop filters live-apply. -->
        <Button intent="primary" class="flex-1 lg:hidden" onclick={() => (filtersOpen = false)}>
          Show {results.length}
        </Button>
      </div>
    {/snippet}
  </Sidebar>

  <!-- Main content — offset by the rail width on desktop, full-bleed on mobile. -->
  <div class="lg:pl-72">
    <!-- Mobile-only toolbar with the funnel trigger; the persistent rail needs none. -->
    <div class="border-border-hairline bg-surface-base/90 sticky top-0 z-[var(--z-sticky)] flex items-center gap-3 border-b px-5 py-3 backdrop-blur lg:hidden">
      <Button variant="outlined" intent="neutral" size="sm" onclick={() => (filtersOpen = true)}>
        <FunnelIcon size={16} /> Filters{activeCount ? ' (' + activeCount + ')' : ''}
      </Button>
      <span class="text-text-tertiary text-sm">{results.length} homes</span>
    </div>

    <div class="p-5">
      <div class="mb-4 hidden items-baseline justify-between lg:flex">
        <h2 class="text-text-primary text-lg font-semibold">{results.length} homes</h2>
        <span class="text-text-tertiary text-sm">Berlin · updated today</span>
      </div>

      {#if results.length === 0}
        <div class="border-border-default rounded-xl border border-dashed p-10 text-center">
          <p class="text-text-primary font-medium">No homes match these filters</p>
          <p class="text-text-tertiary mt-1 text-sm">Widen the rent range or clear a few amenities.</p>
          <Button class="mt-4" variant="outlined" intent="neutral" onclick={reset}>Reset filters</Button>
        </div>
      {:else}
        <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
          {#each results as listing (listing.id)}
            <Card variant="elevated" padding="sm" class="flex h-full flex-col gap-3">
              <div class="flex items-start justify-between gap-2">
                <h3 class="text-text-primary font-semibold">{listing.title}</h3>
                <Badge variant="soft" intent="neutral" size="sm">{TYPE_LABEL[listing.type]}</Badge>
              </div>
              <div class="text-text-tertiary flex items-center gap-3 text-sm">
                <span class="flex items-center gap-1"><MapPinIcon size={14} /> {listing.neighborhood}</span>
                <span class="flex items-center gap-1"><BedIcon size={14} /> {bedsLabel(listing.beds)}</span>
              </div>
              {#if listing.amenities.length}
                <div class="flex flex-wrap gap-1.5">
                  {#each listing.amenities as a (a)}
                    <Badge variant="soft" intent="neutral" size="sm">{amenityLabel(a)}</Badge>
                  {/each}
                </div>
              {/if}
              <div class="mt-auto flex items-baseline gap-1">
                <span class="text-text-primary text-lg font-bold">{listing.rent.toLocaleString('en-US')}</span>
                <span class="text-text-tertiary text-sm">/ month</span>
              </div>
            </Card>
          {/each}
        </div>
      {/if}
    </div>
  </div>
</div>

Why Sidebar, not Drawer

The panel is page shell

On desktop the filters are always present and the results reflow beside them: no backdrop, no focus-trap, nothing to dismiss. That is a Sidebar. A Drawer is a modal <dialog>; its backdrop and focus-trap are non-optional, which is wrong for a panel you want visible while you scan results.

One markup, two lifecycles

mode="responsive" renders the same panel as a persistent rail above 1024px and as a backdropped overlay below it; the open prop governs only the overlay, the desktop rail ignores it. Because the rail is never modal there is no Apply step: filtering is live, and the overlay's Show N button only closes the sheet; it commits nothing that was not already applied.

Offset the main content by hand

The rail is position: fixed, so the results column does not flow around it. Give the main region the rail's width as padding (lg:pl-72 matches width="18rem") and move the two together. For a ready-made shell that wires the offset and the mobile header, SidebarLayout does both.