← ui

sort

ui

one segmented control for an order and its direction: press the chosen again to flip

npx shadcn add @ag/sort
Already installed? Preview an update
npx shadcn add @ag/sort --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-30
  • The direction is one plain arrow (down for descending, turned for ascending), no longer the table's bars-and-arrow glyph, which read as a second icon beside the order's own.
  • Fixed: the chosen order sits on the toggle group's gliding highlight, plain and glass; it was missing. Migration: icon is required on every order. New: show (both, selected, icon), passed to the toggle group; the direction shows after the chosen order in all three.
  • New: one control for an order and its direction, a segmented control with one segment per order (orders, each { id, label, icon, natural?, say?, hint?, disabled? }). The chosen order carries its direction as a glyph after its label; pressing it again flips the direction, another order is chosen in its natural direction (desc by default). Controlled: value is { by, dir }, onChange gets the next one. say names each direction ("newest first") in the tooltip and the accessible name. The toggle group's size, material and scheme.
Updated 2026-10-30 · release notes

also called order by, sort by, sort control

shelf controls

uses material, toggle-group

the demo above, verbatim

sort.demo.tsx
"use client"

import { Clock, Flame, Search as SearchIcon, Trophy } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
  Sort,
  type SortOrder,
  type SortValue,
} from "@/registry/base-nova/ui/sort"
import type { ToggleGroupShow } from "@/registry/base-nova/ui/toggle-group"

// #region orders
type By = "date" | "hot" | "top"

const ORDERS: SortOrder<By>[] = [
  {
    id: "date",
    label: "date",
    icon: <Clock />,
    say: { asc: "oldest first", desc: "newest first" },
  },
  {
    id: "hot",
    label: "hot",
    icon: <Flame />,
    hint: "votes lately, weighed by age",
    say: { asc: "coldest first", desc: "hottest first" },
  },
  {
    id: "top",
    label: "top",
    icon: <Trophy />,
    say: { asc: "fewest votes first", desc: "most votes first" },
  },
]
// #endregion

// #region posts
type Post = { title: string; days: number; votes: number }

const POSTS: Post[] = [
  { title: "a lens for the long walk", days: 1, votes: 12 },
  { title: "what the river kept", days: 9, votes: 140 },
  { title: "notes on a quiet shore", days: 3, votes: 61 },
  { title: "the peaks at first light", days: 21, votes: 230 },
]

/** Votes per day of age: what "hot" means here. */
const heat = (p: Post) => p.votes / (p.days + 2)

const KEY: Record<By, (p: Post) => number> = {
  date: (p) => -p.days,
  hot: heat,
  top: (p) => p.votes,
}

function sorted(posts: Post[], { by, dir }: SortValue<By>) {
  const sign = dir === "asc" ? 1 : -1
  return posts.toSorted((a, b) => sign * (KEY[by](a) - KEY[by](b)))
}
// #endregion

// #region feed
function Feed() {
  const [sort, setSort] = React.useState<SortValue<By>>({
    by: "hot",
    dir: "desc",
  })
  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Sort
        aria-label="sort posts by"
        orders={ORDERS}
        value={sort}
        onChange={setSort}
      />
      <ol className="flex flex-col gap-1 text-[13px]">
        {sorted(POSTS, sort).map((p) => (
          <li key={p.title} className="flex justify-between gap-4">
            <span>{p.title}</span>
            <span className="text-current/55 tabular-nums">
              {p.days}d · {p.votes}
            </span>
          </li>
        ))}
      </ol>
    </div>
  )
}
// #endregion

// #region shows
/** One sort per way of showing its orders: icon and label, the icon alone, the
 *  label on the chosen only. */
function Shows() {
  const [sort, setSort] = React.useState<SortValue<By>>({
    by: "date",
    dir: "desc",
  })
  return (
    <div className="flex flex-col items-start gap-3">
      {(["both", "selected", "icon"] as ToggleGroupShow[]).map((show) => (
        <Sort
          key={show}
          show={show}
          aria-label={`sort by, ${show}`}
          orders={ORDERS}
          value={sort}
          onChange={setSort}
        />
      ))}
    </div>
  )
}
// #endregion

// #region compact
function Compact() {
  const [sort, setSort] = React.useState<SortValue<By>>({
    by: "top",
    dir: "desc",
  })
  return (
    <Sort
      size="sm"
      show="selected"
      orders={ORDERS}
      value={sort}
      onChange={setSort}
    />
  )
}
// #endregion

// #region search
type Found = "relevance" | "date"

const FOUND: SortOrder<Found>[] = [
  {
    id: "relevance",
    label: "relevance",
    icon: <SearchIcon />,
    say: { asc: "worst match first", desc: "best match first" },
  },
  {
    id: "date",
    label: "date",
    icon: <Clock />,
    say: { asc: "oldest first", desc: "newest first" },
  },
]

/** Relevance means something only while there is a query: chosen when one is typed,
 *  disabled when it is cleared, the order falling back to date. */
function Search() {
  const [query, setQuery] = React.useState("")
  const [sort, setSort] = React.useState<SortValue<Found>>({
    by: "date",
    dir: "desc",
  })
  const searching = query.trim() !== ""
  const type = (next: string) => {
    const now = next.trim() !== ""
    if (now && !searching) setSort({ by: "relevance", dir: "desc" })
    if (!now && sort.by === "relevance") setSort({ by: "date", dir: "desc" })
    setQuery(next)
  }
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Input
        aria-label="search"
        placeholder="search"
        value={query}
        onChange={(e) => type(e.target.value)}
        className="w-44"
      />
      <Sort
        orders={FOUND.map((o) =>
          o.id === "relevance" ? { ...o, disabled: !searching } : o,
        )}
        value={sort}
        onChange={setSort}
      />
    </div>
  )
}
// #endregion

export default function SortDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="feed"
        title="one control, order and direction"
        description="press the chosen order again to flip it; another order is chosen in its natural direction. hover an order for its words."
        with="orders posts feed"
      >
        <Feed />
      </Sample>
      <Sample
        name="shows"
        title="icon and label, selected, icon"
        description="one value, three sorts: every bar keeps its width whichever order is chosen."
        with="orders shows"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Shows />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-6 text-white">
              <Shows />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="compact" glance title="compact" with="orders compact">
        <Compact />
      </Sample>
      <Sample name="search" title="an order only while searching" with="search">
        <Search />
      </Sample>
    </div>
  )
}

the source, every file an install writes

sort.tsx
"use client"

import { ArrowDown } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import type { Material, Scheme } from "@/registry/base-nova/ui/material"
import {
  ToggleGroup,
  ToggleGroupItem,
  type ToggleGroupShow,
  type ToggleGroupSize,
} from "@/registry/base-nova/ui/toggle-group"

// One control for an order and its direction: a toggle group (its gliding highlight,
// sizes, materials and `show`), one option per order, each with its icon. The chosen
// order carries its direction after it; pressing it again flips the direction,
// pressing another order chooses that one in its natural direction.
//
//   <Sort
//     orders={[
//       { id: "date", label: "date", icon: <Clock />, say: { asc: "oldest first", desc: "newest first" } },
//       { id: "top", label: "top", icon: <Trophy />, say: { asc: "fewest votes first", desc: "most votes first" } },
//     ]}
//     value={sort}
//     onChange={setSort}
//   />
//
// Controlled only: whoever holds the list sorts it, so it holds the value. The value
// is `{ by, dir }` and nothing else; an order left and come back to starts again in
// its natural direction (the notes say why no direction is remembered per order).
// Keys are the toggle group's: one Tab stop, the arrows between orders, Space or
// Enter on the chosen one flips it.

export type SortDir = "asc" | "desc"

export type SortValue<Id extends string = string> = { by: Id; dir: SortDir }

export type SortOrder<Id extends string = string> = {
  id: Id
  /** Its name on the segment (or its tooltip, where `show` hides it), and the start
   *  of its name to a screen reader. */
  label: string
  /** What kind of order it is, read at a glance (a clock for date, a flame for
   *  hot). */
  icon: React.ReactNode
  /** The direction it is chosen in: `desc` (default), the most, the newest, the
   *  best first, which is what most orders a person picks start with. A name or a
   *  distance reads `asc`. */
  natural?: SortDir
  /** The words for each direction ("newest first"), said in its tooltip and its
   *  accessible name. Default "ascending" and "descending". */
  say?: { asc: string; desc: string }
  /** What the order means ("votes in the last day"), first in its tooltip and its
   *  description. */
  hint?: string
  /** Shown and never chosen (relevance with nothing searched). */
  disabled?: boolean
}

export type SortProps<Id extends string = string> = {
  orders: readonly SortOrder<Id>[]
  value: SortValue<Id>
  onChange: (value: SortValue<Id>) => void
  /** What each order shows (toggle-group's): `both` (default), its icon and label;
   *  `icon`, its icon alone; `selected`, its icon alone unless chosen. The chosen
   *  order's direction shows after it in all three. */
  show?: ToggleGroupShow
  size?: ToggleGroupSize
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  /** What is being ordered, for assistive tech. Default "sort by". */
  "aria-label"?: string
  className?: string
}

const SAY = { asc: "ascending", desc: "descending" } as const
const FLIP = { asc: "desc", desc: "asc" } as const

/** The direction is one plain arrow, down for descending: beside an order's own icon
 *  a second drawing of bars and an arrow (the table's header glyph) read as clutter
 *  (Adrian, 2026-10-30). A flip turns the arrow over rather than swapping it, on the
 *  toggle group's glide curve. A step dimmer than the words, brighter under the hand. */
const DIRECTION = cn(
  "transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
  "opacity-60 group-hover/toggle:opacity-90",
)

const naturalOf = (order: SortOrder) => order.natural ?? "desc"

export function Sort<Id extends string>({
  orders,
  value,
  onChange,
  show,
  size,
  material,
  scheme,
  "aria-label": label = "sort by",
  className,
}: SortProps<Id>) {
  const find = (id: string) => {
    const order = orders.find((o) => o.id === id)
    if (!order)
      throw new Error(
        `Sort: "${id}" is none of the orders (${orders.map((o) => o.id).join(", ")})`,
      )
    return order
  }
  find(value.by)
  return (
    <ToggleGroup
      data-slot="sort"
      show={show}
      size={size}
      material={material}
      scheme={scheme}
      aria-label={label}
      value={value.by}
      // Another order: chosen in its natural direction. The chosen one pressed
      // again never reaches here (one, always one); its own press flips it.
      onValueChange={(id) => {
        const order = find(id)
        onChange({ by: order.id, dir: naturalOf(order) })
      }}
      className={className}
    >
      {orders.map((order) => {
        const on = order.id === value.by
        const dir = on ? value.dir : naturalOf(order)
        const words = order.say ?? SAY
        const next = words[FLIP[dir]]
        const tip = on
          ? [order.hint, `${order.label}, ${words[dir]}`, `press for ${next}`]
          : [order.hint, `${order.label}, ${words[dir]}`]
        return (
          <ToggleGroupItem
            key={order.id}
            value={order.id}
            disabled={order.disabled}
            icon={order.icon}
            tip={tip.filter(Boolean).join(" · ")}
            data-dir={dir}
            aria-label={`${order.label}, ${words[dir]}`}
            aria-description={
              [order.hint, on ? `press for ${next}` : undefined]
                .filter(Boolean)
                .join(", ") || undefined
            }
            onClick={() => {
              if (on) onChange({ by: order.id, dir: FLIP[value.dir] })
            }}
            whileChosen={
              <ArrowDown
                aria-hidden
                data-slot="sort-direction"
                className={cn(DIRECTION, dir === "asc" && "rotate-180")}
              />
            }
          >
            {order.label}
          </ToggleGroupItem>
        )
      })}
    </ToggleGroup>
  )
}