Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
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) => voidCalled with the new field and direction whenever either changes.
labelstring"Sort"Word before the field name in the trigger.
iconOnlybooleanfalseHide 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.
sizeButton 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