Skip to contentVibraUI
Inputs & filters

Sort select

A sort key and its direction as one control: a select for the column, a button for the way.

Controlled only. The direction button's name states the direction in force ("Sort ascending" / "Sort descending"), so it never reads by icon alone, and it carries no aria-pressed: a toggle state beside a stateful name would announce "Sort descending, not pressed" — the opposite of what is happening. Changing the key keeps the direction and changing the direction keeps the key; either way the whole value is reported at once.

Install

npx shadcn@latest add @vibra/sort-select

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
options{ value: string; label: string }[]—The columns that can be sorted on.
value{ key: string; direction: "asc" | "desc" }—The column being sorted on and which way.
onValueChange(value: { key: string; direction: "asc" | "desc" }) => void—Called with the whole new sort from either control.
size"sm" | "default""default"sm drops both controls to h-7 for dense toolbars.
aria-labelstring"Sort by"Names the key select, e.g. Sort invoices by.
classNamestring—Merged onto the root, which holds the key select and the direction button; the remaining div props are spread onto it too.

Dependencies

Source

components/ui/sort-select.tsx
"use client"

import * as React from "react"
import { ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

export type SortDirection = "asc" | "desc"

export type SortValue = {
  key: string
  direction: SortDirection
}

// aria-label is the one div prop that does not reach the root: it names the key
// select inside, which is the control a reader actually operates.
export type SortSelectProps = Omit<React.ComponentProps<"div">, "aria-label"> & {
  options: { value: string; label: string }[]
  value: SortValue
  onValueChange: (value: SortValue) => void
  size?: "sm" | "default"
  /** Names the key select, e.g. "Sort orders by". */
  "aria-label"?: string
}

/** A sort key and its direction as one control: a select for the column, a button for the way. */
function SortSelect({
  options,
  value,
  onValueChange,
  size = "default",
  className,
  "aria-label": ariaLabel = "Sort by",
  ...props
}: SortSelectProps) {
  const ascending = value.direction === "asc"
  // A handful of entries rebuilt per render; memoising against a prop array that
  // is itself rebuilt every render would buy nothing.
  const labels = Object.fromEntries(options.map((option) => [option.value, option.label]))

  return (
    <div
      data-slot="sort-select"
      data-size={size}
      data-direction={value.direction}
      className={cn("flex w-fit items-center gap-1.5", className)}
      {...props}
    >
      <Select
        value={value.key}
        onValueChange={(key) => {
          if (typeof key === "string" && key !== value.key)
            onValueChange({ key, direction: value.direction })
        }}
      >
        <SelectTrigger size={size} aria-label={ariaLabel} className="min-w-32">
          <SelectValue>{(key) => labels[key as string] ?? key}</SelectValue>
        </SelectTrigger>
        <SelectContent>
          {options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <Button
        type="button"
        data-slot="sort-select-direction"
        variant="outline"
        size={size === "sm" ? "icon-sm" : "icon"}
        // The label states the direction in force, and pressing flips it. No
        // aria-pressed: a toggle's pressed state would contradict the words —
        // "Sort descending, not pressed" says the opposite of what is happening.
        aria-label={ascending ? "Sort ascending" : "Sort descending"}
        onClick={() => onValueChange({ key: value.key, direction: ascending ? "desc" : "asc" })}
      >
        {ascending ? <ArrowUpNarrowWideIcon /> : <ArrowDownWideNarrowIcon />}
      </Button>
    </div>
  )
}

export { SortSelect }