Styled command
Five command palettes: simple, grouped, icon, glass and kbd. Each is self-contained (no cmdk), sized, token-driven, filters as you type and supports arrow keys plus Enter.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/command-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/command-styled.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
Calculator,
Calendar,
CreditCard,
FileText,
Search,
Settings,
Smile,
User,
} from "lucide-react"
import { cn } from "@/lib/utils"
/* Command family: 5 decorative SELF-CONTAINED command palettes. NO cmdk, NO radix,
NO portal. Each export is a complete palette: a search <input> (role="combobox")
+ a filtered list (role="listbox" / role="option"). Filtering is a
case-insensitive `includes` on the item label as the user types. The arrow keys
(Up/Down) move the highlighted index, Enter selects, Escape clears. With no
match there is an "empty" state. The panel is rendered INLINE (open) - always
visible on a documentation page. Color comes ONLY from tokens, via alpha
color-mix. Reduced motion through useReducedMotion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const paletteWidth: Record<StyledSize, string> = {
sm: "w-64",
md: "w-80",
lg: "w-96",
xl: "w-[28rem]",
}
export interface CommandItem {
label: React.ReactNode
group?: string
icon?: React.ReactNode
onSelect?: () => void
}
interface CommandProps {
className?: string
size?: StyledSize
placeholder?: string
items?: CommandItem[]
}
/* The label can be a ReactNode; flatten it to plain text for filtering. */
function nodeText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") return ""
if (typeof node === "string" || typeof node === "number") return String(node)
if (Array.isArray(node)) return node.map(nodeText).join("")
if (React.isValidElement(node)) {
return nodeText((node.props as { children?: React.ReactNode }).children)
}
return ""
}
/* Default list: some entries carry a group and an icon; renders without props. */
const defaultItems: CommandItem[] = [
{ label: "Calendar", group: "Suggestions", icon: <Calendar /> },
{ label: "Search Emoji", group: "Suggestions", icon: <Smile /> },
{ label: "Calculator", group: "Suggestions", icon: <Calculator /> },
{ label: "Profile", group: "Settings", icon: <User /> },
{ label: "Billing", group: "Settings", icon: <CreditCard /> },
{ label: "Settings", group: "Settings", icon: <Settings /> },
{ label: "New Document", group: "Actions", icon: <FileText /> },
]
/* Deterministic shortcut tokens (index-based, not random). */
const shortcutTokens = ["K", "E", "C", "P", "B", "S", "N", "D"]
/* Ortak durum: sorgu, filtrelenmis liste, vurgulanan index ve klavye idaresi. */
function useCommandState(source: CommandItem[]) {
const [query, setQuery] = React.useState("")
const [active, setActive] = React.useState(0)
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase()
if (q.length === 0) return source
return source.filter((item) => nodeText(item.label).toLowerCase().includes(q))
}, [query, source])
React.useEffect(() => {
setActive((prev) => (prev >= filtered.length ? 0 : prev))
}, [filtered.length])
const select = React.useCallback(
(index: number) => {
const item = filtered[index]
if (item) item.onSelect?.()
},
[filtered]
)
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActive((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActive((prev) =>
filtered.length === 0 ? 0 : (prev - 1 + filtered.length) % filtered.length
)
} else if (e.key === "Enter") {
e.preventDefault()
select(active)
} else if (e.key === "Escape") {
e.preventDefault()
setQuery("")
setActive(0)
}
},
[filtered.length, active, select]
)
return { query, setQuery, active, setActive, filtered, select, onKeyDown }
}
const rootBase =
"flex flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none"
const inputRowBase = "flex items-center gap-2 border-b border-border px-3"
const inputBase =
"h-11 w-full bg-transparent py-3 text-sm text-popover-foreground outline-none placeholder:text-muted-foreground"
const listBase = "max-h-72 overflow-y-auto p-1.5"
const optionBase =
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground [&_i]:text-base [&_i]:leading-none [&_i]:text-muted-foreground"
const emptyBase = "py-6 text-center text-sm text-muted-foreground"
/* Fade transition of the highlighted row; no animation under reduced motion. */
function OptionMotion({
active,
reduce,
children,
...rest
}: {
active: boolean
reduce: boolean
children: React.ReactNode
} & React.ComponentProps<typeof motion.div>) {
return (
<motion.div
initial={false}
animate={{ opacity: 1 }}
transition={reduce ? { duration: 0 } : { duration: 0.15, ease: "easeOut" }}
{...rest}
>
{children}
</motion.div>
)
}
/* SimpleCommand: temiz arama + duz liste. */
export function SimpleCommand({
className,
size = "md",
placeholder = "Type a command...",
items = defaultItems,
}: CommandProps) {
const { query, setQuery, active, setActive, filtered, select, onKeyDown } =
useCommandState(items)
const reduce = useReducedMotion() ?? false
return (
<div
data-slot="styled-command"
role="listbox"
aria-label="Command palette"
className={cn(rootBase, paletteWidth[size], className)}
>
<div className={inputRowBase}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
role="combobox"
aria-expanded="true"
aria-controls="styled-command-simple-list"
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className={inputBase}
/>
</div>
<div id="styled-command-simple-list" className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((item, i) => (
<OptionMotion
key={`${nodeText(item.label)}-${i}`}
active={i === active}
reduce={reduce}
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => select(i)}
className={cn(optionBase, i === active && "bg-accent text-accent-foreground")}
>
{item.label}
</OptionMotion>
))
)}
</div>
</div>
)
}
/* GroupedCommand: item'lar token grup basliklari altinda kumelenir. */
export function GroupedCommand({
className,
size = "md",
placeholder = "Type a command...",
items = defaultItems,
}: CommandProps) {
const { query, setQuery, active, setActive, filtered, select, onKeyDown } =
useCommandState(items)
const reduce = useReducedMotion() ?? false
/* Group the flat filtered list while preserving the global index. */
const groups = React.useMemo(() => {
const map = new Map<string, { item: CommandItem; index: number }[]>()
filtered.forEach((item, index) => {
const key = item.group ?? "Other"
const bucket = map.get(key)
if (bucket) bucket.push({ item, index })
else map.set(key, [{ item, index }])
})
return Array.from(map.entries())
}, [filtered])
return (
<div
data-slot="styled-command"
role="listbox"
aria-label="Command palette"
className={cn(rootBase, paletteWidth[size], className)}
>
<div className={inputRowBase}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
role="combobox"
aria-expanded="true"
aria-controls="styled-command-grouped-list"
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className={inputBase}
/>
</div>
<div id="styled-command-grouped-list" className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
groups.map(([group, entries]) => (
<div key={group} className="pb-1 last:pb-0">
<p className="px-2.5 py-1.5 text-xs font-medium text-muted-foreground">{group}</p>
{entries.map(({ item, index }) => (
<OptionMotion
key={`${nodeText(item.label)}-${index}`}
active={index === active}
reduce={reduce}
role="option"
aria-selected={index === active}
onMouseEnter={() => setActive(index)}
onClick={() => select(index)}
className={cn(
optionBase,
index === active && "bg-accent text-accent-foreground"
)}
>
{item.icon}
{item.label}
</OptionMotion>
))}
</div>
))
)}
</div>
</div>
)
}
/* IconCommand: every item carries a leading token icon plus a right-aligned hint. */
export function IconCommand({
className,
size = "md",
placeholder = "Type a command...",
items = defaultItems,
}: CommandProps) {
const { query, setQuery, active, setActive, filtered, select, onKeyDown } =
useCommandState(items)
const reduce = useReducedMotion() ?? false
return (
<div
data-slot="styled-command"
role="listbox"
aria-label="Command palette"
className={cn(rootBase, paletteWidth[size], className)}
>
<div className={inputRowBase}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
role="combobox"
aria-expanded="true"
aria-controls="styled-command-icon-list"
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className={inputBase}
/>
</div>
<div id="styled-command-icon-list" className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((item, i) => (
<OptionMotion
key={`${nodeText(item.label)}-${i}`}
active={i === active}
reduce={reduce}
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => select(i)}
className={cn(optionBase, i === active && "bg-accent text-accent-foreground")}
>
{item.icon ?? <Search />}
<span className="flex-1 truncate">{item.label}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.group ?? "Jump to"}
</span>
</OptionMotion>
))
)}
</div>
</div>
)
}
/* GlassCommand: buzlu cam palet + backdrop-blur. */
export function GlassCommand({
className,
size = "md",
placeholder = "Type a command...",
items = defaultItems,
}: CommandProps) {
const { query, setQuery, active, setActive, filtered, select, onKeyDown } =
useCommandState(items)
const reduce = useReducedMotion() ?? false
return (
<div
data-slot="styled-command"
role="listbox"
aria-label="Command palette"
className={cn(
"flex flex-col overflow-hidden rounded-xl border border-border text-popover-foreground shadow-lg outline-none",
"bg-[color-mix(in_oklab,var(--color-popover)_75%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-popover)_55%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl",
"shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
paletteWidth[size],
className
)}
>
<div className={inputRowBase}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
role="combobox"
aria-expanded="true"
aria-controls="styled-command-glass-list"
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className={inputBase}
/>
</div>
<div id="styled-command-glass-list" className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((item, i) => (
<OptionMotion
key={`${nodeText(item.label)}-${i}`}
active={i === active}
reduce={reduce}
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => select(i)}
className={cn(
optionBase,
i === active &&
"bg-[color-mix(in_oklab,var(--color-accent)_70%,transparent)] text-accent-foreground"
)}
>
{item.icon}
{item.label}
</OptionMotion>
))
)}
</div>
</div>
)
}
/* KbdCommand: every item shows a right-aligned token keyboard-shortcut chip. */
export function KbdCommand({
className,
size = "md",
placeholder = "Type a command...",
items = defaultItems,
}: CommandProps) {
const { query, setQuery, active, setActive, filtered, select, onKeyDown } =
useCommandState(items)
const reduce = useReducedMotion() ?? false
return (
<div
data-slot="styled-command"
role="listbox"
aria-label="Command palette"
className={cn(rootBase, paletteWidth[size], className)}
>
<div className={inputRowBase}>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
role="combobox"
aria-expanded="true"
aria-controls="styled-command-kbd-list"
aria-autocomplete="list"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className={inputBase}
/>
</div>
<div id="styled-command-kbd-list" className={listBase}>
{filtered.length === 0 ? (
<p className={emptyBase}>No results found.</p>
) : (
filtered.map((item, i) => (
<OptionMotion
key={`${nodeText(item.label)}-${i}`}
active={i === active}
reduce={reduce}
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => select(i)}
className={cn(optionBase, i === active && "bg-accent text-accent-foreground")}
>
{item.icon}
<span className="flex-1 truncate">{item.label}</span>
<kbd className="pointer-events-none inline-flex h-5 shrink-0 select-none items-center gap-1 rounded border border-border bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs">{"⌘"}</span>
{shortcutTokens[i % shortcutTokens.length]}
</kbd>
</OptionMotion>
))
)}
</div>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Simple
A search box over a flat, filtered list.
import { SimpleCommand } from "@/components/ui/command-styled"
<SimpleCommand />Grouped
Results grouped under token headers.
Suggestions
Settings
Actions
import { GroupedCommand } from "@/components/ui/command-styled"
<GroupedCommand />Suggestions
Settings
Actions
Suggestions
Settings
Actions
Suggestions
Settings
Actions
Suggestions
Settings
Actions
Icon
Each item has a leading icon and a hint.
import { IconCommand } from "@/components/ui/command-styled"
<IconCommand />Glass
A frosted glass palette.
import { GlassCommand } from "@/components/ui/command-styled"
<GlassCommand />Kbd
Each item shows a token keyboard shortcut.
import { KbdCommand } from "@/components/ui/command-styled"
<KbdCommand />ai2 Styled command: 5 styled variations on the token system
The ai2 Styled command are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around command palettes. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: the filtered list updates as you type; no motion library work is required for the core. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, there is no continuous motion to reduce.
What is in the ai2 Styled command?
5 exports in one file: Simple, Grouped, Icon, Glass and Kbd. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: the filtered list updates as you type; no motion library work is required for the core.
- Reduced-motion aware: Under prefers-reduced-motion, there is no continuous motion to reduce.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled command sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.