one segmented control for an order and its direction: press the chosen again to flip
npx shadcn add @ag/sortAlready installed? Preview an update
npx shadcn add @ag/sort --dry-runYour 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:
iconis 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 itsnaturaldirection (descby default). Controlled:valueis{ by, dir },onChangegets the next one.saynames each direction ("newest first") in the tooltip and the accessible name. The toggle group'ssize,materialandscheme.
the demo above, verbatim
"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
"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>
)
}