# Filter Sidebar

> Results with their filters in a sidebar: Sidebar as="aside" turns the navigation into a complementary region for tools beside the content.

Pattern · Pro kit · [Live page](https://ui.twentyfirst.media/pro/patterns/FilterSidebarScreen/)

Results with their filters in a sidebar: `Sidebar as="aside"` turns the navigation into a
complementary region for tools beside the content.

- Items with `checked` and `onCheckedChange` are filter options with their `count`; in a group with
  `select: "single"` they are radios (one choice).
- `content` puts anything into a group: a `RangeSlider`, date fields, chips.
- Groups stay collapsible; `collapsible` (default false for an aside) hides the icons-only mode.
- On phones the filters open as a drawer behind a filter button.

## Code

```tsx
import * as React from "react";
import {
  Badge,
  Button,
  Card,
  Chip,
  PageHeader,
  RangeSlider,
  SearchField,
  Sidebar,
  Stagger,
  useFormat,
} from "@twentyfirstmedia/pro";
import type { SidebarGroup } from "@twentyfirstmedia/pro";

const TYPES = [
  ["Apartment", 128],
  ["House", 46],
  ["Office", 19],
  ["Plot", 7],
] as const;
const ROOMS = ["Any", "1+", "2+", "3+", "4+"];
const FEATURES = ["Balcony", "Lift", "Parking", "Garden", "New build"];

const homes = [
  { title: "Bright loft by the canal", type: "Apartment", rooms: 3, price: 640000, tag: "New" },
  { title: "Townhouse with garden", type: "House", rooms: 5, price: 1180000, tag: "Garden" },
  { title: "Corner flat, top floor", type: "Apartment", rooms: 2, price: 420000, tag: "Lift" },
  { title: "Studio near the park", type: "Apartment", rooms: 1, price: 289000, tag: "Balcony" },
];

/** A results list with filters in the sidebar: options with counts, a range and one-choice groups. */
export default function Preview() {
  const f = useFormat();
  const [types, setTypes] = React.useState<string[]>(["Apartment"]);
  const [rooms, setRooms] = React.useState("2+");
  const [features, setFeatures] = React.useState<string[]>([]);
  const [price, setPrice] = React.useState<[number, number]>([250000, 900000]);
  const toggle = (list: string[], set: (v: string[]) => void, v: string) => (on: boolean) =>
    set(on ? [...list, v] : list.filter((x) => x !== v));
  const reset = () => {
    setTypes([]);
    setRooms("Any");
    setFeatures([]);
    setPrice([0, 1500000]);
  };

  const groups: SidebarGroup[] = [
    {
      label: "Type",
      items: TYPES.map(([t, n]) => ({
        id: t,
        label: t,
        count: n,
        checked: types.includes(t),
        onCheckedChange: toggle(types, setTypes, t),
      })),
    },
    {
      label: "Price",
      content: (
        <RangeSlider
          label="Price range"
          min={0}
          max={1500000}
          step={10000}
          value={price}
          onChange={setPrice}
          unit="€"
        />
      ),
    },
    {
      label: "Rooms",
      select: "single",
      items: ROOMS.map((r) => ({
        id: `rooms-${r}`,
        label: r,
        checked: rooms === r,
        onCheckedChange: () => setRooms(r),
      })),
    },
    {
      label: "Features",
      collapsible: true,
      items: FEATURES.map((x) => ({
        id: x,
        label: x,
        checked: features.includes(x),
        onCheckedChange: toggle(features, setFeatures, x),
      })),
    },
  ];
  const active = types.length + features.length + (rooms === "Any" ? 0 : 1);

  return (
    <div
      style={{
        display: "flex",
        minHeight: 720,
        overflow: "hidden",
        borderRadius: "var(--radius-lg)",
        border: "1px solid var(--line)",
        background: "var(--canvas)",
      }}
    >
      <Sidebar
        as="aside"
        variant="attached"
        label="Filters"
        groups={groups}
        header={
          <div className="tf-row" style={{ justifyContent: "space-between", width: "100%" }}>
            <b>Filters</b>
            {active > 0 && <Badge>{active}</Badge>}
          </div>
        }
        footer={
          <Button variant="secondary" icon="refresh" onClick={reset}>
            Reset filters
          </Button>
        }
      />
      <main className="tf-col" style={{ flex: 1, minWidth: 0, gap: 20, padding: 24 }}>
        <PageHeader
          title="Homes for sale"
          subtitle={`${f.formatNumber(200)} results`}
          actions={[<SearchField key="s" placeholder="Search area or street" />]}
        />
        <div className="tf-row" style={{ gap: 8, flexWrap: "wrap" }}>
          {[...types, ...features].map((x) => (
            <Chip
              key={x}
              selected
              icon="x"
              onClick={() => {
                setTypes(types.filter((t) => t !== x));
                setFeatures(features.filter((t) => t !== x));
              }}
            >
              {x}
            </Chip>
          ))}
          {rooms !== "Any" && (
            <Chip selected icon="x" onClick={() => setRooms("Any")}>
              {`${rooms} rooms`}
            </Chip>
          )}
        </div>
        <Stagger className="tf-col" style={{ gap: 12, alignContent: "start" }}>
          {homes.map((h) => (
            <Card key={h.title} style={{ minHeight: 0 }}>
              <div className="tf-row" style={{ justifyContent: "space-between", gap: 12 }}>
                <div className="tf-col" style={{ gap: 4, minWidth: 0 }}>
                  <b>{h.title}</b>
                  <small className="tf-muted">{`${h.type} · ${h.rooms} rooms`}</small>
                </div>
                <div className="tf-col" style={{ gap: 4, alignItems: "flex-end" }}>
                  <b>{f.formatMoney(h.price, null, { maximumFractionDigits: 0 })}</b>
                  <Badge>{h.tag}</Badge>
                </div>
              </div>
            </Card>
          ))}
        </Stagger>
      </main>
    </div>
  );
}
```

## Props

### Sidebar

| Prop | Type | Notes |
| --- | --- | --- |
| `groups` | `SidebarGroup[]` | Required |
| `current?` | `string` | Id (or label) of the current page. |
| `onPick?` | `(id: string) => void` |  |
| `variant?` | `"floating" \| "attached" \| "shell"` | `floating` (default): a card with rounded corners and a shadow. `attached`: full height, flush with the edge of the screen, divided by a line. `shell`: on the dark frame of `AppShell`. |
| `collapsed?` | `boolean` | Icons only. |
| `defaultCollapsed?` | `boolean` |  |
| `onCollapsedChange?` | `(collapsed: boolean) => void` |  |
| `storageKey?` | `string` | Remember the collapsed state in the browser under this key. |
| `shortcut?` | `boolean` | ⌘B (Ctrl+B) collapses and expands the sidebar, except while typing in a field. |
| `linkAs?` | `ElementType` | The element items with an `href` render as (default `a`), e.g. Next.js `Link`. |
| `mobileOpen?` | `boolean` | On phones (up to 760px wide): whether the drawer is open (controlled). |
| `onMobileOpenChange?` | `(open: boolean) => void` |  |
| `header?` | `ReactNode \| ((state: { collapsed: boolean }) => ReactNode)` | Top of the sidebar (brand, `WorkspaceSwitcher`); a function gets the collapsed state. |
| `footer?` | `ReactNode \| ((state: { collapsed: boolean }) => ReactNode)` | Bottom of the sidebar (`UserMenu`, settings); a function gets the collapsed state. |
| `as?` | `"nav" \| "aside"` | `nav` (default) for app navigation. `aside` for filters, settings or other tools beside the content: a complementary region instead of a navigation landmark. |
| `collapsible?` | `boolean` | Offers the collapse button and the icons-only mode (default true; filters usually set false). |
| `label?` | `string` | Accessible name of the navigation (or the aside). |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### RangeSlider

| Prop | Type | Notes |
| --- | --- | --- |
| `label?` | `ReactNode` |  |
| `min?` | `number` |  |
| `max?` | `number` |  |
| `step?` | `number` |  |
| `value?` | `[number, number]` |  |
| `defaultValue?` | `[number, number]` |  |
| `onChange?` | `(v: [number, number]) => void` |  |
| `unit?` | `string` |  |
| `inputs?` | `boolean` |  |
| `openEnd?` | `boolean` |  |
| `format?` | `(v: number) => string` |  |
| `hint?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Badge

| Prop | Type | Notes |
| --- | --- | --- |
| `tone?` | `Tone` |  |
| `dot?` | `boolean` |  |
| `live?` | `boolean` |  |
| `children?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Button

| Prop | Type | Notes |
| --- | --- | --- |
| `variant?` | `"primary" \| "secondary" \| "soft" \| "ghost" \| "danger" \| "danger-solid"` |  |
| `size?` | `"md" \| "sm"` |  |
| `icon?` | `IconName` |  |
| `iconRight?` | `IconName` |  |
| `disabled?` | `boolean` |  |
| `type?` | `"button" \| "submit" \| "reset"` |  |
| `onClick?` | `(e: MouseEvent<HTMLButtonElement>) => void` |  |
| `children?` | `ReactNode` |  |
| `asChild?` | `boolean` | Render the single child element (a link, a router Link) with the button's look instead of a <button>. |
| `id?` | `string` |  |
| `name?` | `string` | Form field name and value, sent with the form when this button submits it. |
| `value?` | `string` |  |
| `form?` | `string` | The id of the form this button belongs to, when it sits outside it. |
| `title?` | `string` |  |
| `tabIndex?` | `number` |  |
| `autoFocus?` | `boolean` |  |
| `aria-label?` | `string` |  |
| `aria-labelledby?` | `string` |  |
| `aria-describedby?` | `string` |  |
| `aria-expanded?` | `boolean \| "true" \| "false"` | For disclosure and menu triggers: whether what the button controls is open. |
| `aria-controls?` | `string` |  |
| `aria-haspopup?` | `boolean \| "true" \| "false" \| "menu" \| "listbox" \| "tree" \| "grid" \| "dialog"` |  |
| `aria-pressed?` | `boolean \| "true" \| "false" \| "mixed"` | For toggle buttons. |
| `aria-current?` | `boolean \| "true" \| "false" \| "page" \| "step" \| "location" \| "date" \| "time"` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Card

| Prop | Type | Notes |
| --- | --- | --- |
| `title?` | `string` |  |
| `description?` | `string` |  |
| `meta?` | `string[]` |  |
| `corner?` | `ReactNode` |  |
| `status?` | `ReactNode` |  |
| `actions?` | `ReactNode[]` |  |
| `tint?` | `Tint \| "surface"` |  |
| `onClick?` | `() => void` |  |
| `children?` | `ReactNode` |  |
| `eyebrow?` | `ReactNode` |  |
| `surface?` | `Surface` | `"glass"`: translucent glass over what is behind (maps, 3D, video, photos); `"solid"` opts out inside a glass `UIProvider`. |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Chip

| Prop | Type | Notes |
| --- | --- | --- |
| `selected?` | `boolean` |  |
| `icon?` | `IconName` |  |
| `count?` | `number` |  |
| `onClick?` | `(e: MouseEvent<HTMLButtonElement>) => void` |  |
| `children?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### PageHeader

| Prop | Type | Notes |
| --- | --- | --- |
| `crumbs?` | `(string \| Crumb)[]` | A breadcrumb above the title (see `Breadcrumb`): labels, or items with `href`/`onClick`. |
| `crumbLinkAs?` | `ElementType` | The element crumb links render as, e.g. Next.js `Link`. |
| `title` | `string` | Required |
| `subtitle?` | `string` |  |
| `actions?` | `ReactNode[]` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### SearchField

| Prop | Type | Notes |
| --- | --- | --- |
| `placeholder?` | `string` |  |
| `label?` | `string` | Accessible name (default: the placeholder, or "Search"). |
| `shortcut?` | `string` | The key hint, e.g. "⌘K" (hidden on touch screens). |
| `bindShortcut?` | `boolean` | ⌘/Ctrl + the hint's last key (K for "⌘K") focuses the field, except while typing elsewhere. |
| `value?` | `string` |  |
| `defaultValue?` | `string` |  |
| `onChange?` | `(e: ChangeEvent<HTMLInputElement>) => void` |  |
| `onKeyDown?` | `(e: KeyboardEvent<HTMLInputElement>) => void` |  |
| `onFocus?` | `(e: FocusEvent<HTMLInputElement>) => void` |  |
| `onBlur?` | `(e: FocusEvent<HTMLInputElement>) => void` |  |
| `id?` | `string` |  |
| `name?` | `string` |  |
| `autoFocus?` | `boolean` |  |
| `inputProps?` | `InputHTMLAttributes<HTMLInputElement>` | For a search with suggestions (combobox): role, aria-controls, aria-activedescendant … |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Stagger

| Prop | Type | Notes |
| --- | --- | --- |
| `as?` | `string` |  |
| `className?` | `string` |  |
| `style?` | `CSSProperties` |  |
| `delay?` | `number` | Extra delay before the first child, in ms. |
| `reveal?` | `boolean` | Below the fold, wait until the group scrolls into view (default true). |
| `children?` | `ReactNode` |  |
