Segmented Filter
Counted, mutually exclusive slice picker with radiogroup semantics.
Segmented Filter picks one slice of a collection that is already on screen, and may show a count beside each label. Selection applies immediately. It is a radiogroup, not tabs — a filtered single list has no tabpanel. On a list page, pass it through Query List segmentedFilter so it mounts in the toolbar tabs slot and replaces a status-enum KPI band.
When To Use
- The choices are mutually exclusive, one is always active, and they slice the same collection (same object type, same columns).
- Selection should apply immediately, with no Save step.
- A list page's primary move is switching a status or lifecycle enum with counts — use Query List
segmentedFilterinstead of a clickable Statistic band. - Keep two to five segments. Labels are nouns in sentence case (
Failed, notShow failed).
When Not To Use
- Multi-select, or one dimension among several — put counts on filter values inside Query Filter.
- Distinct content areas or object types — use real tabs with panels.
- Initiating an action — use a Button.
- Highlighting a figure — use Statistic, and never make the figure clickable.
Counts Are Facet-Scoped
A segment's count is scoped by every applied filter except this control's own dimension. Otherwise Failed could only ever read its own slice. The component does not fetch — it renders the numbers the host passes. Do not reuse a KPI / summary query that already includes this dimension.
Features
| Area | Behavior |
|---|---|
| Selection | Single-select, controlled. One value is always selected; clicking the active segment is a no-op. |
| Full collection | When the control drives a filter, include a full-list segment (All). That is where the unfiltered total lives. |
| Counts | Optional per segment. Rendered as muted tabular-nums text beside the label. A count of 0 renders as 0. |
| Ceiling | Two to five segments. Past five, the host moves the tail into a dropdown — the kit has no built-in overflow. Worked example: Message Popover (severities stay in the group; channels go in a menu). |
| List page | Query List segmentedFilter mounts this control in the toolbar tabs slot and replaces a status-enum stats band. |
| Labels | Nouns, sentence case. |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/segmented-filter.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/segmented-filter.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/segmented-filter.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/segmented-filter.jsonWith a namespace: npx shadcn@latest add @f-ui/segmented-filter.
registryDependencies: utils, shadcn toggle-group.
Usage
import { SegmentedFilter } from "@/components/f-ui/segmented-filter/segmented-filter";
<SegmentedFilter
value={status}
onValueChange={setStatus}
aria-label="Filter by status"
segments={[
{ value: "all", label: "All", count: 24 },
{ value: "pending", label: "Pending", count: 6 },
{ value: "ready", label: "Ready", count: 16 },
{ value: "failed", label: "Failed", count: 2 },
]}
/>On a resource index, prefer the Query List config so the kit owns param writes and drops a duplicate Query Filter field:
<QueryList
schema={schema}
listCode="orders"
adapter={adapter}
params={params}
onParamsChange={onParamsChange}
getRowId={(row) => row.id}
segmentedFilter={{
paramKey: "status",
label: "Filter by status",
segments: [
{ value: "all", label: "All", count: totals.all },
{ value: "pending", label: "Pending", count: totals.pending },
{ value: "ready", label: "Ready", count: totals.ready },
{ value: "failed", label: "Failed", count: totals.failed },
],
}}
/>allValue defaults to "all". Selecting that segment clears params[paramKey] with null and resets page to 1. Do not also expose the same paramKey as a Query Filter field — the kit drops any matching field so there is one owner per param.
Examples
Status Filter
A four-segment status strip with facet counts. Click a slice — the selection stays on one value; the counts sit beside the labels as muted tabular figures.
"use client";
import { useState } from "react";
import { SegmentedFilter } from "@/components/f-ui/segmented-filter/segmented-filter";
const segments = [
{ value: "all", label: "All", count: 24 },
{ value: "pending", label: "Pending", count: 6 },
{ value: "ready", label: "Ready", count: 16 },
{ value: "failed", label: "Failed", count: 2 },
];
export function SegmentedFilterDemo() {
const [value, setValue] = useState("all");
return (
<SegmentedFilter
value={value}
onValueChange={setValue}
segments={segments}
aria-label="Filter by status"
/>
);
}Composition
SegmentedFilter (radiogroup)
└── segment × n (radio)
├── label
└── count? (muted tabular-nums)Accessibility
The root is a radiogroup, and each segment is a radio. A tab must own a tabpanel; a filtered single list has none, so this control must not ship role="tab".
aria-label is required. The radios are otherwise an unlabelled group. Hosts localize that name (Filter by status, Filter by severity).
API Reference
Props
SegmentedFilter
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Selected segment. Always one value. |
onValueChange | (value: string) => void | — | Fires with the next segment. Empty (deselect) values are ignored. |
segments | ReadonlyArray<SegmentedFilterSegment> | — | Each item has value, label, and optional count. |
aria-label | string | — | Required accessible name for the radiogroup. |
disabled | boolean | false | Disables every segment. |
className | string | — | Merged onto the root. |
SegmentedFilterSegment
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Segment id written to the controlled value. |
label | ReactNode | — | Noun label shown beside the optional count. |
count | number | — | Facet-scoped count. 0 renders as 0. Omit to hide the figure. |