Sort menu
A pill that opens a menu with a radio group for the sort field and one for direction.
npx shadcn@latest add https://ui.goodtabs.co/r/sort-menu.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/sort-menu
Three fields and the direction below a separator. The trigger shows the current field.
Icon only
Only the arrows icon; the field and direction become the accessible name.
In a toolbar
Next to a search input, aligned to the end so the menu stays inside the row.
Usage
import { SortMenu } from "@/components/ui/sort-menu"
const [sort, setSort] = useState({ field: "name", direction: "asc" })
<SortMenu
fields={[{ value: "name", label: "Name" }, { value: "added", label: "Date added" }]}
value={sort}
onValueChange={setSort}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| fields | { value: string; label: string }[] | The fields people can sort by, in menu order. | |
| value | { field: string; direction: "asc" | "desc" } | The current field and direction. The component is controlled. | |
| onValueChange | (value: SortValue) => void | Called with the new field and direction whenever either changes. | |
| label | string | "Sort" | Word before the field name in the trigger. |
| iconOnly | boolean | false | Hide the text and show only the icon, with the label as the accessible name. |
| align | "start" | "end" | "start" | Which side of the trigger the menu aligns to. |
| size | Button size | "sm" | Trigger height: sm is 28px, default is 32px. Any other Button prop applies too. |
Source
sort-menu/sort-menu.tsx
"use client";
import * as React from "react";
import { ArrowUpDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type SortField = {
/** The key reported in onValueChange. */
value: string;
/** Menu item text, also shown in the trigger. */
label: string;
};
export type SortDirection = "asc" | "desc";
export type SortValue = {
field: string;
direction: SortDirection;
};
type SortMenuProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange" | "children"> & {
/** The fields people can sort by, in menu order. */
fields: SortField[];
/** The current field and direction. */
value: SortValue;
/** Called with the new field and direction whenever either changes. */
onValueChange: (value: SortValue) => void;
/** Word before the field name in the trigger. */
label?: string;
/** Hide the text and show only the icon. The label becomes the accessible name. */
iconOnly?: boolean;
/** Which side of the trigger the menu aligns to. */
align?: "start" | "end";
};
const itemClass = "h-8 rounded-sm focus:bg-muted focus:text-foreground";
const directions: { value: SortDirection; label: string }[] = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
/**
* A sort control: an outline pill that opens a menu with one radio group for the field
* and one for the direction. Controlled: pass value and onValueChange.
*/
export function SortMenu({
fields,
value,
onValueChange,
label = "Sort",
iconOnly = false,
align = "start",
variant = "outline",
size = "sm",
className,
...props
}: SortMenuProps) {
const current = fields.find((f) => f.value === value.field);
const direction = directions.find((d) => d.value === value.direction)?.label ?? "";
const name = `${label}: ${current?.label ?? ""}`;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={iconOnly ? (size === "sm" ? "icon-sm" : "icon") : size}
aria-label={iconOnly ? `${name}, ${direction.toLowerCase()}` : undefined}
className={cn("rounded-full", className)}
{...props}
>
<ArrowUpDownIcon data-icon={iconOnly ? undefined : "inline-start"} aria-hidden="true" />
{!iconOnly && (
<span>
{name}
<span className="sr-only">, {direction.toLowerCase()}</span>
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="min-w-44 rounded-md">
<DropdownMenuRadioGroup
value={value.field}
onValueChange={(field) => onValueChange({ ...value, field })}
aria-label="Sort by"
>
{fields.map((field) => (
<DropdownMenuRadioItem key={field.value} value={field.value} className={itemClass}>
{field.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={value.direction}
onValueChange={(d) => onValueChange({ ...value, direction: d as SortDirection })}
aria-label="Direction"
>
{directions.map((d) => (
<DropdownMenuRadioItem key={d.value} value={d.value} className={itemClass}>
{d.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
For AI agents
Hand your agent this page, the markdown at /components/sort-menu/prompt.md, or the Copy AI prompt button above.
When to use
- Above a list, table or grid that people can reorder by more than one field
- In a toolbar next to search and filter controls
- When direction matters as much as the field, so both are one choice away
When not to use
- For a single toggle between two orders, use a button that flips
- For column sorting in a table, put the control in the column header
- When there is only one field to sort by
Agent notes
- Controlled only: keep { field, direction } in state and pass both value and onValueChange, so the parent must be a client component
- The trigger reads Sort: plus the label of the current field; the direction is read out to screen readers
- Pass align="end" when the trigger sits at the right edge of a toolbar
- It extends shadcn's Button, so variant and size apply; the default is an outline sm pill