Filter Panel
Card of grouped chips that folds away behind a round button on narrow screens.
From the Findit template — see it in contextExplore : Queens
Pick a type, a category and an area — each filter is a page of its own.
Type
Category
Location
"use client";
import { useState } from "react";
import { FilterPanel } from "@/components/ui/filter-panel";
const groups = {
Type: ["Apartment", "House", "Studio", "Loft"],
Category: ["For sale", "For rent", "New build"],
Location: ["Queens", "Brooklyn", "Harlem", "Bronx"],
};
export default function FilterPanelDemo() {
const [chosen, setChosen] = useState<Record<string, string>>({ Type: "House", Category: "For sale", Location: "Queens" });
return (
<FilterPanel
className="max-w-sm"
// Open from 768px so the preview shows the chips; narrow the window to see them fold away.
openFrom={768}
title="Explore : Queens"
description="Pick a type, a category and an area — each filter is a page of its own."
sticky={false}
groups={Object.entries(groups).map(([label, items]) => ({
label,
items: items.map((item) => ({
label: item,
active: chosen[label] === item,
onClick: () => setChosen((current) => ({ ...current, [label]: item })),
})),
}))}
/>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/filter-panel.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/filter-panel.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { motion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Filter Panel — a card of grouped chips that folds away on narrow screens (from the Findit "Misc/Property Nav"). * From `openFrom` up it is always open and can stick to the top while the list beside it scrolls; below that the * groups hide behind a round caret button between two rules, and open by height with Framer's soft spring * (bounce 0, 0.4s) while the content fades in just after. * * Improvement over Framer: Findit renders the chips twice (an always-open desktop copy and a folding one), which * puts every filter link in the page twice. Here they are rendered once and the same panel folds. */ const soft: Transition = { type: "spring", bounce: 0, duration: 0.4 }; /* Tailwind needs the whole class name in the source, so the breakpoints are spelled out. */ const stickyClasses = { 768: "md:sticky", 1024: "lg:sticky", 1280: "xl:sticky" } as const; export type FilterItem = { label: React.ReactNode; /** A filter that is its own page. */ href?: string; onClick?: () => void; active?: boolean; }; export type FilterGroup = { label: React.ReactNode; items: FilterItem[] }; export type FilterPanelProps = { groups: FilterGroup[]; title?: React.ReactNode; description?: React.ReactNode; /** Width from which the panel is always open. */ openFrom?: 768 | 1024 | 1280; /** Stick to the top of the window while the page scrolls (only where the panel is always open). */ sticky?: boolean; /** Distance from the top while stuck, in px. */ stickyTop?: number; showLabel?: string; hideLabel?: string; className?: string; }; /* True from `width` up. The server renders the open panel, so the chips are in the HTML for search engines. */ function useWideScreen(width: number) { const subscribe = React.useCallback( (onChange: () => void) => { const query = window.matchMedia(`(min-width: ${width}px)`); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }, [width], ); return React.useSyncExternalStore( subscribe, () => window.matchMedia(`(min-width: ${width}px)`).matches, () => true, ); } export function FilterPanel({ groups, title, description, openFrom = 1024, sticky = true, stickyTop = 120, showLabel = "Show filters", hideLabel = "Hide filters", className, }: FilterPanelProps) { const wide = useWideScreen(openFrom); const [expanded, setExpanded] = React.useState(false); const open = wide || expanded; const panelId = React.useId(); return ( <div style={sticky ? { top: stickyTop } : undefined} className={cn( "flex w-full flex-col gap-6 rounded-lg border bg-card p-5 text-card-foreground", sticky && stickyClasses[openFrom], className, )} > {(title || description) && ( <div className="flex flex-col gap-2"> {title && <h2 className="font-medium">{title}</h2>} {description && <p className="text-sm text-muted-foreground">{description}</p>} </div> )} {!wide && ( <div className="flex items-center gap-3"> <span aria-hidden className="h-px flex-1 bg-border" /> <button type="button" aria-expanded={expanded} aria-controls={panelId} aria-label={expanded ? hideLabel : showLabel} onClick={() => setExpanded((value) => !value)} className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-full bg-primary text-primary-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <motion.svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-4" initial={false} animate={{ rotate: expanded ? 180 : 0 }} transition={soft} > <path d="M4 6.5L8 10.5l4-4" /> </motion.svg> </button> <span aria-hidden className="h-px flex-1 bg-border" /> </div> )} <motion.div id={panelId} inert={!open} initial={false} animate={open ? "open" : "closed"} variants={{ open: { height: "auto", opacity: 1, transition: { height: soft, opacity: { duration: 0.25, delay: 0.1 } } }, closed: { height: 0, opacity: 0, transition: { height: soft, opacity: { duration: 0.15 } } }, }} className="overflow-hidden" > <div className="flex flex-col gap-6"> {groups.map((group, index) => ( <Group key={index} group={group} /> ))} </div> </motion.div> </div> ); } function Group({ group }: { group: FilterGroup }) { const id = React.useId(); return ( <div role="group" aria-labelledby={id} className="flex flex-col gap-3"> <h3 id={id} className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> {group.label} </h3> <div className="flex flex-wrap gap-2"> {group.items.map((item, index) => ( <Chip key={index} item={item} /> ))} </div> </div> ); } /* The colours ease over 0.4s, as in Framer. Hover only applies while the pointer is there, so a chip that stops being active goes straight back to its resting colours. */ function Chip({ item }: { item: FilterItem }) { const classes = cn( "cursor-pointer rounded-full px-4 py-1.5 text-sm font-medium transition-colors duration-400 ease-out outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", item.active ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground hover:bg-primary/80 hover:text-primary-foreground", ); if (item.href) { return ( <Link href={item.href} aria-current={item.active ? "page" : undefined} onClick={item.onClick} className={classes}> {item.label} </Link> ); } return ( <button type="button" aria-pressed={item.active} onClick={item.onClick} className={classes}> {item.label} </button> ); }
Usage
import { FilterPanel } from "@/components/ui/filter-panel";
<FilterPanel
title="Explore : Queens"
description="Pick a type, a category and an area."
groups={[
{ label: "Type", items: [{ label: "House", href: "/houses", active: true }, { label: "Studio", href: "/studios" }] },
{ label: "Category", items: [{ label: "For sale", href: "/for-sale" }] },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | { label, items: { label, href?, onClick?, active? }[] }[] | — | The groups of chips. A chip with `href` is a link (its own page), otherwise a button. |
| title | ReactNode | — | Heading at the top of the card. |
| description | ReactNode | — | One line under the heading. |
| openFrom | 768 | 1024 | 1280 | 1024 | Width from which the panel is always open. |
| sticky | boolean | true | Stick to the top while the list beside it scrolls, where the panel is always open. |
| stickyTop | number | 120 | Distance from the top while stuck, in px. |
| showLabel / hideLabel | string | — | Accessible names of the round button. |
- The chips are rendered once: the same panel folds, instead of a second copy for narrow screens, so filter links are not in the page twice.
- The server renders it open, so the chips are in the HTML for search engines; a closed panel is inert, so keyboards and screen readers skip it.
- Chip colours ease over 0.4 s, as in Framer.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called FilterPanel. - A card (rounded-lg, 1px border, bg-card, p-5) holding an optional title and description, then groups of chips. - Each group: a small uppercase muted heading (role group + aria-labelledby) and pill chips, text-sm font-medium, px-4 py-1.5: active bg-primary text-primary-foreground, otherwise bg-secondary with a hover that fills them, colours transitioning over 400ms ease-out. A chip with href is a Next.js Link with aria-current, otherwise a button with aria-pressed. - From `openFrom` (default 1024px) up the panel is always open and may stick to the top of the window. Below that, the groups collapse behind a round primary button holding a caret, centred between two hairlines; the caret rotates 180° when open. - The panel animates height 0 → auto with a spring (bounce 0, 0.4s) and fades its content in 0.25s after a 0.1s delay. - Render the chips once and fold that same panel; decide "wide" with useSyncExternalStore over matchMedia(min-width) whose server snapshot is true, so the server renders it open and a closed panel is inert. - Accessibility: aria-expanded and aria-controls on the button, an aria-label that says show or hide.











