();
// Shiki's built-in no-op grammars. These need no on-demand `shiki/langs/*`
// import (there is no module for them) — `codeToHast` handles them directly.
// Used for fenced blocks with no language (file trees, plain output) so they
// still render in a real rather than collapsing into inline code.
const PLAINTEXT_LANGS = new Set(["text", "txt", "plaintext", "plain", "ansi"]);
const BASE_TRANSFORMERS: ShikiTransformer[] = [
{
name: "remove-background",
pre(node) {
delete node.properties.style;
},
code(node) {
delete node.properties.style;
},
},
{
name: "fix-empty-lines",
line(node) {
// Ensure empty lines have a space to maintain height
// Shiki creates empty .line spans for blank lines which collapse
if (!node.children || node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
},
];
export interface HighlightOptions {
highlightLines?: number[] | string;
showDiff?: boolean;
focusLines?: number[] | string;
}
async function highlightWithLang(
code: string,
lang: BundledLanguage,
options?: HighlightOptions,
) {
// Await the global highlighter promise (per Shiki Next.js docs)
const instance = await highlighter;
// Load language dynamically if not already loaded (cached Set for performance).
// Plaintext grammars are built in, so skip the import that would 404.
if (!PLAINTEXT_LANGS.has(lang) && !loadedLanguages.has(lang)) {
// Dynamically import only the specific language module needed
const langModule = await import(`shiki/langs/${lang}.mjs`);
await instance.loadLanguage(langModule.default);
loadedLanguages.add(lang);
}
// Build transformers array based on options
const transformers: ShikiTransformer[] = [...BASE_TRANSFORMERS];
if (options?.highlightLines) {
transformers.push(createHighlightLinesTransformer(options.highlightLines));
}
if (options?.showDiff) {
transformers.push(createDiffTransformer());
}
if (options?.focusLines) {
transformers.push(createFocusTransformer(options.focusLines));
}
const out = instance.codeToHast(code, {
lang,
themes: {
light: "github-light",
dark: "github-dark",
},
defaultColor: "light-dark()",
transformers,
});
return toJsxRuntime(out, { Fragment, jsx, jsxs }) as JSX.Element;
}
export async function highlight(
code: string,
lang: BundledLanguage,
options?: HighlightOptions,
) {
try {
return await highlightWithLang(code, lang, options);
} catch {
// If language isn't supported, try with javascript as fallback
try {
return await highlightWithLang(code, "javascript", options);
} catch {
// Final fallback to plain text
return jsx("pre", { children: code }) as JSX.Element;
}
}
}
```
Create `components/ui/cubby-ui/code-block/lib/transformers/diff.ts`:
```tsx
import type { ShikiTransformer } from "shiki";
import { detectDiffMarker, stripDiffMarker } from "./utils";
/**
* Shiki transformer that detects diff markers and adds data-diff attributes
* Supports:
* - "+" prefix for added lines
* - "-" prefix for removed lines
* - "!" prefix for modified lines
* - Comment variants: "// +", "// -", "// !"
*
* The markers are stripped from the displayed code for clean output
*
* @returns Shiki transformer
*/
export function createDiffTransformer(): ShikiTransformer {
// Store original code lines for diff detection
let codeLines: string[] = [];
return {
name: "diff",
preprocess(code) {
// Store original lines before Shiki processes them
codeLines = code.split("\n");
// Strip diff markers from the code that will be highlighted
return code
.split("\n")
.map((line) => stripDiffMarker(line))
.join("\n");
},
line(node, lineNumber) {
// lineNumber is 1-indexed by Shiki, convert to 0-indexed for array access
const originalLine = codeLines[lineNumber - 1];
if (!originalLine) return;
const diffType = detectDiffMarker(originalLine);
if (diffType) {
node.properties["data-diff"] = diffType;
}
},
};
}
```
Create `components/ui/cubby-ui/code-block/lib/transformers/focus.ts`:
```tsx
import type { ShikiTransformer } from "shiki";
import { parseLineRange } from "./utils";
/**
* Shiki transformer that adds focus attributes to lines
* - Adds data-line-number to all lines for targeting
* - Adds data-focused to specified lines
*
* @param lines - Line numbers to focus (1-indexed) or range string like "1-3,5"
* @returns Shiki transformer
*/
export function createFocusTransformer(
lines: number[] | string | undefined,
): ShikiTransformer {
const focusedLines = parseLineRange(lines);
const focusSet = new Set(focusedLines);
return {
name: "focus",
line(node, lineNumber) {
// lineNumber is already 1-indexed by Shiki
// Add line number to all lines for CSS targeting
node.properties["data-line-number"] = String(lineNumber);
// Mark focused lines
if (focusSet.has(lineNumber)) {
node.properties["data-focused"] = "true";
}
},
};
}
```
Create `components/ui/cubby-ui/code-block/lib/transformers/highlight-lines.ts`:
```tsx
import type { ShikiTransformer } from "shiki";
import { parseLineRange } from "./utils";
/**
* Shiki transformer that adds data-highlighted attribute to specified lines
*
* @param lines - Line numbers to highlight (1-indexed) or range string like "1-3,5,7-9"
* @returns Shiki transformer
*/
export function createHighlightLinesTransformer(
lines: number[] | string | undefined,
): ShikiTransformer {
const highlightedLines = parseLineRange(lines);
const highlightSet = new Set(highlightedLines);
return {
name: "highlight-lines",
line(node, lineNumber) {
// lineNumber is already 1-indexed by Shiki
if (highlightSet.has(lineNumber)) {
node.properties["data-highlighted"] = "true";
}
},
};
}
```
Create `components/ui/cubby-ui/code-block/lib/transformers/utils.ts`:
```tsx
/**
* Parse line range syntax into an array of line numbers
* Supports formats:
* - Array: [1, 2, 3]
* - String: "1,3,5" or "1-3,5,7-9"
* - Mixed: "1-3,5" becomes [1, 2, 3, 5]
*
* @param input - Line numbers as array or range string
* @returns Array of line numbers (1-indexed)
*/
export function parseLineRange(
input: number[] | string | undefined,
): number[] {
if (!input) return [];
if (Array.isArray(input)) return input;
const lines = new Set();
const parts = input.split(",").map((s) => s.trim());
for (const part of parts) {
// Range format: "1-3"
if (part.includes("-")) {
const [start, end] = part.split("-").map((s) => Number.parseInt(s, 10));
if (!Number.isNaN(start) && !Number.isNaN(end)) {
for (let i = start; i <= end; i++) {
lines.add(i);
}
}
} else {
// Single number
const num = Number.parseInt(part, 10);
if (!Number.isNaN(num)) {
lines.add(num);
}
}
}
return Array.from(lines).sort((a, b) => a - b);
}
/**
* Detect diff markers in code lines
* Supports:
* - Prefix markers: "+", "-", "!"
* - Comment markers: "// +", "// -", "// !"
*
* @param line - Code line to check
* @returns Diff type or null
*/
export function detectDiffMarker(
line: string,
): "added" | "removed" | "modified" | null {
const trimmed = line.trim();
// Direct prefix markers
if (trimmed.startsWith("+")) return "added";
if (trimmed.startsWith("-")) return "removed";
if (trimmed.startsWith("!")) return "modified";
// Comment markers (for languages that don't support bare +/-)
if (trimmed.startsWith("// +")) return "added";
if (trimmed.startsWith("// -")) return "removed";
if (trimmed.startsWith("// !")) return "modified";
return null;
}
/**
* Strip diff markers from a code line
*
* @param line - Code line with potential diff marker
* @returns Line with marker removed
*/
export function stripDiffMarker(line: string): string {
const trimmed = line.trim();
// Direct prefix markers
if (trimmed.startsWith("+") || trimmed.startsWith("-") || trimmed.startsWith("!")) {
return line.replace(/^(\s*)[-+!](\s?)/, "$1");
}
// Comment markers
if (trimmed.startsWith("// +") || trimmed.startsWith("// -") || trimmed.startsWith("// !")) {
return line.replace(/^(\s*)\/\/\s*[-+!](\s?)/, "$1");
}
return line;
}
```
## Usage [#usage]
**Imports:**
```tsx
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
```
**Basic Usage:**
```tsx
```
## Composition [#composition]
```text
CodeBlock
├── CodeBlockHeader
└── CodeBlockPre
└── CodeBlockCode
```
## Examples [#examples]
### Floating Copy Button [#floating-copy-button]
Set `floatingCopy` on `CodeBlock` to show a copy button that appears on hover, without a header. A code block with no `CodeBlockHeader` drops its outer tray (background, padding and shadow), so the code card stands on its own.
```tsx
// code-block-floating-copy.tsx
"use client";
import {
CodeBlock,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockFloatingCopyExample() {
const code = `function greet(name) {
return \`Hello, \${name}!\`;
}
console.log(greet("World"));`;
return (
);
}
```
### With Filename [#with-filename]
Pass `filename` to `CodeBlockHeader` to label the snippet.
```tsx
// code-block-with-filename.tsx
"use client";
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockWithFilename() {
const code = `export interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
export function createUser(data: Omit): User {
return {
...data,
id: crypto.randomUUID(),
};
}`;
return (
);
}
```
### With Tabs [#with-tabs]
Pass `tabs` to `CodeBlockHeader` to switch between code variants such as package managers or languages.
```tsx
// code-block-tabs.tsx
"use client";
import { useState } from "react";
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockTabsExample() {
const [packageManager, setPackageManager] = useState("npm");
const getInstallCommand = (pm: string) => {
switch (pm) {
case "npm":
return "npm install react-query";
case "pnpm":
return "pnpm add react-query";
case "yarn":
return "yarn add react-query";
case "bun":
return "bun add react-query";
default:
return "npm install react-query";
}
};
return (
Switch between package managers using the tabs in the header.
setPackageManager(value as string)}
/>
);
}
```
### With Line Numbers [#with-line-numbers]
Set `lineNumbers` on `CodeBlockPre` to number each line.
```tsx
// code-block-line-numbers.tsx
"use client";
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockLineNumbersExample() {
const code = `class BinarySearchTree {
constructor() {
this.root = null;
}
insert(value) {
const newNode = { value, left: null, right: null };
if (!this.root) {
this.root = newNode;
return;
}
let current = this.root;
while (true) {
if (value < current.value) {
if (!current.left) {
current.left = newNode;
return;
}
current = current.left;
} else {
if (!current.right) {
current.right = newNode;
return;
}
current = current.right;
}
}
}
}`;
return (
);
}
```
### Multiple Languages [#multiple-languages]
Set `language` on `CodeBlock` to highlight any Shiki-supported language.
```tsx
// code-block-languages.tsx
"use client";
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockLanguagesExample() {
return (
TypeScript
{
data: T;
status: number;
message: string;
}
async function fetchUser(id: string): Promise> {
const response = await fetch(\`/api/users/\${id}\`);
return response.json();
}`}
language="typescript"
>
Python
pivot]
return quicksort(left) + middle + quicksort(right)
print(quicksort([3, 6, 8, 10, 1, 2, 1])) # [1, 1, 2, 3, 6, 8, 10]`}
language="python"
>
Bash
);
}
```
### Custom Icon [#custom-icon]
Pass `customIcon` to `CodeBlockHeader` to replace the default language icon.
```tsx
// code-block-custom-icon.tsx
"use client";
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
import { HugeiconsIcon } from "@hugeicons/react";
import { SparklesIcon } from "@hugeicons/core-free-icons";
export default function CodeBlockCustomIcon() {
const code = `function greet(name) {
return \`Hello, \${name}! ✨\`;
}
console.log(greet("World"));`;
return (
}
/>
);
}
```
### Custom Height [#custom-height]
Set a `max-h-*` class on `CodeBlockPre` to cap the height and scroll overflow.
```tsx
// code-block-height.tsx
"use client";
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockHeightExample() {
const code = `export const theme = {
colors: {
primary: "#3b82f6",
secondary: "#8b5cf6",
success: "#10b981",
warning: "#f59e0b",
danger: "#ef4444",
info: "#06b6d4",
},
spacing: {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem",
"2xl": "3rem",
},
typography: {
fontFamily: {
sans: "Inter, system-ui, sans-serif",
mono: "Fira Code, monospace",
},
fontSize: {
xs: "0.75rem",
sm: "0.875rem",
base: "1rem",
lg: "1.125rem",
xl: "1.25rem",
"2xl": "1.5rem",
"3xl": "1.875rem",
"4xl": "2.25rem",
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
},
borderRadius: {
sm: "0.25rem",
md: "0.5rem",
lg: "1rem",
full: "9999px",
},
};`;
return (
Custom max height with scrolling (max-h-[200px])
);
}
```
### Server-Side Highlighting [#server-side-highlighting]
Pre-highlight on the server with the `highlight` function and pass the result as `initial` for instant rendering.
```tsx
// code-block-server-highlight.tsx
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
import { highlight } from "@/lib/cubby-ui/shiki-shared";
export default async function CodeBlockServerHighlight() {
const code = `export async function fetchUserData(userId: string) {
const response = await fetch(\`/api/users/\${userId}\`);
if (!response.ok) {
throw new Error('Failed to fetch user data');
}
return response.json();
}`;
// Pre-highlight on the server for instant rendering
const highlighted = await highlight(code, "typescript");
return (
);
}
```
### Highlighted Lines [#highlighted-lines]
Pass `highlightLines` to `CodeBlock` to emphasize lines with a background and left border.
```tsx
// code-block-highlight-lines.tsx
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockHighlightLines() {
const code = `function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += item.price * item.quantity;
}
return total;
}`;
return (
Highlight specific lines (lines 2, 5)
Highlight range using string syntax (lines 1-3, 7)
);
}
```
### Diffs [#diffs]
Set `showDiff` to mark added, removed, and modified lines; the diff markers are stripped from the displayed code.
```tsx
// code-block-diff.tsx
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockDiff() {
const code = `function greet(name) {
- console.log("Hello " + name);
+ console.log(\`Hello, \${name}!\`);
}
function calculatePrice(item) {
- return item.price;
+ return item.price * (1 - item.discount);
! // Apply discount
}`;
return (
Diff view with added (+), removed (-), and modified (!) lines
Using comment-style markers for TypeScript/JSX compatibility
);
}
```
### Focus Mode [#focus-mode]
Pass `focusLines` to `CodeBlock` to blur every line except the ones you name.
```tsx
// code-block-focus.tsx
import {
CodeBlock,
CodeBlockHeader,
CodeBlockPre,
CodeBlockCode,
} from "@/components/ui/cubby-ui/code-block/code-block";
export default function CodeBlockFocus() {
const code = `async function fetchUser(userId) {
const response = await fetch(\`/api/users/\${userId}\`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const data = await response.json();
return data;
}`;
return (
Focus on specific lines (lines 4-5) - other lines are dimmed
Focus on a range using string syntax (lines 1-2, 8)
);
}
```
## API Reference [#api-reference]
The CodeBlock component is a custom implementation built with Shiki for syntax highlighting. It does not extend Base UI.
### Props [#props]
#### CodeBlock [#codeblock]
The main component that manages state and provides context to sub-components automatically via React Context. Sub-components can access `code`, `language`, `nodes`, and `lines` without explicit prop passing.
- **code** (type: `string`)
The code content to display and syntax highlight. Automatically shared with
all sub-components via context.
- **language** (type: `string`) - Default: `"javascript"`
The programming language for syntax highlighting. Supports all Shiki bundled
languages (typescript, python, bash, etc.). Automatically shared with
sub-components via context.
- **floatingCopy** (type: `boolean`) - Default: `false`
Automatically append a floating copy button positioned at the top-right that
appears on hover. Ideal for headerless code blocks.
- **highlightLines** (type: `array | string`, full: `number[] | string`)
Lines to highlight with background color and left border. Accepts an array of
line numbers `[1, 2, 3]` or a range string `"1-3,5,7-9"`.
- **showDiff** (type: `boolean`) - Default: `false`
Enable diff mode to show added (+), removed (-), and modified (!) lines. Diff
markers are automatically detected and stripped from the displayed code.
- **focusLines** (type: `array | string`, full: `number[] | string`)
Lines to keep focused while blurring others. Accepts an array of line numbers
`[1, 2, 3]` or a range string `"1-3,5,7-9"`.
- **initial** (type: `element`, full: `React.ReactElement`)
Optional pre-highlighted React element to display immediately, useful for
server-side rendering.
- **children** (type: `node`, full: `ReactNode`)
The sub-components to render inside the code block. Sub-components
automatically receive shared state via React Context.
#### CodeBlockHeader [#codeblockheader]
Smart header component that auto-arranges language icons, filenames, tabs, and copy button based on props. Automatically consumes `language` and `code` from CodeBlock context.
- **filename** (type: `string`)
Optional filename to display next to the language icon.
- **showCopy** (type: `boolean`) - Default: `true`
Whether to show the copy button in the header.
- **customIcon** (type: `node`, full: `ReactNode`)
Custom icon to display instead of the default language icon. Accepts any React
node (component, JSX element, etc.).
- **tabs** (type: `array`, full: `HeaderTab[]`)
Array of tab objects with `value` and `label` properties. When provided with
`activeTab` and `onTabChange`, renders tab navigation.
- **activeTab** (type: `string | undefined`)
Currently active tab value (required if tabs are provided). Type is inferred
from the Tabs component.
- **onTabChange** (type: `function`, full: `function`)
Tab change handler (required if tabs are provided). Type is inferred from the
Tabs component's `onValueChange` prop.
- **tabVariant** (type: `string`, full: `"capsule" | "underline"`)
Visual style variant of the tabs. Type is inferred from the TabsList
component's `variant` prop.
- **className** (type: `string`)
Additional CSS classes for styling the header.
- **render** (type: `element`, full: `ReactElement`)
Custom render element for polymorphic behavior.
- **children** (type: `node`, full: `ReactNode`)
Custom children for full control over header layout. When provided, all
auto-layout props are ignored.
#### CodeBlockPre [#codeblockpre]
The pre-formatted text container that wraps the code content. Automatically consumes `lines` from CodeBlock context for line numbers. Includes `group` and `relative` classes for floating button support. Uses [ScrollArea](/docs/components/scroll-area) internally for styled scrollbars and fade edges.
- **lineNumbers** (type: `boolean`) - Default: `false`
Automatically display line numbers alongside the code. When enabled, wraps
children in proper flex layout.
- **fadeEdges** (type: `boolean | string | array`, full: `boolean | FadeEdge | FadeEdge[]`) - Default: `true`
Configure fade edges to indicate scrollable content. Accepts `true` (all
edges), `false` (none), a single edge (`"top"`, `"bottom"`, `"left"`,
`"right"`, `"x"`, `"y"`), or an array of edges (`["top", "right"]`).
- **hideScrollbar** (type: `boolean`) - Default: `false`
Hide scrollbars while keeping scroll functionality. Useful for cleaner
aesthetics when fade edges provide sufficient scroll indication.
- **nativeScroll** (type: `boolean`) - Default: `false`
Use native browser scrolling instead of Base UI ScrollArea. Native scroll uses
CSS scroll-driven animations for fade edges (Chrome 115+) with static fallback
elsewhere.
- **className** (type: `string`)
Additional CSS classes for styling. Useful for controlling height (e.g.,
`max-h-96`) or scrolling behavior.
- **render** (type: `element`, full: `ReactElement`)
Custom render element for polymorphic behavior.
#### CodeBlockCode [#codeblockcode]
The actual syntax-highlighted code element. Automatically consumes `code` and `nodes` (highlighted JSX) from CodeBlock context.
- **className** (type: `string`)
Additional CSS classes for styling.
- **render** (type: `element`, full: `ReactElement`)
Custom render element for polymorphic behavior.
# Command (/docs/components/command)
The command menu is a searchable list of actions and links, driven from the
keyboard. Use it for a ⌘K palette, a quick switcher, or any
search-then-select flow.
For a simple dropdown of options bound to a form field, use
[`Select`](/docs/components/select) instead.
## Preview [#preview]
```tsx
// command-basic-command-menu.tsx
"use client";
import * as React from "react";
import {
Command,
CommandCollection,
CommandContent,
CommandGroup,
CommandGroupLabel,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/cubby-ui/command";
import { HugeiconsIcon } from "@hugeicons/react";
import {
CalculatorIcon,
Calendar01Icon,
CreditCardIcon,
Settings01Icon,
SmileIcon,
UserIcon,
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
interface CommandItemData {
value: string;
label: string;
icon: IconSvgElement;
shortcut?: string;
}
interface CommandGroupData {
label: string;
items: CommandItemData[];
}
const commandGroups: CommandGroupData[] = [
{
label: "Suggestions",
items: [
{ value: "calendar", label: "Calendar", icon: Calendar01Icon },
{ value: "search-emoji", label: "Search Emoji", icon: SmileIcon },
{ value: "calculator", label: "Calculator", icon: CalculatorIcon },
],
},
{
label: "Settings",
items: [
{ value: "profile", label: "Profile", icon: UserIcon, shortcut: "⌘P" },
{ value: "billing", label: "Billing", icon: CreditCardIcon, shortcut: "⌘B" },
{ value: "settings", label: "Settings", icon: Settings01Icon, shortcut: "⌘S" },
],
},
];
export default function CommandBasicCommandMenu() {
return (
{(group: CommandGroupData, index: number) => (
{index > 0 && }
{group.label}
{(item: CommandItemData) => {
return (
{item.label}
{item.shortcut && (
{item.shortcut}
)}
);
}}
)}
);
}
```
## Installation [#installation]
### Installation
**CLI:**
```bash
npx shadcn@latest add @cubby-ui/command
```
**Manual:**
1. Install dependencies:
```bash
npm install @hugeicons/react @hugeicons/core-free-icons
```
2. Copy the component source code to your project:
Create `components/ui/cubby-ui/command.tsx`:
```tsx
"use client";
import * as React from "react";
import { Autocomplete as AutocompleteBase } from "@base-ui/react/autocomplete";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/cubby-ui/input-group";
import {
ScrollArea,
type ScrollAreaProps,
} from "@/components/ui/cubby-ui/scroll-area/scroll-area";
import { cn } from "@/lib/utils";
import {
solidSurface,
type SurfaceLevel,
} from "@/lib/cubby-ui/elevated";
import { HugeiconsIcon } from "@hugeicons/react";
import { Search01Icon } from "@hugeicons/core-free-icons";
const useCommandFilter = AutocompleteBase.useFilter;
const useCommandFilteredItems = AutocompleteBase.useFilteredItems;
const AutocompleteRoot = AutocompleteBase.Root;
function Command({
className,
items = [],
children,
autoHighlight = "always",
open = true,
keepHighlight = true,
level = 3,
shadowLevel = 3,
...props
}: AutocompleteBase.Root.Props & {
className?: string;
/** Surface elevation level for the outer Command frame (1-8). Defaults to 3. Bump to 7 when used inside CommandDialog so the palette pops above the dialog. */
level?: SurfaceLevel;
/** Shadow weight (1-8). Pinned to 3 by default. */
shadowLevel?: SurfaceLevel;
}) {
return (
);
}
const CommandDialog = BaseDialog.Root;
function CommandDialogTrigger({ ...props }: BaseDialog.Trigger.Props) {
return ;
}
function CommandDialogPortal({ ...props }: BaseDialog.Portal.Props) {
return ;
}
function CommandDialogBackdrop({
className,
...props
}: BaseDialog.Backdrop.Props) {
return (
);
}
function CommandDialogViewport({
className,
...props
}: BaseDialog.Viewport.Props) {
return (
);
}
function CommandDialogPopup({
className,
children,
...props
}: BaseDialog.Popup.Props) {
return (
{children}
);
}
function CommandInput({
className,
loading = false,
...props
}: AutocompleteBase.Input.Props & { loading?: boolean }) {
return (
}
{...props}
/>
);
}
function CommandContent({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
{children}
);
}
function CommandList({
className,
children,
emptyMessage = "No results found.",
nativeScroll = false,
fadeEdges = true,
scrollbarGutter = false,
persistScrollbar,
hideScrollbar,
...props
}: AutocompleteBase.List.Props &
Pick<
ScrollAreaProps,
| "nativeScroll"
| "fadeEdges"
| "scrollbarGutter"
| "persistScrollbar"
| "hideScrollbar"
> & {
emptyMessage?: React.ReactNode;
}) {
return (
<>
{emptyMessage}
{children}
>
);
}
function CommandVirtualizedList({
className,
children,
scrollRef,
totalSize,
emptyMessage = "No results found.",
fadeEdges = "y",
nativeScroll = false,
...props
}: Omit, "ref"> &
Pick & {
scrollRef: (element: HTMLDivElement | null) => void;
totalSize: number;
emptyMessage?: React.ReactNode;
}) {
return (
<>
{emptyMessage}
{/* Spacer that gives TanStack Virtual its total scroll height */}
{children}
>
);
}
function CommandGroup({
className,
children,
...props
}: AutocompleteBase.Group.Props) {
return (
{children}
);
}
function CommandGroupLabel({
className,
...props
}: AutocompleteBase.GroupLabel.Props) {
return (
);
}
function CommandSeparator({
className,
...props
}: AutocompleteBase.Separator.Props) {
return (
);
}
function CommandItem({
className,
ref,
...props
}: AutocompleteBase.Item.Props & {
ref?: React.Ref;
}) {
return (
);
}
function CommandCollection({ ...props }: AutocompleteBase.Collection.Props) {
return (
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
);
}
function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
);
}
export {
Command,
CommandDialog,
CommandDialogTrigger,
CommandDialogPortal,
CommandDialogBackdrop,
CommandDialogViewport,
CommandDialogPopup,
CommandInput,
CommandContent,
CommandList,
CommandVirtualizedList,
CommandGroup,
CommandGroupLabel,
CommandCollection,
CommandItem,
CommandShortcut,
CommandSeparator,
CommandFooter,
useCommandFilter,
useCommandFilteredItems,
};
```
Create `lib/cubby-ui/elevated.tsx`:
```tsx
import type * as React from "react";
import { cn } from "@/lib/utils";
export type SurfaceLevel = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
/** Every `SurfaceLevel`, for exhaustive iteration. Keep in sync with the type. */
export const SURFACE_LEVELS = [
1, 2, 3, 4, 5, 6, 7, 8,
] as const satisfies readonly SurfaceLevel[];
export const SURFACE_BG: Record = {
1: "bg-surface-1",
2: "bg-surface-2",
3: "bg-surface-3",
4: "bg-surface-4",
5: "bg-surface-5",
6: "bg-surface-6",
7: "bg-surface-7",
8: "bg-surface-8",
};
/**
* Every shadow below uses the `shadow-(--var)` form, pointing at a single theme
* variable. Two reasons, and both break if you "simplify" it back:
*
* 1. tailwind-merge only treats this form as the `shadow` group. A theme-key
* name and a square-bracket arbitrary value both land in `shadow-color`, so
* a caller's `className="shadow-none"` never overrides them and stylesheet
* order silently picks the winner. (Do not spell those two forms out here:
* Tailwind scans comments, and a bracket example compiles to a real,
* broken utility.)
* 2. The variable is a `--surface-*` source token, not a `@theme` alias. Source
* tokens are re-declared in `.dark`, so they resolve per element. A `@theme`
* alias is declared once on `:root`, which makes a `.dark` scoped to a
* subtree inherit the light value (same trap as `--border`).
*/
export const SURFACE_SHADOW: Record = {
1: "shadow-(--surface-shadow-1)",
2: "shadow-(--surface-shadow-2)",
3: "shadow-(--surface-shadow-3)",
4: "shadow-(--surface-shadow-4)",
5: "shadow-(--surface-shadow-5)",
6: "shadow-(--surface-shadow-6)",
7: "shadow-(--surface-shadow-7)",
8: "shadow-(--surface-shadow-8)",
};
export const SURFACE_RIM: Record = {
1: "after:shadow-(--surface-rim-1)",
2: "after:shadow-(--surface-rim-2)",
3: "after:shadow-(--surface-rim-3)",
4: "after:shadow-(--surface-rim-4)",
5: "after:shadow-(--surface-rim-5)",
6: "after:shadow-(--surface-rim-6)",
7: "after:shadow-(--surface-rim-7)",
8: "after:shadow-(--surface-rim-8)",
};
/**
* Exposes the surface level to descendants as `--popup-surface` so children
* (arrow fills, sticky labels, fade gradients) can track the popup's level
* via `bg-(--popup-surface,var(--popover))`.
*/
export const SURFACE_VAR: Record = {
1: "[--popup-surface:var(--surface-1)]",
2: "[--popup-surface:var(--surface-2)]",
3: "[--popup-surface:var(--surface-3)]",
4: "[--popup-surface:var(--surface-4)]",
5: "[--popup-surface:var(--surface-5)]",
6: "[--popup-surface:var(--surface-6)]",
7: "[--popup-surface:var(--surface-7)]",
8: "[--popup-surface:var(--surface-8)]",
};
/**
* Drops + rim insets in a single `box-shadow` (no `::after`). Used by `solidSurface()`.
* Literal Tailwind classes so the scanner picks them up.
*/
export const SURFACE_SHADOW_COMBINED: Record = {
1: "shadow-(--surface-shadow-combined-1)",
2: "shadow-(--surface-shadow-combined-2)",
3: "shadow-(--surface-shadow-combined-3)",
4: "shadow-(--surface-shadow-combined-4)",
5: "shadow-(--surface-shadow-combined-5)",
6: "shadow-(--surface-shadow-combined-6)",
7: "shadow-(--surface-shadow-combined-7)",
8: "shadow-(--surface-shadow-combined-8)",
};
/**
* Near-black fill in BOTH themes, deliberately OFF the elevation ladder. For
* persistent app chrome (a header bar) and for floating labels that should read
* as instrument rather than page.
*
* Pair it with `data-surface="chrome"` on the same element — that attribute
* re-points `--foreground`, `--border` and the washes for the subtree, so
* ordinary page classes keep working inside. Without it, contents paint
* dark-on-dark.
*
* Fill only, like `SURFACE_BG` + `SURFACE_VAR`. Add the edge separately with
* `shadow-(--chrome-shadow)` for anything LARGE: in dark it is not decoration,
* since the fill measures 1.08:1 against the page and that token is the only
* thing defining the surface (light is 16.3:1 and needs none). Leave it off
* for anything small, as the chrome `Tooltip` does.
*/
export const CHROME_SURFACE = "bg-chrome [--popup-surface:var(--chrome)]";
export function surfaceClasses(
level: SurfaceLevel,
shadowLevel: SurfaceLevel = level,
): string {
return `${SURFACE_BG[level]} ${SURFACE_SHADOW[shadowLevel]} ${SURFACE_VAR[level]}`;
}
/**
* Like `surfaceClasses`, but adds a `::after` overlay that re-paints the rim
* above the container's children. Use on elevated containers with opaque
* children near their edges (sticky labels, pinned inputs, dialog headers).
*
* Host requirements: positioned, `border-radius` class, clipped overflow.
* Overlay is `z-index: 2` — bump if children exceed that.
*/
export function elevatedSurface(
level: SurfaceLevel,
shadowLevel: SurfaceLevel = level,
): string {
return `${SURFACE_BG[level]} ${SURFACE_SHADOW[shadowLevel]} ${SURFACE_RIM[level]} ${SURFACE_VAR[level]} after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:z-[2]`;
}
/**
* Like `elevatedSurface`, but paints the rim in the popup's own `box-shadow`
* (no `::after`) — leaves `::after` free for other purposes. Use for elevated
* containers with no opaque children near their edges; switch to
* `elevatedSurface()` if you add a sticky/opaque child.
*
* When `level !== shadowLevel` the rim color tracks `shadowLevel`; for
* independent rim/drop control use `elevatedSurface` instead.
*/
export function solidSurface(
level: SurfaceLevel,
shadowLevel: SurfaceLevel = level,
): string {
return `${SURFACE_BG[level]} ${SURFACE_SHADOW_COMBINED[shadowLevel]} ${SURFACE_VAR[level]}`;
}
/**
* `::after` classes for a 1px inset rim on a single edge — for viewport-flush
* containers (Sheet/Drawer) so the rim only shows on the inner-facing edge.
* Color is `--surface-rim-color` (transparent in light, ~4% white in dark).
*/
export const SURFACE_RIM_EDGE: Record<
"top" | "bottom" | "left" | "right",
string
> = {
top: "after:shadow-[inset_0_1px_0_0_var(--surface-rim-color)]",
bottom: "after:shadow-[inset_0_-1px_0_0_var(--surface-rim-color)]",
left: "after:shadow-[inset_1px_0_0_0_var(--surface-rim-color)]",
right: "after:shadow-[inset_-1px_0_0_0_var(--surface-rim-color)]",
};
/**
* `::after` overlay with a 1px rim on the specified inner-facing edge.
* Pair with `surfaceClasses()` (NOT `elevatedSurface()`) for flush variants.
*/
export function innerEdgeRim(
innerEdge: "top" | "bottom" | "left" | "right",
): string {
return `after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:z-[2] ${SURFACE_RIM_EDGE[innerEdge]}`;
}
/** Maps attach-side → inner-facing edge for `innerEdgeRim`. */
export const INNER_EDGE_FROM_ATTACH_SIDE: Record<
"top" | "bottom" | "left" | "right",
"top" | "bottom" | "left" | "right"
> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
/**
* Directional drop shadow for a viewport-flush surface: casts toward the
* inner-facing edge (into the content) rather than always straight down. A
* flush surface only sheds shadow on the one edge that isn't pinned to the
* viewport, so the radial `--surface-shadow-N` recipe (which casts down on
* every surface) points the wrong way for top/bottom/left attach sides.
*
* This is the level-5 recipe, rotated per edge. It matches the floating
* variant's edge ring EXACTLY by mirroring how that recipe handles the ring
* per mode: the base shadow uses --surface-shadow-ring (light: 4.5%) and the
* `dark:` shadow inlines oklch(0 0 0 / 0.12) (the level-5 dark ring). The drop
* layers reference --surface-shadow-near/mid/far, which already redefine per
* mode, so they're identical between the two and don't need splitting.
* The ring casts on all four edges — the three viewport-pinned ones fall
* off-screen.
*/
export const INNER_EDGE_SHADOW: Record<
"top" | "bottom" | "left" | "right",
string
> = {
left: "shadow-[0_0_0_1px_var(--surface-shadow-ring),-1px_0_1px_-0.5px_var(--surface-shadow-near),-3px_0_3px_-1.5px_var(--surface-shadow-mid),-6px_0_6px_-3px_var(--surface-shadow-mid),-12px_0_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),-1px_0_1px_-0.5px_var(--surface-shadow-near),-3px_0_3px_-1.5px_var(--surface-shadow-mid),-6px_0_6px_-3px_var(--surface-shadow-mid),-12px_0_12px_-6px_var(--surface-shadow-far)]",
right:
"shadow-[0_0_0_1px_var(--surface-shadow-ring),1px_0_1px_-0.5px_var(--surface-shadow-near),3px_0_3px_-1.5px_var(--surface-shadow-mid),6px_0_6px_-3px_var(--surface-shadow-mid),12px_0_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),1px_0_1px_-0.5px_var(--surface-shadow-near),3px_0_3px_-1.5px_var(--surface-shadow-mid),6px_0_6px_-3px_var(--surface-shadow-mid),12px_0_12px_-6px_var(--surface-shadow-far)]",
top: "shadow-[0_0_0_1px_var(--surface-shadow-ring),0_-1px_1px_-0.5px_var(--surface-shadow-near),0_-3px_3px_-1.5px_var(--surface-shadow-mid),0_-6px_6px_-3px_var(--surface-shadow-mid),0_-12px_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),0_-1px_1px_-0.5px_var(--surface-shadow-near),0_-3px_3px_-1.5px_var(--surface-shadow-mid),0_-6px_6px_-3px_var(--surface-shadow-mid),0_-12px_12px_-6px_var(--surface-shadow-far)]",
bottom:
"shadow-[0_0_0_1px_var(--surface-shadow-ring),0_1px_1px_-0.5px_var(--surface-shadow-near),0_3px_3px_-1.5px_var(--surface-shadow-mid),0_6px_6px_-3px_var(--surface-shadow-mid),0_12px_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),0_1px_1px_-0.5px_var(--surface-shadow-near),0_3px_3px_-1.5px_var(--surface-shadow-mid),0_6px_6px_-3px_var(--surface-shadow-mid),0_12px_12px_-6px_var(--surface-shadow-far)]",
};
/**
* Flush-surface elevation: bg + a directional drop shadow cast toward the
* inner edge + the single-edge inset rim on that edge. Use for viewport-flush
* containers (Sheet/Drawer `default` variant) instead of `solidSurface` (whose
* all-around, downward shadow only suits a free-floating surface). `innerEdge`
* is the content-facing edge — derive it from the attach side via
* `INNER_EDGE_FROM_ATTACH_SIDE`.
*/
export function flushSurface(
level: SurfaceLevel,
innerEdge: "top" | "bottom" | "left" | "right",
): string {
return `${SURFACE_BG[level]} ${SURFACE_VAR[level]} ${INNER_EDGE_SHADOW[innerEdge]} ${innerEdgeRim(innerEdge)}`;
}
export interface ElevatedProps extends React.ComponentProps<"div"> {
level: SurfaceLevel;
shadowLevel?: SurfaceLevel;
}
function Elevated({ level, shadowLevel, className, ...props }: ElevatedProps) {
return (
);
}
export { Elevated };
```
## Usage [#usage]
**Imports:**
```tsx
import {
Command,
CommandInput,
CommandList,
CommandGroup,
CommandGroupLabel,
CommandItem,
} from "@/components/ui/cubby-ui/command"
```
**Basic Usage:**
```tsx
Suggestions
Calendar
Search
```
## Composition [#composition]
```text
Command
└── CommandContent
├── CommandInput
└── CommandList
└── CommandGroup
├── CommandGroupLabel
├── CommandSeparator
└── CommandCollection
└── CommandItem
└── CommandShortcut
```
Wrap the whole menu in `CommandDialog` to open it as a modal, and add
`CommandFooter` below `CommandContent` for navigation hints.
## Examples [#examples]
### Command Dialog [#command-dialog]
Wrap the menu in `CommandDialog` to open it as a ⌘K modal.
```tsx
// command-command-dialog.tsx
"use client";
import {
Command,
CommandCollection,
CommandContent,
CommandDialog,
CommandDialogPopup,
CommandDialogTrigger,
CommandGroup,
CommandGroupLabel,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/cubby-ui/command";
import { Button } from "@/components/ui/cubby-ui/button";
import { Kbd } from "@/components/ui/cubby-ui/kbd";
import * as React from "react";
import { useState, useEffect } from "react";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import {
CalculatorIcon,
Calendar01Icon,
CreditCardIcon,
Settings01Icon,
SmileIcon,
UserIcon,
} from "@hugeicons/core-free-icons";
interface CommandItemData {
value: string;
label: string;
icon: IconSvgElement;
shortcut?: string;
}
interface CommandGroupData {
label: string;
items: CommandItemData[];
}
const commandGroups: CommandGroupData[] = [
{
label: "Suggestions",
items: [
{ value: "calendar", label: "Calendar", icon: Calendar01Icon },
{ value: "search-emoji", label: "Search Emoji", icon: SmileIcon },
{ value: "calculator", label: "Calculator", icon: CalculatorIcon },
],
},
{
label: "Settings",
items: [
{ value: "profile", label: "Profile", icon: UserIcon, shortcut: "⌘P" },
{ value: "billing", label: "Billing", icon: CreditCardIcon, shortcut: "⌘B" },
{ value: "settings", label: "Settings", icon: Settings01Icon, shortcut: "⌘S" },
],
},
];
export default function CommandCommandDialog() {
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setDialogOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
Press to open
Open Command Palette}
/>
{(group: CommandGroupData, index: number) => (
{index > 0 && }
{group.label}
{(item: CommandItemData) => {
return (
{item.label}
{item.shortcut && (
{item.shortcut}
)}
);
}}
)}
);
}
```
### With Footer [#with-footer]
Use `CommandFooter` to show keyboard navigation hints below the list.
```tsx
// command-with-footer.tsx
"use client";
import {
Command,
CommandCollection,
CommandContent,
CommandDialog,
CommandDialogPopup,
CommandDialogTrigger,
CommandFooter,
CommandGroup,
CommandGroupLabel,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/cubby-ui/command";
import { Button } from "@/components/ui/cubby-ui/button";
import { Kbd } from "@/components/ui/cubby-ui/kbd";
import * as React from "react";
import { useState, useEffect } from "react";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import {
ArrowTurnBackwardIcon,
ChevronDownIcon,
ChevronUpIcon,
File01Icon,
Folder01Icon,
Home01Icon,
Search01Icon,
Settings01Icon,
} from "@hugeicons/core-free-icons";
interface CommandItemData {
value: string;
label: string;
icon: IconSvgElement;
type?: string;
}
interface CommandGroupData {
label: string;
items: CommandItemData[];
}
const commandGroups: CommandGroupData[] = [
{
label: "Navigation",
items: [
{ value: "home", label: "Home", icon: Home01Icon },
{ value: "settings", label: "Settings", icon: Settings01Icon },
{ value: "search", label: "Search", icon: Search01Icon },
],
},
{
label: "Files",
items: [
{
value: "documents",
label: "Documents",
icon: Folder01Icon,
type: "Folder",
},
{ value: "readme", label: "README.md", icon: File01Icon, type: "File" },
{
value: "package",
label: "package.json",
icon: File01Icon,
type: "File",
},
{ value: "config", label: "config.json", icon: File01Icon, type: "File" },
{
value: "settings",
label: "settings.json",
icon: File01Icon,
type: "File",
},
{ value: "index", label: "index.html", icon: File01Icon, type: "File" },
{ value: "style", label: "style.css", icon: File01Icon, type: "File" },
{ value: "script", label: "script.js", icon: File01Icon, type: "File" },
{ value: "image", label: "image.png", icon: File01Icon, type: "File" },
{ value: "video", label: "video.mp4", icon: File01Icon, type: "File" },
{ value: "audio", label: "audio.mp3", icon: File01Icon, type: "File" },
{ value: "pdf", label: "pdf.pdf", icon: File01Icon, type: "File" },
{ value: "word", label: "word.docx", icon: File01Icon, type: "File" },
{ value: "excel", label: "excel.xlsx", icon: File01Icon, type: "File" },
{
value: "powerpoint",
label: "powerpoint.pptx",
icon: File01Icon,
type: "File",
},
{ value: "zip", label: "zip.zip", icon: File01Icon, type: "File" },
{ value: "rar", label: "rar.rar", icon: File01Icon, type: "File" },
{ value: "7z", label: "7z.7z", icon: File01Icon, type: "File" },
{ value: "tar", label: "tar.tar", icon: File01Icon, type: "File" },
{ value: "gz", label: "gz.gz", icon: File01Icon, type: "File" },
],
},
];
export default function CommandWithFooter() {
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setDialogOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
Press to open
Open Command Menu} />
{(group: CommandGroupData, index: number) => (
{index > 0 && }
{group.label}
{(item: CommandItemData) => {
return (
{item.label}
{item.type && (
{item.type}
)}
);
}}
)}
esc
to close
);
}
```
### Fuzzy Search [#fuzzy-search]
Use `useFuzzyFilter` to match across multiple fields and tolerate typos.
```tsx
// command-fuzzy.tsx
"use client";
import * as React from "react";
import {
Command,
CommandCollection,
CommandContent,
CommandDialog,
CommandDialogPopup,
CommandDialogTrigger,
CommandFooter,
CommandGroup,
CommandGroupLabel,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/cubby-ui/command";
import { useFuzzyFilter } from "@/registry/default/hooks/use-fuzzy-filter";
import { highlightText } from "@/registry/default/lib/highlight-text";
import { Button } from "@/components/ui/cubby-ui/button";
import { Kbd } from "@/components/ui/cubby-ui/kbd";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import {
ArrowTurnBackwardIcon,
BubbleChatIcon,
CalculatorIcon,
Calendar01Icon,
ChevronDownIcon,
ChevronUpIcon,
CreditCardIcon,
File02Icon,
Mail01Icon,
Settings01Icon,
UserIcon,
} from "@hugeicons/core-free-icons";
interface CommandItemData {
value: string;
label: string;
description: string;
icon: IconSvgElement;
}
type CommandGroupData = {
label: string;
items: CommandItemData[];
};
const commandGroups: CommandGroupData[] = [
{
label: "Quick Actions",
items: [
{
value: "new-document",
label: "New Document",
description: "Create a new blank document",
icon: File02Icon,
},
{
value: "send-message",
label: "Send Message",
description: "Compose and send a new message",
icon: BubbleChatIcon,
},
{
value: "schedule-event",
label: "Schedule Event",
description: "Add an event to your calendar",
icon: Calendar01Icon,
},
],
},
{
label: "Tools",
items: [
{
value: "calculator",
label: "Calculator",
description: "Open the calculator tool",
icon: CalculatorIcon,
},
{
value: "email-client",
label: "Email Client",
description: "Check your inbox and emails",
icon: Mail01Icon,
},
],
},
{
label: "Account",
items: [
{
value: "profile-settings",
label: "Profile Settings",
description: "Manage your profile information",
icon: UserIcon,
},
{
value: "billing-info",
label: "Billing Information",
description: "View and update payment methods",
icon: CreditCardIcon,
},
{
value: "preferences",
label: "Preferences",
description: "Customize app settings and behavior",
icon: Settings01Icon,
},
],
},
];
export default function CommandFuzzy() {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const { filterItem } = useFuzzyFilter({
keys: [
{ key: "label", threshold: "contains" },
{ key: "description", threshold: "word-starts-with" },
"value",
],
});
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setDialogOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
Press to open
Fuzzy Search Commands} />
{(group: CommandGroupData, index: number) => (
{index > 0 && }
{group.label}
{(item: CommandItemData) => {
return (
{highlightText(item.label, query)}
{highlightText(item.description, query)}
);
}}
)}
esc
to close
);
}
```
### Virtualized [#virtualized]
Use `CommandVirtualizedList` for large lists (100+ items); it only renders the items in view.
```tsx
// command-virtualized.tsx
"use client";
import * as React from "react";
import {
Command,
CommandContent,
CommandDialog,
CommandDialogPopup,
CommandDialogTrigger,
CommandInput,
CommandItem,
CommandVirtualizedList,
useCommandFilteredItems,
} from "@/components/ui/cubby-ui/command";
import {
useListVirtualizer,
useHighlightHandler,
type ListVirtualizerInstance,
} from "@/registry/default/hooks/use-list-virtualizer";
import { highlightText } from "@/registry/default/lib/highlight-text";
import { Button } from "@/components/ui/cubby-ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { File01Icon } from "@hugeicons/core-free-icons";
interface FileItem {
id: string;
name: string;
}
// Generate 1000 mock files
const allFiles: FileItem[] = Array.from({ length: 1000 }, (_, i) => ({
id: `file-${i}`,
name: `file-${i}.tsx`,
}));
const getItemLabel = (item: FileItem | null) => item?.name ?? "";
export default function CommandVirtualized() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const virtualizerRef = React.useRef(null);
const onItemHighlighted = useHighlightHandler(virtualizerRef);
return (
Search 1,000 Files} />
);
}
function VirtualizedListContent({
query,
open,
virtualizerRef,
}: {
query: string;
open: boolean;
virtualizerRef: React.RefObject;
}) {
const filteredItems = useCommandFilteredItems();
const {
scrollRef,
measureRef,
totalSize,
virtualItems,
getItem,
getItemStyle,
getItemProps,
} = useListVirtualizer({
enabled: open,
items: allFiles,
filteredItems,
estimateSize: 40,
virtualizerRef,
});
return (
{virtualItems.map((virtualItem) => {
const item = getItem(virtualItem);
if (!item) return null;
return (
{highlightText(item.name, query)}
);
})}
);
}
```
**Key concepts:**
1. **Use `useListVirtualizer` hook** - Handles scroll positioning, keyboard navigation, and item measurement. See [useListVirtualizer docs](/docs/hooks/use-list-virtualizer) for full API reference.
2. **Use `useCommandFilteredItems()` inside Command** - Call `useCommandFilteredItems()` in a child component of `Command` to get internally filtered items without manual filtering boilerplate.
3. **Replace `CommandList` with `CommandVirtualizedList`** - Accepts `scrollRef` and `totalSize` from the hook.
4. **Pass `virtualized` and `items` on Command** - Enables virtualized mode with your full item list.
5. **Use `useHighlightHandler`** - Pass a `virtualizerRef` to the child and use `useHighlightHandler(virtualizerRef)` on the parent so keyboard navigation scrolls to the highlighted item.
> **Manual filtering still works.** If you need custom filter logic (fuzzy search, server-side filtering), you can still compute `filteredItems` externally, pass `filter={null}` to `Command`, and spread `rootProps` from `useListVirtualizer`. See [useCommandFilter](#usecommandfilter).
## Accessibility [#accessibility]
### Keyboard navigation [#keyboard-navigation]
The menu is operable entirely from the keyboard: type to filter, `↑`/`↓` to move
between items, and `Enter` to select. `autoHighlight="always"` keeps an item
highlighted at all times so `Enter` always has a target.
### Label the input [#label-the-input]
`CommandInput` is the search field. Give it a `placeholder`, and when the menu
opens outside a labeled `CommandDialog`, add an `aria-label` so its purpose is
announced.
```tsx showLineNumbers
```
## API Reference [#api-reference]
The Command component is built on top of [Base UI's Autocomplete](https://base-ui.com/react/components/autocomplete) and [Dialog](https://base-ui.com/react/components/dialog). All Base UI props are supported. The documentation below only covers custom props and modified defaults specific to our implementation.
For the complete Base UI API, see the [Base UI Autocomplete documentation](https://base-ui.com/react/components/autocomplete).
### Props [#props]
#### Command [#command]
Root component that manages command menu state and keyboard navigation. Wraps Base UI's `Autocomplete.Root`.
- **items** (type: `array`, full: `T[] | { items: T[] }[]`) - Default: `[]`
Array of items or grouped items to display. Used with the collection pattern
where `CommandList`, `CommandGroup`, and `CommandCollection` receive render
functions as children.
- **autoHighlight** (type: `boolean | "always"`) - Default: `"always"`
Whether the first matching item is highlighted automatically. Set to
`"always"` to ensure an item is always highlighted during keyboard navigation.
Base UI defaults to `false`.
- **open** (type: `boolean`) - Default: `true`
Keeps the autocomplete list always open. Appropriate for command menu behavior
where the list is always visible. Base UI defaults to `false`.
- **keepHighlight** (type: `boolean`) - Default: `true`
Whether the highlighted item should be preserved when the pointer leaves the
list. Prevents the highlight from going to a "null" state during navigation.
Base UI defaults to `false`.
- **level** (type: `1 | 2 | 3 | 4 | 5 | 6 | 7 | 8`) - Default: `3`
Surface elevation level for the outer Command frame. See the [Surfaces](/docs/getting-started/surfaces) docs. Bump to `7` when rendered inside `CommandDialog` so the palette pops above the dialog.
- **shadowLevel** (type: `1 | 2 | 3 | 4 | 5 | 6 | 7 | 8`) - Default: `3`
Shadow weight for the outer frame. The bg is pinned to `bg-muted` (gray frame); only the shadow + rim track this prop.
#### CommandList [#commandlist]
Scrollable container for command items with empty state handling. Wraps Base UI's `Autocomplete.List`.
- **emptyMessage** (type: `node`, full: `ReactNode`) - Default: `"No results found."`
Custom prop not available in Base UI. Message shown when no results are found
in the command list.
- **nativeScroll** (type: `boolean`) - Default: `false`
Use native browser scrolling with styled scrollbars instead of Base UI's
scroll virtualization. Enables CSS scroll-driven animations for fade edges.
- **fadeEdges** (type: `boolean | 'top' | 'bottom' | 'left' | 'right' | 'x' | 'y' | FadeEdge[]`) - Default: `true`
Controls the fade effect on scroll edges. See
[ScrollArea](/docs/components/scroll-area#custom-props) for all options.
- **scrollbarGutter** (type: `boolean`) - Default: `false`
Reserves space for the scrollbar when content overflows, preventing layout
shift.
- **persistScrollbar** (type: `boolean`) - Default: `false`
Always show the scrollbar instead of fading it in on hover or scroll.
- **hideScrollbar** (type: `boolean`) - Default: `false`
Hides the scrollbar while keeping scroll functionality.
#### CommandVirtualizedList [#commandvirtualizedlist]
Virtualized scrollable container for large lists. Use with `useListVirtualizer` hook for optimal performance with 100+ items.
- **scrollRef**
void">
Ref callback from `useListVirtualizer` for the scroll container.
- **totalSize** (type: `number`)
Total height of all virtual items in pixels. Provided by `useListVirtualizer`.
- **emptyMessage** (type: `node`, full: `ReactNode`) - Default: `"No results found."`
Message shown when no results are found.
- **fadeEdges** (type: `boolean | 'x' | 'y' | FadeEdge[]`) - Default: `"y"`
Controls the fade effect on scroll edges.
- **nativeScroll** (type: `boolean`) - Default: `false`
Use native browser scrolling instead of custom scrollbars.
### Utilities [#utilities]
#### highlightText [#highlighttext]
Highlights matching portions of text by wrapping them in `` tags. Useful for showing which parts of a command match the user's input.
See [highlightText](/docs/utils/highlight-text) for full documentation and API reference.
### Hooks [#hooks]
#### useCommandFilter [#usecommandfilter]
```ts
function useCommandFilter(): {
contains: (
item: T,
query: string,
getItemLabel: (item: T) => string,
) => boolean;
startsWith: (
item: T,
query: string,
getItemLabel: (item: T) => string,
) => boolean;
endsWith: (
item: T,
query: string,
getItemLabel: (item: T) => string,
) => boolean;
};
```
Exposes Base UI's built-in filter matchers for external filtering. Useful when you need manual control over filtering (e.g., with custom logic or server-side filtering).
**Returns**
| Property | Type | Description |
| ------------ | ---------------------------------------- | ------------------------------------------------------------ |
| `contains` | `(item, query, getItemLabel) => boolean` | Returns true if item label contains query (case-insensitive) |
| `startsWith` | `(item, query, getItemLabel) => boolean` | Returns true if item label starts with query |
| `endsWith` | `(item, query, getItemLabel) => boolean` | Returns true if item label ends with query |
```tsx
import { useCommandFilter } from "@/registry/default/command/command";
const { contains } = useCommandFilter();
const getItemLabel = (item: Item | null) => item?.name ?? "";
const filteredItems = useMemo(
() => items.filter((item) => contains(item, query, getItemLabel)),
[contains, query],
);
```
#### useCommandFilteredItems [#usecommandfiltereditems]
```ts
function useCommandFilteredItems(): T[];
```
Returns the internally filtered items from the Command root. Must be called inside `Command`. Simplifies virtualized implementations by eliminating manual query state, `useDeferredValue`, `useFilter()`, and `useMemo` filtering.
```tsx
import { useCommandFilteredItems } from "@/registry/default/command/command";
function VirtualizedListContent() {
// Must be called inside Command
const filteredItems = useCommandFilteredItems();
const virtualizer = useListVirtualizer({
items: allItems,
filteredItems,
});
// ...
}
```
#### useFuzzyFilter [#usefuzzyfilter]
Provides fuzzy matching capabilities for flexible searching across multiple fields. Returns filter functions compatible with Base UI's Autocomplete.
See [useFuzzyFilter](/docs/hooks/use-fuzzy-filter) for full documentation and API reference.
#### useListVirtualizer [#uselistvirtualizer]
Provides virtualization for large command lists using TanStack Virtual. Abstracts scroll handling, item positioning, and keyboard navigation.
See [useListVirtualizer](/docs/hooks/use-list-virtualizer) for full documentation and API reference.
# Copy Button (/docs/components/copy-button)
The copy button copies a string to the clipboard and animates to a checkmark to confirm. If the copy fails (for example, when clipboard permissions are blocked), it shows an error icon instead. Use it beside code snippets, API keys, share links, or any value a user needs to grab.
For a full multi-line code sample with its own copy control, use [`CodeBlock`](/docs/components/code-block) instead.
## Preview [#preview]
```tsx
// copy-button-demo.tsx
"use client";
import { CopyButton } from "@/components/ui/cubby-ui/copy-button/copy-button";
export default function CopyButtonDemo() {
const textToCopy = "npm install @base-ui/react";
return (
);
}
```
## Installation [#installation]
### Installation
**CLI:**
```bash
npx shadcn@latest add @cubby-ui/copy-button
```
**Manual:**
1. Install dependencies:
```bash
npm install @hugeicons/react @hugeicons/core-free-icons
```
2. Copy the component source code to your project:
Create `components/ui/cubby-ui/copy-button/copy-button.tsx`:
```tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/cubby-ui/button";
import { cn } from "@/lib/utils";
import { useCopyToClipboard } from "@/registry/default/copy-button/hooks/use-copy-to-clipboard";
import {
toast as toastApi,
type AnchoredToastOptions,
} from "@/components/ui/cubby-ui/toast/toast";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Cancel01Icon,
CheckIcon,
Copy01Icon,
} from "@hugeicons/core-free-icons";
type CopyButtonToastConfig = Omit;
const DEFAULT_COPY_ICON = (
);
const DEFAULT_CHECK_ICON = (
);
const DEFAULT_ERROR_ICON = (
);
interface CopyButtonProps extends Omit<
React.ComponentProps,
"onClick" | "children" | "size" | "variant"
> {
content: string;
timeout?: number;
copyIcon?: React.ReactNode;
checkIcon?: React.ReactNode;
errorIcon?: React.ReactNode;
onCopied?: (text: string) => void;
onCopyError?: (text: string) => void;
/**
* Show an anchored toast above the button on successful copy.
* Pass `true` for defaults, or an options object to customize the toast.
*/
toast?: true | CopyButtonToastConfig;
}
function CopyButton({
content,
timeout = 2000,
className,
copyIcon,
checkIcon,
errorIcon,
onCopied,
onCopyError,
toast,
ref,
...props
}: CopyButtonProps) {
const internalRef = React.useRef(null);
const toastEnabled = Boolean(toast);
const toastConfig: CopyButtonToastConfig =
toast === true ? {} : (toast ?? {});
const { isCopied, isError, copyToClipboard, reset } = useCopyToClipboard({
// When an anchored toast is attached, the toast's lifecycle owns the
// reset via `onClose` — so disable the hook's internal auto-reset.
timeout: toastEnabled ? null : timeout,
onCopied: (text) => {
onCopied?.(text);
if (toastEnabled) {
toastApi.anchored({
description: "Copied to clipboard!",
side: "top",
sideOffset: 8,
arrow: true,
duration: timeout,
...toastConfig,
anchor: internalRef,
onClose: () => {
reset();
toastConfig.onClose?.();
},
});
}
},
onCopyError: (text) => {
onCopyError?.(text);
if (toastEnabled) {
toastApi.anchored({
side: "top",
sideOffset: 8,
arrow: true,
duration: timeout,
...toastConfig,
// The configurable description is the success message — the error
// toast always states the failure.
description: "Failed to copy to clipboard",
anchor: internalRef,
onClose: () => {
reset();
toastConfig.onClose?.();
},
});
}
},
});
const mergedRef = React.useCallback(
(node: HTMLButtonElement | null) => {
internalRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const button = (
the instant their own keypress succeeds. Base UI trades the
// native `disabled` attribute for aria-disabled to do that, which leaves
// the element clickable — it blocks the keyboard path itself, and the
// guard below is the pointer half.
focusableWhenDisabled
onClick={() => {
if (isCopied) return;
copyToClipboard(content);
}}
className={cn(
"text-muted-foreground size-auto rounded-md p-1.5",
className,
)}
// Deliberately fixed while the state underneath is not. Focus stays here
// now, so a label that rewrote itself mid-copy would be a second
// announcement racing the live region below, and which of the two a
// screen reader reads is up to the screen reader. The label names the
// control, the region reports the outcome.
aria-label="Copy to clipboard"
title={isCopied ? "Copied!" : isError ? "Copy failed" : "Copy"}
{...props}
>
{/* The three icons crossfade in place, so they share one grid cell.
This wrapper owns that grid rather than styling Button's internal
content span through `[&>span]`. That span is Button's private DOM,
and reaching for it is not a theoretical risk: it did not exist until
the Button redesign added it, so for every release before that one
`[&>span]` matched these three icons themselves and stacked nothing —
they sat side by side, two of them invisible but still taking width.
The selector did not break, it silently changed which element it
meant, in both directions, unnoticed.
`grid-template-areas` on the Button root was inert throughout: the
root is `inline-flex`, so it is not a grid. Declaring the area here
is what lets `[grid-area:stack]` below resolve by name rather than
through the implicit-line fallback it has been leaning on. */}
{copyIcon ?? DEFAULT_COPY_ICON}
{checkIcon ?? DEFAULT_CHECK_ICON}
{errorIcon ?? DEFAULT_ERROR_ICON}
);
// The icons are aria-hidden and the label is fixed, so this is the only
// thing that reports the outcome. Two rules govern where it can live.
//
// Mounted for the whole life of the button, never conditionally rendered
// around the result: a region that arrives already holding its text is not a
// change, and screen readers announce the change.
//
// And a SIBLING of the button, not a child. `role="button"` is Children
// Presentational, so a conforming reader prunes the semantics of everything
// inside it — a live region in there is not a live region. This is why the
// fixed `aria-label` above is safe: the region genuinely does the reporting.
//
// Suppressed entirely when a toast is attached, which says the same words out
// loud on its own.
//
// `display: contents` rather than a bare fragment, so the component still
// resolves to one element for anything that counts them — `Children.only`,
// a `render` prop, `:only-child`, `> * + *`. It adds no box, so it creates no
// containing block and the absolutely-positioned floating variant in
// CodeBlock still resolves against the same ancestor it did before.
return (
{button}
{!toastEnabled && (
{isCopied
? "Copied to clipboard"
: isError
? "Failed to copy to clipboard. Copy it manually."
: ""}
)}
);
}
export { CopyButton };
```
Create `components/ui/cubby-ui/copy-button/hooks/use-copy-to-clipboard.ts`:
```tsx
"use client";
import { useCallback, useEffect, useState } from "react";
async function writeToClipboard(text: string): Promise {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
return success;
} catch {
return false;
}
}
}
export interface UseCopyToClipboardOptions {
/** ms before `isCopied`/`isError` auto-resets. Pass `null` to disable (e.g. when another mechanism owns the lifecycle). */
timeout?: number | null;
onCopied?: (text: string) => void;
onCopyError?: (text: string) => void;
}
export function useCopyToClipboard({
timeout = 2000,
onCopied,
onCopyError,
}: UseCopyToClipboardOptions = {}) {
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
useEffect(() => {
if (status !== "idle" && timeout != null) {
const timer = setTimeout(() => setStatus("idle"), timeout);
return () => clearTimeout(timer);
}
}, [status, timeout]);
const copyToClipboard = useCallback(
async (text: string): Promise => {
const success = await writeToClipboard(text);
if (success) {
setStatus("copied");
onCopied?.(text);
} else {
setStatus("error");
onCopyError?.(text);
}
return success;
},
[onCopied, onCopyError],
);
const reset = useCallback(() => setStatus("idle"), []);
return {
isCopied: status === "copied",
isError: status === "error",
copyToClipboard,
reset,
};
}
```
## Usage [#usage]
**Imports:**
```tsx
import { CopyButton } from "@/components/ui/cubby-ui/copy-button/copy-button";
```
**Basic Usage:**
```tsx
```
## Examples [#examples]
### Custom Icons [#custom-icons]
Pass `copyIcon` and `checkIcon` to replace the default copy and confirmation icons.
```tsx
// copy-button-custom-icons-demo.tsx
"use client";
import { CopyButton } from "@/components/ui/cubby-ui/copy-button/copy-button";
import { HugeiconsIcon } from "@hugeicons/react";
import { Copy02Icon, TickDouble01Icon } from "@hugeicons/core-free-icons";
export default function CopyButtonCustomIconsDemo() {
return (
}
checkIcon={
}
/>
);
}
```
### With Toast [#with-toast]
Set `toast` to show an anchored toast above the button on a successful copy. Pass an options object to customize the description, side, duration, or any other anchored toast option.
```tsx
// copy-button-with-toast.tsx
"use client";
import { CopyButton } from "@/components/ui/cubby-ui/copy-button/copy-button";
export default function CopyButtonWithToast() {
return (
);
}
```
## Accessibility [#accessibility]
The button is icon-only, so it ships with a built-in `aria-label` that switches from `"Copy to clipboard"` to `"Copied to clipboard"` when the copy succeeds, or `"Copy failed"` when it does not. Do not pass children; if you need a different label, override `aria-label`.
## API Reference [#api-reference]
The CopyButton component extends the [Button component](/docs/components/button) with clipboard functionality. All Button props are supported except `size`, `variant`, `onClick`, and `children` which are controlled internally.
### Props [#props]
#### CopyButton [#copybutton]
Button that copies text to clipboard with visual feedback. Extends the Button component.
- **content** (type: `string`)
The text content to copy to the clipboard when the button is clicked.
- **timeout** (type: `number`) - Default: `2000`
Duration in milliseconds to show the success state (checkmark icon) after
copying. Defaults to 2 seconds.
- **copyIcon** (type: `node`, full: `React.ReactNode`)
Custom icon to display in the default (not copied) state. Defaults to the
lucide Copy icon.
- **checkIcon** (type: `node`, full: `React.ReactNode`)
Custom icon to display in the copied/success state. Defaults to the lucide
Check icon with green color.
- **errorIcon** (type: `node`, full: `React.ReactNode`)
Custom icon to display when the copy fails. Defaults to an X icon with red
color. The error state auto-resets after `timeout`, and the button stays
enabled so the user can retry.
- **toast**
" simpleType="boolean | object">
Show an anchored toast above the button on successful copy. Pass `true` to
use the defaults (`"Copied to clipboard!"`, `side: "top"`, arrow, duration
matches `timeout`), or an options object to override any anchored toast
option except `anchor` (which is always the button itself). If the copy
fails, the toast reads `"Failed to copy to clipboard"` regardless of a
custom `description`.
- **onCopied**
void" simpleType="function">
Fires after a successful copy with the copied text. Runs independently of
`toast`; use it for analytics or extra side effects.
- **onCopyError**
void" simpleType="function">
Fires when the copy fails, with the text that could not be copied. When
`toast` is enabled, a "Failed to copy to clipboard" toast is shown
automatically; use this callback for logging or a custom fallback.
# Data Table (/docs/components/data-table)
The data table renders rows and columns with sorting, filtering, row selection, and pagination. Use it for dashboards, admin lists, and any tabular data that needs interaction. It is built on TanStack Table, so you define columns with `ColumnDef` and compose the table from parts.
## Preview [#preview]
```tsx
// data-table-basic.tsx
"use client"
import {
type ColumnDef,
DataTable,
DataTableContent,
DataTableHeader,
DataTableBody,
} from "@/components/ui/cubby-ui/data-table/data-table"
type Invoice = {
id: string
status: "pending" | "paid" | "unpaid"
method: string
amount: number
}
const columns: ColumnDef[] = [
{
accessorKey: "id",
header: "Invoice",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
{row.getValue("status")}
),
},
{
accessorKey: "method",
header: "Method",
},
{
accessorKey: "amount",
header: () => Amount
,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount)
return {formatted}
},
},
]
const data: Invoice[] = [
{ id: "INV001", status: "paid", amount: 250.0, method: "Credit Card" },
{ id: "INV002", status: "pending", amount: 150.0, method: "PayPal" },
{ id: "INV003", status: "unpaid", amount: 350.0, method: "Bank Transfer" },
{ id: "INV004", status: "paid", amount: 450.0, method: "Credit Card" },
{ id: "INV005", status: "paid", amount: 550.0, method: "PayPal" },
]
export default function DataTableBasic() {
return (
)
}
```
## Installation [#installation]
### Installation
**CLI:**
```bash
npx shadcn@latest add @cubby-ui/data-table
```
**Manual:**
1. Install dependencies:
```bash
npm install @tanstack/react-table @hugeicons/react @hugeicons/core-free-icons
```
2. Copy the component source code to your project:
Create `components/ui/cubby-ui/data-table/data-table.tsx`:
```tsx
"use client";
import * as React from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
type ColumnFiltersState,
type PaginationState,
type VisibilityState,
type OnChangeFn,
type Row,
type Column,
} from "@tanstack/react-table";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowUpDownIcon,
SortByDown01Icon,
SortByUp01Icon,
} from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
import { elevatedSurface } from "@/lib/cubby-ui/elevated";
import { Checkbox } from "@/components/ui/cubby-ui/checkbox";
import {
DataTableContext,
useDataTable,
} from "@/registry/default/data-table/data-table-context";
import {
Toolbar,
ToolbarSeparator,
type ToolbarProps,
} from "@/components/ui/cubby-ui/toolbar/toolbar";
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
type TableProps,
} from "@/components/ui/cubby-ui/table";
import { DataTableSearch } from "@/registry/default/data-table/data-table-search";
import { DataTableColumnToggle } from "@/registry/default/data-table/data-table-column-toggle";
import { DataTablePagination } from "@/registry/default/data-table/data-table-pagination";
export interface DataTableProps {
columns: ColumnDef[];
data: TData[];
children: React.ReactNode;
className?: string;
enableSorting?: boolean;
enableRowSelection?: boolean | ((row: Row) => boolean);
enableMultiRowSelection?: boolean;
enableFiltering?: boolean;
enablePagination?: boolean;
showSelectionColumn?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn;
columnFilters?: ColumnFiltersState;
onColumnFiltersChange?: OnChangeFn;
pagination?: PaginationState;
onPaginationChange?: OnChangeFn;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn;
globalFilter?: string;
onGlobalFilterChange?: OnChangeFn;
getRowId?: (originalRow: TData, index: number, parent?: Row) => string;
}
function DataTable({
columns: userColumns,
data,
children,
className,
enableSorting = false,
enableRowSelection = false,
enableMultiRowSelection = true,
enableFiltering = false,
enablePagination = false,
showSelectionColumn = true,
sorting: controlledSorting,
onSortingChange,
rowSelection: controlledRowSelection,
onRowSelectionChange,
columnFilters: controlledColumnFilters,
onColumnFiltersChange,
pagination: controlledPagination,
onPaginationChange,
columnVisibility: controlledColumnVisibility,
onColumnVisibilityChange,
globalFilter: controlledGlobalFilter,
onGlobalFilterChange,
getRowId,
}: DataTableProps) {
const [internalSorting, setInternalSorting] = React.useState(
[],
);
const [internalRowSelection, setInternalRowSelection] =
React.useState({});
const [internalColumnFilters, setInternalColumnFilters] =
React.useState([]);
const [internalPagination, setInternalPagination] =
React.useState({
pageIndex: 0,
pageSize: 10,
});
const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("");
const [internalColumnVisibility, setInternalColumnVisibility] =
React.useState({});
const sorting = controlledSorting ?? internalSorting;
const rowSelection = controlledRowSelection ?? internalRowSelection;
const columnFilters = controlledColumnFilters ?? internalColumnFilters;
const pagination = controlledPagination ?? internalPagination;
const globalFilter = controlledGlobalFilter ?? internalGlobalFilter;
const columnVisibility =
controlledColumnVisibility ?? internalColumnVisibility;
const columns = React.useMemo(() => {
if (!enableRowSelection || !showSelectionColumn) {
return userColumns;
}
const selectionColumn: ColumnDef = {
id: "__select__",
header: ({ table }) =>
enableMultiRowSelection ? (
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
) : null,
cell: ({ row }) => (
row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
};
return [selectionColumn, ...userColumns];
}, [
userColumns,
enableRowSelection,
enableMultiRowSelection,
showSelectionColumn,
]);
const table = useReactTable({
data,
columns,
getRowId,
state: {
sorting,
rowSelection,
columnFilters,
pagination,
globalFilter,
columnVisibility,
},
onSortingChange: onSortingChange ?? setInternalSorting,
onRowSelectionChange: onRowSelectionChange ?? setInternalRowSelection,
onColumnFiltersChange: onColumnFiltersChange ?? setInternalColumnFilters,
onPaginationChange: onPaginationChange ?? setInternalPagination,
onGlobalFilterChange: onGlobalFilterChange ?? setInternalGlobalFilter,
onColumnVisibilityChange:
onColumnVisibilityChange ?? setInternalColumnVisibility,
getCoreRowModel: getCoreRowModel(),
...(enableSorting && { getSortedRowModel: getSortedRowModel() }),
...(enableFiltering && { getFilteredRowModel: getFilteredRowModel() }),
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
enableRowSelection,
enableMultiRowSelection,
globalFilterFn: "includesString",
});
return (
{children}
);
}
// A plain row above the table: its controls are fields and triggers, not
// toolbar buttons, so it skips the shell's fixed height and highlight.
function DataTableToolbar({
className,
variant = "ghost",
highlight = false,
...props
}: ToolbarProps) {
return (
);
}
export interface DataTableContentProps extends Omit {
children: React.ReactNode;
}
function DataTableContent({
bordered,
striped,
hoverable,
rowDividers = true,
className,
children,
...tableProps
}: DataTableContentProps) {
return (
);
}
function SortableHeader({
column,
children,
align,
isFirst,
isLast,
}: {
column: Column;
children: React.ReactNode;
align?: "left" | "center" | "right";
isFirst?: boolean;
isLast?: boolean;
}) {
return (
column.toggleSorting(column.getIsSorted() === "asc")}
>
{children}
{column.getIsSorted() === "asc" ? (
) : column.getIsSorted() === "desc" ? (
) : (
)}
);
}
export interface DataTableHeaderProps extends React.ComponentProps<
typeof TableHeader
> {
enableSorting?: boolean;
}
function DataTableHeader({
enableSorting = false,
className,
...props
}: DataTableHeaderProps) {
const { table } = useDataTable();
const renderHeader = (
header: ReturnType[0]["headers"][0],
index: number,
totalHeaders: number,
) => {
if (header.isPlaceholder) return null;
const headerDef = header.column.columnDef.header;
const canSort = header.column.getCanSort();
const meta = header.column.columnDef.meta as
| { align?: "left" | "center" | "right" }
| undefined;
if (typeof headerDef === "string" && enableSorting && canSort) {
return (
{headerDef}
);
}
return flexRender(headerDef, header.getContext());
};
return (
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header, index) => (
{renderHeader(header, index, headerGroup.headers.length)}
))}
))}
);
}
export interface DataTableBodyProps extends React.ComponentProps<
typeof TableBody
> {
emptyState?: React.ReactNode;
}
function DataTableBody({
emptyState,
className,
...props
}: DataTableBodyProps) {
const { table } = useDataTable();
const totalColumns = table.getAllColumns().length;
return (
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
))
) : (
{emptyState ?? "No results."}
)}
);
}
export type DataTableFooterProps = React.ComponentProps;
function DataTableFooter({
className,
children,
...props
}: DataTableFooterProps) {
return (
{children}
);
}
export {
DataTable,
DataTableToolbar,
ToolbarSeparator as DataTableToolbarSeparator,
DataTableSearch,
DataTableColumnToggle,
DataTableContent,
DataTableHeader,
DataTableBody,
DataTableFooter,
DataTablePagination,
useDataTable,
};
export type {
ColumnDef,
SortingState,
RowSelectionState,
ColumnFiltersState,
VisibilityState,
PaginationState,
};
```
Create `components/ui/cubby-ui/data-table/data-table-column-toggle.tsx`:
```tsx
"use client";
import * as React from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { FilterHorizontalIcon } from "@hugeicons/core-free-icons";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuCheckboxItem,
} from "@/components/ui/cubby-ui/dropdown-menu";
import { Button } from "@/components/ui/cubby-ui/button";
import { useDataTable } from "@/registry/default/data-table/data-table-context";
function DataTableColumnToggle() {
const { table } = useDataTable();
const toggleableColumns = table
.getAllColumns()
.filter((col) => col.id !== "__select__" && col.getCanHide());
return (
}>
Toggle columns
{toggleableColumns.map((column) => {
const label =
typeof column.columnDef.header === "string"
? column.columnDef.header
: column.id;
return (
column.toggleVisibility(!!checked)}
>
{label}
);
})}
);
}
export { DataTableColumnToggle };
```
Create `components/ui/cubby-ui/data-table/data-table-context.tsx`:
```tsx
"use client"
import * as React from "react"
import { type Table } from "@tanstack/react-table"
interface DataTableContextValue {
table: Table
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic context requires any; type safety is enforced via useDataTable()
const DataTableContext = React.createContext | null>(
null
)
function useDataTable() {
const context = React.useContext(DataTableContext)
if (!context) {
throw new Error("useDataTable must be used within a DataTable")
}
return context as DataTableContextValue
}
export { DataTableContext, useDataTable }
export type { DataTableContextValue }
```
Create `components/ui/cubby-ui/data-table/data-table-pagination.tsx`:
```tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/cubby-ui/button"
import { useDataTable } from "@/registry/default/data-table/data-table-context"
interface DataTablePaginationProps {
className?: string
showSelectedCount?: boolean
}
function DataTablePagination({
className,
showSelectedCount = true,
}: DataTablePaginationProps) {
const { table } = useDataTable()
return (
{showSelectedCount ? (
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
) : (
)}
table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
)
}
export { DataTablePagination }
```
Create `components/ui/cubby-ui/data-table/data-table-search.tsx`:
```tsx
"use client";
import * as React from "react";
import { Toolbar } from "@base-ui/react/toolbar";
import { HugeiconsIcon } from "@hugeicons/react";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
import { useDataTable } from "@/registry/default/data-table/data-table-context";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/cubby-ui/input-group";
interface DataTableSearchProps extends Omit<
React.ComponentProps<"input">,
"value" | "onChange" | "size"
> {
placeholder?: string;
size?: "default" | "sm";
}
function DataTableSearch({
placeholder = "Search and filter",
size = "sm",
className,
...props
}: DataTableSearchProps) {
const { table } = useDataTable();
return (
table.setGlobalFilter(e.target.value)}
className={cn("py-0", className)}
render={ }
{...props}
/>
);
}
export { DataTableSearch };
```
Create `lib/cubby-ui/elevated.tsx`:
```tsx
import type * as React from "react";
import { cn } from "@/lib/utils";
export type SurfaceLevel = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
/** Every `SurfaceLevel`, for exhaustive iteration. Keep in sync with the type. */
export const SURFACE_LEVELS = [
1, 2, 3, 4, 5, 6, 7, 8,
] as const satisfies readonly SurfaceLevel[];
export const SURFACE_BG: Record = {
1: "bg-surface-1",
2: "bg-surface-2",
3: "bg-surface-3",
4: "bg-surface-4",
5: "bg-surface-5",
6: "bg-surface-6",
7: "bg-surface-7",
8: "bg-surface-8",
};
/**
* Every shadow below uses the `shadow-(--var)` form, pointing at a single theme
* variable. Two reasons, and both break if you "simplify" it back:
*
* 1. tailwind-merge only treats this form as the `shadow` group. A theme-key
* name and a square-bracket arbitrary value both land in `shadow-color`, so
* a caller's `className="shadow-none"` never overrides them and stylesheet
* order silently picks the winner. (Do not spell those two forms out here:
* Tailwind scans comments, and a bracket example compiles to a real,
* broken utility.)
* 2. The variable is a `--surface-*` source token, not a `@theme` alias. Source
* tokens are re-declared in `.dark`, so they resolve per element. A `@theme`
* alias is declared once on `:root`, which makes a `.dark` scoped to a
* subtree inherit the light value (same trap as `--border`).
*/
export const SURFACE_SHADOW: Record = {
1: "shadow-(--surface-shadow-1)",
2: "shadow-(--surface-shadow-2)",
3: "shadow-(--surface-shadow-3)",
4: "shadow-(--surface-shadow-4)",
5: "shadow-(--surface-shadow-5)",
6: "shadow-(--surface-shadow-6)",
7: "shadow-(--surface-shadow-7)",
8: "shadow-(--surface-shadow-8)",
};
export const SURFACE_RIM: Record = {
1: "after:shadow-(--surface-rim-1)",
2: "after:shadow-(--surface-rim-2)",
3: "after:shadow-(--surface-rim-3)",
4: "after:shadow-(--surface-rim-4)",
5: "after:shadow-(--surface-rim-5)",
6: "after:shadow-(--surface-rim-6)",
7: "after:shadow-(--surface-rim-7)",
8: "after:shadow-(--surface-rim-8)",
};
/**
* Exposes the surface level to descendants as `--popup-surface` so children
* (arrow fills, sticky labels, fade gradients) can track the popup's level
* via `bg-(--popup-surface,var(--popover))`.
*/
export const SURFACE_VAR: Record = {
1: "[--popup-surface:var(--surface-1)]",
2: "[--popup-surface:var(--surface-2)]",
3: "[--popup-surface:var(--surface-3)]",
4: "[--popup-surface:var(--surface-4)]",
5: "[--popup-surface:var(--surface-5)]",
6: "[--popup-surface:var(--surface-6)]",
7: "[--popup-surface:var(--surface-7)]",
8: "[--popup-surface:var(--surface-8)]",
};
/**
* Drops + rim insets in a single `box-shadow` (no `::after`). Used by `solidSurface()`.
* Literal Tailwind classes so the scanner picks them up.
*/
export const SURFACE_SHADOW_COMBINED: Record = {
1: "shadow-(--surface-shadow-combined-1)",
2: "shadow-(--surface-shadow-combined-2)",
3: "shadow-(--surface-shadow-combined-3)",
4: "shadow-(--surface-shadow-combined-4)",
5: "shadow-(--surface-shadow-combined-5)",
6: "shadow-(--surface-shadow-combined-6)",
7: "shadow-(--surface-shadow-combined-7)",
8: "shadow-(--surface-shadow-combined-8)",
};
/**
* Near-black fill in BOTH themes, deliberately OFF the elevation ladder. For
* persistent app chrome (a header bar) and for floating labels that should read
* as instrument rather than page.
*
* Pair it with `data-surface="chrome"` on the same element — that attribute
* re-points `--foreground`, `--border` and the washes for the subtree, so
* ordinary page classes keep working inside. Without it, contents paint
* dark-on-dark.
*
* Fill only, like `SURFACE_BG` + `SURFACE_VAR`. Add the edge separately with
* `shadow-(--chrome-shadow)` for anything LARGE: in dark it is not decoration,
* since the fill measures 1.08:1 against the page and that token is the only
* thing defining the surface (light is 16.3:1 and needs none). Leave it off
* for anything small, as the chrome `Tooltip` does.
*/
export const CHROME_SURFACE = "bg-chrome [--popup-surface:var(--chrome)]";
export function surfaceClasses(
level: SurfaceLevel,
shadowLevel: SurfaceLevel = level,
): string {
return `${SURFACE_BG[level]} ${SURFACE_SHADOW[shadowLevel]} ${SURFACE_VAR[level]}`;
}
/**
* Like `surfaceClasses`, but adds a `::after` overlay that re-paints the rim
* above the container's children. Use on elevated containers with opaque
* children near their edges (sticky labels, pinned inputs, dialog headers).
*
* Host requirements: positioned, `border-radius` class, clipped overflow.
* Overlay is `z-index: 2` — bump if children exceed that.
*/
export function elevatedSurface(
level: SurfaceLevel,
shadowLevel: SurfaceLevel = level,
): string {
return `${SURFACE_BG[level]} ${SURFACE_SHADOW[shadowLevel]} ${SURFACE_RIM[level]} ${SURFACE_VAR[level]} after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:z-[2]`;
}
/**
* Like `elevatedSurface`, but paints the rim in the popup's own `box-shadow`
* (no `::after`) — leaves `::after` free for other purposes. Use for elevated
* containers with no opaque children near their edges; switch to
* `elevatedSurface()` if you add a sticky/opaque child.
*
* When `level !== shadowLevel` the rim color tracks `shadowLevel`; for
* independent rim/drop control use `elevatedSurface` instead.
*/
export function solidSurface(
level: SurfaceLevel,
shadowLevel: SurfaceLevel = level,
): string {
return `${SURFACE_BG[level]} ${SURFACE_SHADOW_COMBINED[shadowLevel]} ${SURFACE_VAR[level]}`;
}
/**
* `::after` classes for a 1px inset rim on a single edge — for viewport-flush
* containers (Sheet/Drawer) so the rim only shows on the inner-facing edge.
* Color is `--surface-rim-color` (transparent in light, ~4% white in dark).
*/
export const SURFACE_RIM_EDGE: Record<
"top" | "bottom" | "left" | "right",
string
> = {
top: "after:shadow-[inset_0_1px_0_0_var(--surface-rim-color)]",
bottom: "after:shadow-[inset_0_-1px_0_0_var(--surface-rim-color)]",
left: "after:shadow-[inset_1px_0_0_0_var(--surface-rim-color)]",
right: "after:shadow-[inset_-1px_0_0_0_var(--surface-rim-color)]",
};
/**
* `::after` overlay with a 1px rim on the specified inner-facing edge.
* Pair with `surfaceClasses()` (NOT `elevatedSurface()`) for flush variants.
*/
export function innerEdgeRim(
innerEdge: "top" | "bottom" | "left" | "right",
): string {
return `after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:z-[2] ${SURFACE_RIM_EDGE[innerEdge]}`;
}
/** Maps attach-side → inner-facing edge for `innerEdgeRim`. */
export const INNER_EDGE_FROM_ATTACH_SIDE: Record<
"top" | "bottom" | "left" | "right",
"top" | "bottom" | "left" | "right"
> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
/**
* Directional drop shadow for a viewport-flush surface: casts toward the
* inner-facing edge (into the content) rather than always straight down. A
* flush surface only sheds shadow on the one edge that isn't pinned to the
* viewport, so the radial `--surface-shadow-N` recipe (which casts down on
* every surface) points the wrong way for top/bottom/left attach sides.
*
* This is the level-5 recipe, rotated per edge. It matches the floating
* variant's edge ring EXACTLY by mirroring how that recipe handles the ring
* per mode: the base shadow uses --surface-shadow-ring (light: 4.5%) and the
* `dark:` shadow inlines oklch(0 0 0 / 0.12) (the level-5 dark ring). The drop
* layers reference --surface-shadow-near/mid/far, which already redefine per
* mode, so they're identical between the two and don't need splitting.
* The ring casts on all four edges — the three viewport-pinned ones fall
* off-screen.
*/
export const INNER_EDGE_SHADOW: Record<
"top" | "bottom" | "left" | "right",
string
> = {
left: "shadow-[0_0_0_1px_var(--surface-shadow-ring),-1px_0_1px_-0.5px_var(--surface-shadow-near),-3px_0_3px_-1.5px_var(--surface-shadow-mid),-6px_0_6px_-3px_var(--surface-shadow-mid),-12px_0_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),-1px_0_1px_-0.5px_var(--surface-shadow-near),-3px_0_3px_-1.5px_var(--surface-shadow-mid),-6px_0_6px_-3px_var(--surface-shadow-mid),-12px_0_12px_-6px_var(--surface-shadow-far)]",
right:
"shadow-[0_0_0_1px_var(--surface-shadow-ring),1px_0_1px_-0.5px_var(--surface-shadow-near),3px_0_3px_-1.5px_var(--surface-shadow-mid),6px_0_6px_-3px_var(--surface-shadow-mid),12px_0_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),1px_0_1px_-0.5px_var(--surface-shadow-near),3px_0_3px_-1.5px_var(--surface-shadow-mid),6px_0_6px_-3px_var(--surface-shadow-mid),12px_0_12px_-6px_var(--surface-shadow-far)]",
top: "shadow-[0_0_0_1px_var(--surface-shadow-ring),0_-1px_1px_-0.5px_var(--surface-shadow-near),0_-3px_3px_-1.5px_var(--surface-shadow-mid),0_-6px_6px_-3px_var(--surface-shadow-mid),0_-12px_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),0_-1px_1px_-0.5px_var(--surface-shadow-near),0_-3px_3px_-1.5px_var(--surface-shadow-mid),0_-6px_6px_-3px_var(--surface-shadow-mid),0_-12px_12px_-6px_var(--surface-shadow-far)]",
bottom:
"shadow-[0_0_0_1px_var(--surface-shadow-ring),0_1px_1px_-0.5px_var(--surface-shadow-near),0_3px_3px_-1.5px_var(--surface-shadow-mid),0_6px_6px_-3px_var(--surface-shadow-mid),0_12px_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),0_1px_1px_-0.5px_var(--surface-shadow-near),0_3px_3px_-1.5px_var(--surface-shadow-mid),0_6px_6px_-3px_var(--surface-shadow-mid),0_12px_12px_-6px_var(--surface-shadow-far)]",
};
/**
* Flush-surface elevation: bg + a directional drop shadow cast toward the
* inner edge + the single-edge inset rim on that edge. Use for viewport-flush
* containers (Sheet/Drawer `default` variant) instead of `solidSurface` (whose
* all-around, downward shadow only suits a free-floating surface). `innerEdge`
* is the content-facing edge — derive it from the attach side via
* `INNER_EDGE_FROM_ATTACH_SIDE`.
*/
export function flushSurface(
level: SurfaceLevel,
innerEdge: "top" | "bottom" | "left" | "right",
): string {
return `${SURFACE_BG[level]} ${SURFACE_VAR[level]} ${INNER_EDGE_SHADOW[innerEdge]} ${innerEdgeRim(innerEdge)}`;
}
export interface ElevatedProps extends React.ComponentProps<"div"> {
level: SurfaceLevel;
shadowLevel?: SurfaceLevel;
}
function Elevated({ level, shadowLevel, className, ...props }: ElevatedProps) {
return (
);
}
export { Elevated };
```
## Usage [#usage]
**Imports:**
```tsx
import {
type ColumnDef,
DataTable,
DataTableContent,
DataTableHeader,
DataTableBody,
} from "@/components/ui/cubby-ui/data-table/data-table"
```
**Basic Usage:**
```tsx
```
## Composition [#composition]
`DataTable` builds the TanStack Table instance from `columns` and `data` and shares it through context. Compose the parts you need inside it.
```text
DataTable (columns + data)
├── DataTableToolbar
│ ├── DataTableSearch
│ ├── DataTableColumnToggle
│ └── DataTableToolbarSeparator
├── DataTableContent
│ ├── DataTableHeader
│ ├── DataTableBody
│ └── DataTableFooter
└── DataTablePagination
```
```tsx
```
## Examples [#examples]
### Sorting [#sorting]
Set `enableSorting` on `DataTable` and pass it to `DataTableHeader`; columns with string headers get clickable sort controls.
```tsx
// data-table-sorting.tsx
"use client"
import {
type ColumnDef,
DataTable,
DataTableContent,
DataTableHeader,
DataTableBody,
} from "@/components/ui/cubby-ui/data-table/data-table"
type Employee = {
id: string
name: string
email: string
department: string
salary: number
}
const columns: ColumnDef[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "department",
header: "Department",
},
{
accessorKey: "salary",
header: "Salary",
meta: { align: "right" as const },
cell: ({ row }) => {
const salary = parseFloat(row.getValue("salary"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(salary)
return {formatted}
},
},
]
const data: Employee[] = [
{ id: "1", name: "Alice Johnson", email: "alice@example.com", department: "Engineering", salary: 95000 },
{ id: "2", name: "Bob Smith", email: "bob@example.com", department: "Design", salary: 85000 },
{ id: "3", name: "Charlie Brown", email: "charlie@example.com", department: "Marketing", salary: 75000 },
{ id: "4", name: "Diana Prince", email: "diana@example.com", department: "Engineering", salary: 105000 },
{ id: "5", name: "Eve Wilson", email: "eve@example.com", department: "Sales", salary: 80000 },
{ id: "6", name: "Frank Miller", email: "frank@example.com", department: "Design", salary: 90000 },
]
export default function DataTableSorting() {
return (
)
}
```
### Row Selection [#row-selection]
Set `enableRowSelection` to add a checkbox column, and control state with `rowSelection` and `onRowSelectionChange`.
```tsx
// data-table-row-selection.tsx
"use client"
import * as React from "react"
import {
type ColumnDef,
DataTable,
DataTableContent,
DataTableHeader,
DataTableBody,
type RowSelectionState,
} from "@/components/ui/cubby-ui/data-table/data-table"
type Task = {
id: string
title: string
status: "todo" | "in-progress" | "done"
priority: "low" | "medium" | "high"
}
const columns: ColumnDef[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
{row.getValue("title")}
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
{row.getValue("status")}
),
},
{
accessorKey: "priority",
header: "Priority",
cell: ({ row }) => (
{row.getValue("priority")}
),
},
]
const data: Task[] = [
{ id: "TASK-001", title: "Update documentation", status: "in-progress", priority: "high" },
{ id: "TASK-002", title: "Fix login bug", status: "todo", priority: "high" },
{ id: "TASK-003", title: "Add dark mode", status: "done", priority: "medium" },
{ id: "TASK-004", title: "Refactor API layer", status: "in-progress", priority: "low" },
{ id: "TASK-005", title: "Write unit tests", status: "todo", priority: "medium" },
]
export default function DataTableRowSelection() {
const [rowSelection, setRowSelection] = React.useState({})
return (
{Object.keys(rowSelection).length} of {data.length} row(s) selected.
)
}
```
### Toolbar [#toolbar]
Compose `DataTableToolbar` with `DataTableSearch` and `DataTableColumnToggle`, and set `enableFiltering` on `DataTable` so search works.
```tsx
// data-table-toolbar.tsx
"use client"
import * as React from "react"
import {
type ColumnDef,
DataTable,
DataTableToolbar,
DataTableToolbarSeparator,
DataTableSearch,
DataTableColumnToggle,
DataTableContent,
DataTableHeader,
DataTableBody,
} from "@/components/ui/cubby-ui/data-table/data-table"
import { Badge } from "@/components/ui/cubby-ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/cubby-ui/select"
type Bill = {
id: string
billNumber: string
dateIssued: string
total: number
status: "paid" | "pending" | "overdue"
}
const columns: ColumnDef[] = [
{
accessorKey: "billNumber",
header: "Bill",
},
{
accessorKey: "dateIssued",
header: "Date issued",
cell: ({ row }) => {
return new Date(row.getValue("dateIssued")).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
},
},
{
accessorKey: "total",
header: "Total",
cell: ({ row }) => {
const total = parseFloat(row.getValue("total"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
return formatted
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
{status.charAt(0).toUpperCase() + status.slice(1)}
)
},
},
]
const data: Bill[] = [
{ id: "1", billNumber: "448895349", dateIssued: "2025-11-22", total: 39.99, status: "paid" },
{ id: "2", billNumber: "551238764", dateIssued: "2025-11-18", total: 124.50, status: "paid" },
{ id: "3", billNumber: "662341987", dateIssued: "2025-11-15", total: 89.00, status: "pending" },
{ id: "4", billNumber: "773456210", dateIssued: "2025-11-10", total: 250.75, status: "overdue" },
{ id: "5", billNumber: "884567321", dateIssued: "2025-11-08", total: 67.25, status: "paid" },
{ id: "6", billNumber: "995678432", dateIssued: "2025-11-05", total: 189.99, status: "pending" },
{ id: "7", billNumber: "106789543", dateIssued: "2025-11-01", total: 45.00, status: "paid" },
{ id: "8", billNumber: "217890654", dateIssued: "2025-10-28", total: 320.00, status: "overdue" },
{ id: "9", billNumber: "328901765", dateIssued: "2025-10-25", total: 55.50, status: "paid" },
{ id: "10", billNumber: "439012876", dateIssued: "2025-10-22", total: 175.25, status: "pending" },
{ id: "11", billNumber: "540123987", dateIssued: "2025-10-18", total: 92.00, status: "paid" },
{ id: "12", billNumber: "651234098", dateIssued: "2025-10-15", total: 410.50, status: "overdue" },
{ id: "13", billNumber: "762345109", dateIssued: "2025-10-12", total: 28.75, status: "paid" },
{ id: "14", billNumber: "873456210", dateIssued: "2025-10-08", total: 145.00, status: "pending" },
{ id: "15", billNumber: "984567321", dateIssued: "2025-10-05", total: 78.99, status: "paid" },
]
const statusOptions = [
{ label: "All", value: "all" },
{ label: "Paid", value: "paid" },
{ label: "Pending", value: "pending" },
{ label: "Overdue", value: "overdue" },
]
export default function DataTableToolbarExample() {
const [statusFilter, setStatusFilter] = React.useState("all")
// Filter data based on status
const filteredData =
statusFilter === "all"
? data
: data.filter((row) => row.status === statusFilter)
return (
setStatusFilter(value ?? "all")}
>
{statusOptions.map((option) => (
{option.label}
))}
)
}
```
### Striped [#striped]
Set `striped` on `DataTableContent` for alternating row backgrounds.
```tsx
// data-table-striped.tsx
"use client"
import {
type ColumnDef,
DataTable,
DataTableContent,
DataTableHeader,
DataTableBody,
} from "@/components/ui/cubby-ui/data-table/data-table"
type Product = {
id: string
name: string
category: string
price: number
stock: number
}
const columns: ColumnDef[] = [
{
accessorKey: "id",
header: "SKU",
},
{
accessorKey: "name",
header: "Product",
cell: ({ row }) => (
{row.getValue("name")}
),
},
{
accessorKey: "category",
header: "Category",
},
{
accessorKey: "price",
header: () => Price
,
cell: ({ row }) => {
const price = parseFloat(row.getValue("price"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price)
return {formatted}
},
},
{
accessorKey: "stock",
header: () => Stock
,
cell: ({ row }) => (
{row.getValue("stock")}
),
},
]
const data: Product[] = [
{ id: "SKU-001", name: "Wireless Mouse", category: "Electronics", price: 29.99, stock: 150 },
{ id: "SKU-002", name: "Mechanical Keyboard", category: "Electronics", price: 89.99, stock: 75 },
{ id: "SKU-003", name: "USB-C Hub", category: "Accessories", price: 49.99, stock: 200 },
{ id: "SKU-004", name: "Monitor Stand", category: "Furniture", price: 79.99, stock: 50 },
{ id: "SKU-005", name: "Desk Lamp", category: "Furniture", price: 34.99, stock: 120 },
{ id: "SKU-006", name: "Webcam HD", category: "Electronics", price: 59.99, stock: 85 },
]
export default function DataTableStriped() {
return (
)
}
```
## Column Definitions [#column-definitions]
Define columns using the `ColumnDef` type from TanStack Table. Each column can have:
* `accessorKey` - The key to access data from the row
* `header` - Header content (string or render function)
* `cell` - Cell content render function
* `enableSorting` - Whether the column is sortable (default: `true` when `enableSorting` prop is set)
When `enableSorting` is enabled, columns with string headers automatically render a sortable button with direction indicators. Use `meta.align` to control header alignment, or a render function for fully custom headers.
```tsx
import { type ColumnDef } from "@/registry/default/data-table/data-table"
const columns: ColumnDef[] = [
{
accessorKey: "name",
header: "Name", // Auto-renders with sort UI when enableSorting is true
},
{
accessorKey: "amount",
header: "Amount",
meta: { align: "right" as const }, // Right-align the header
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"))
return ${amount.toFixed(2)}
},
},
{
accessorKey: "status",
header: "Status",
enableSorting: false, // Disable sorting for this column
},
]
```
## API Reference [#api-reference]
### DataTable [#datatable]
Root component that creates the TanStack Table instance and provides context to children.
- **columns**
[]">
Array of column definitions. See TanStack Table documentation for full options.
- **data** (type: `TData[]`)
Array of data objects to display in the table.
- **children** (type: `React.ReactNode`)
Composition slot for toolbar, content, and other sub-components.
- **enableSorting** (type: `boolean`) - Default: `false`
Enables column sorting at the table level. Pass `enableSorting` to `DataTableHeader` to render sort UI.
- **enableRowSelection**
) => boolean)" defaultValue="false">
Enables row selection. Pass a function for conditional selection.
- **enableMultiRowSelection** (type: `boolean`) - Default: `true`
Allows multiple rows to be selected. Set to `false` for single selection.
- **showSelectionColumn** (type: `boolean`) - Default: `true`
When `enableRowSelection` is true, automatically adds a checkbox column. Set to `false` to handle selection UI yourself.
- **enableFiltering** (type: `boolean`) - Default: `false`
Enables column filtering.
- **enablePagination** (type: `boolean`) - Default: `false`
Enables pagination. Use with `DataTablePagination` component.
- **sorting** (type: `SortingState`)
Controlled sorting state. Use with `onSortingChange` for controlled mode.
- **onSortingChange**
">
Callback when sorting changes.
- **rowSelection** (type: `RowSelectionState`)
Controlled row selection state. Use with `onRowSelectionChange` for controlled mode.
- **onRowSelectionChange**
">
Callback when row selection changes.
- **columnFilters** (type: `ColumnFiltersState`)
Controlled column filters state. Use with `onColumnFiltersChange`.
- **onColumnFiltersChange**
">
Callback when column filters change.
- **pagination** (type: `PaginationState`)
Controlled pagination state. Use with `onPaginationChange`.
- **onPaginationChange**
">
Callback when pagination changes.
- **columnVisibility** (type: `VisibilityState`)
Controlled column visibility state. Use with `onColumnVisibilityChange` for controlled mode.
- **onColumnVisibilityChange**
">
Callback when column visibility changes.
- **globalFilter** (type: `string`)
Controlled global filter value. Filters across all columns.
- **onGlobalFilterChange**
">
Callback when global filter changes.
- **getRowId**
string">
Function to derive a unique ID for each row. Defaults to using the row index.
DataTable's outer wrapper uses the same `solidSurface(3, 1)` + `bg-muted` pattern as Card inset and Table, a flat outlined gray frame. The inner `DataTableContent` Table inside has its own bg-muted/inner-card visual (body cells `bg-surface-3 dark:bg-surface-1`), with its outer Table styling suppressed (`bg-transparent shadow-none ring-0`) so the elevation lives on the DataTable wrapper only. See the [Surfaces](/docs/getting-started/surfaces) docs.
### DataTableToolbar [#datatabletoolbar]
Container for toolbar controls. Renders above the table content with visual connection to the header.
- **variant** (type: `'default' | 'ghost'`) - Default: `'default'`
Visual style of the toolbar.
- **children** (type: `React.ReactNode`)
Toolbar contents such as search, filters, and column toggle.
### DataTableSearch [#datatablesearch]
Search input that filters across all columns using the global filter.
- **placeholder** (type: `string`) - Default: `"Search and filter"`
Placeholder text for the search input.
- **size** (type: `'default' | 'sm'`) - Default: `'sm'`
Size of the search input.
### DataTableColumnToggle [#datatablecolumntoggle]
Dropdown button that allows users to show/hide columns.
This component has no configurable props. It automatically reads column visibility state from the DataTable context.
### DataTableContent [#datatablecontent]
Wrapper for the table that applies styling props. Accepts all `Table` component props.
- **bordered** (type: `boolean`) - Default: `false`
Adds borders to all table cells for a grid appearance.
- **striped** (type: `boolean`) - Default: `false`
Adds alternating background colors to rows.
- **hoverable** (type: `boolean`) - Default: `true`
Enables hover effect on rows.
- **rowDividers** (type: `boolean`) - Default: `true`
Shows horizontal dividers between body rows.
### DataTableHeader [#datatableheader]
Renders the table header with optional sort controls.
- **enableSorting** (type: `boolean`) - Default: `false`
When true, columns with string headers render clickable sort buttons with direction indicators.
### DataTableBody [#datatablebody]
Renders the table body with support for empty state.
- **emptyState** (type: `React.ReactNode`) - Default: `"No results."`
Content to display when the table has no data.
### DataTablePagination [#datatablepagination]
Pagination controls with previous/next buttons and selection count.
- **showSelectedCount** (type: `boolean`) - Default: `true`
Shows the count of selected rows (e.g., "2 of 10 row(s) selected").
### DataTableToolbarSeparator [#datatabletoolbarseparator]
Visual separator for toolbar items. No configurable props.
# Date Picker (/docs/components/date-picker)
The date picker is a field that opens a calendar popover for choosing one date. Use it for due dates, birthdays, and other single-date form fields.
To select a start and end date together, use [`DateRangePicker`](/docs/components/date-range-picker) instead.
## Preview [#preview]
```tsx
// date-picker-basic.tsx
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
export default function DatePickerBasic() {
return ;
}
```
## Installation [#installation]
### Installation
**CLI:**
```bash
npx shadcn@latest add @cubby-ui/date-picker
```
**Manual:**
1. Install dependencies:
```bash
npm install react-day-picker class-variance-authority @hugeicons/react @hugeicons/core-free-icons
```
2. Copy the component source code to your project:
Create `components/ui/cubby-ui/date-picker/date-picker.tsx`:
```tsx
"use client";
import * as React from "react";
import {
dateMatchModifiers,
type Matcher,
type PropsBase,
} from "react-day-picker";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import {
Calendar,
CalendarPreset,
type CalendarLabelsProp,
} from "@/components/ui/cubby-ui/calendar/calendar";
import { useControllableState } from "@/hooks/cubby-ui/use-controllable-state";
import type { inputVariants } from "@/components/ui/cubby-ui/input";
import {
isSameDay,
startOfDay,
toISODate,
} from "@/lib/cubby-ui/date-utils";
import { parseDate } from "@/lib/cubby-ui/parse-date";
import { Popover } from "@/components/ui/cubby-ui/popover";
import {
DatePickerClear,
DatePickerContent,
DatePickerField,
DatePickerTrigger,
toDisabledMatchers,
type DateFieldReading,
type PickerControlProps,
} from "./date-picker-parts";
export interface DatePickerPreset {
label: string;
/** A date, or a function so relative presets ("Tomorrow") stay current. */
value: Date | (() => Date);
}
/** Copy overrides for the picker's own text, for wording and translation. */
export interface DatePickerLabels {
/** The calendar button beside an `editable` field. */
chooseDate: string;
clear: string;
/** The group of presets, for screen readers. */
presets: string;
/** Typed text that can't be read as a date (`editable`). */
invalid: string;
/** A typed date that's blocked by min, max, or `disabledDates`. */
unavailable: string;
}
const DEFAULT_LABELS: DatePickerLabels = {
chooseDate: "Choose date",
clear: "Clear date",
presets: "Presets",
invalid: "Not a date",
unavailable: "Unavailable",
};
export type DatePickerProps = PickerControlProps &
VariantProps & {
/** The selected date. `null` means nothing is selected. */
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (value: Date | null) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
/** Formats the date on the trigger. Defaults to the locale's medium date. */
format?: (date: Date) => string;
/** Shows a button that clears the date. */
clearable?: boolean;
/**
* Makes the field a text input: type "tomorrow", "next fri", "mar 14" or
* "3/14", and the calendar opens from the button beside it.
*/
editable?: boolean;
disabled?: boolean;
/** Earliest selectable date. Also stops navigation before its month. */
minDate?: Date;
/** Latest selectable date. Also stops navigation after its month. */
maxDate?: Date;
/** Extra dates to block, as DayPicker matchers. */
disabledDates?: Matcher | Matcher[];
/** One-click shortcuts shown beside the calendar. */
presets?: DatePickerPreset[];
/** Submits the date as `YYYY-MM-DD` under this name in a form. */
name?: string;
/**
* Passed through to the calendar (locale, week start, and so on). Its
* `labels` take DayPicker's and the calendar's own screen-reader text.
*/
calendarProps?: Omit, "labels"> & {
labels?: CalendarLabelsProp;
};
/** Copy overrides for the picker's own text, merged over the defaults. */
labels?: Partial;
};
function resolvePreset(preset: DatePickerPreset): Date {
return startOfDay(
typeof preset.value === "function" ? preset.value() : preset.value,
);
}
function DatePicker({
value: valueProp,
defaultValue = null,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
placeholder = "Pick a date",
format,
clearable = false,
editable = false,
disabled,
minDate,
maxDate,
disabledDates,
presets,
name,
calendarProps,
labels,
variant,
size,
className,
...props
}: DatePickerProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onValueChange,
});
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onValueChange: onOpenChange,
});
const triggerRef = React.useRef(null);
const groupRef = React.useRef(null);
const copy = { ...DEFAULT_LABELS, ...labels };
const locale = calendarProps?.locale?.code;
const label = value
? (format?.(value) ??
new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(value))
: null;
const disabledMatchers = toDisabledMatchers(minDate, maxDate, disabledDates);
const isBlocked = (date: Date) =>
disabledMatchers.length > 0 && dateMatchModifiers(date, disabledMatchers);
const commit = (date: Date | null) => {
setValue(date);
setOpen(false);
};
const hintFormat = (date: Date) =>
new Intl.DateTimeFormat(locale, {
weekday: "short",
month: "short",
day: "numeric",
year:
date.getFullYear() === new Date().getFullYear() ? undefined : "numeric",
}).format(date);
const readText = (text: string): DateFieldReading | null => {
const date = parseDate(text, { locale });
if (!date) return null;
return {
value: date,
hint: hintFormat(date),
error: isBlocked(date) ? copy.unavailable : undefined,
};
};
const showClear = clearable && value !== null && !disabled;
return (
setOpen(next)}>
{editable ? (
setOpen(true)}
triggerLabel={copy.chooseDate}
clearLabel={copy.clear}
invalidLabel={copy.invalid}
showClear={showClear}
placeholder={placeholder}
disabled={disabled}
variant={variant}
size={size}
{...props}
/>
) : (
{label ?? placeholder}
)}
{
const date = resolvePreset(preset);
return (
commit(date)}
>
{preset.label}
);
})}
>
commit(date)}
disabled={
disabledMatchers.length > 0 ? disabledMatchers : undefined
}
startMonth={calendarProps?.startMonth ?? minDate}
endMonth={calendarProps?.endMonth ?? maxDate}
/>
{showClear && !editable && (
{
setValue(null);
triggerRef.current?.focus();
}}
/>
)}
{name && (
)}
);
}
export { DatePicker };
```
Create `components/ui/cubby-ui/date-picker/date-picker-parts.tsx`:
```tsx
"use client";
import * as React from "react";
import type { Matcher } from "react-day-picker";
import type { VariantProps } from "class-variance-authority";
import { HugeiconsIcon } from "@hugeicons/react";
import { Calendar01Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/lib/utils";
import { CalendarPresets } from "@/components/ui/cubby-ui/calendar/calendar";
import { inputVariants } from "@/components/ui/cubby-ui/input";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/cubby-ui/input-group";
import { startOfDay } from "@/lib/cubby-ui/date-utils";
import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/cubby-ui/popover";
/* Pieces shared by DatePicker and DateRangePicker. */
/**
* Props forwarded to the trigger button, or the text input when `editable`.
* Limited to what both accept (id, aria-*, data-*, handlers).
*/
export type PickerControlProps = Omit<
React.HTMLAttributes,
"defaultValue" | "onChange" | "onSelect" | "children"
>;
/** min, max, and extra blocked dates as one list of DayPicker matchers. */
export function toDisabledMatchers(
minDate: Date | undefined,
maxDate: Date | undefined,
disabledDates: Matcher | Matcher[] | undefined,
): Matcher[] {
return [
...(minDate ? [{ before: startOfDay(minDate) }] : []),
...(maxDate ? [{ after: startOfDay(maxDate) }] : []),
...(disabledDates
? Array.isArray(disabledDates)
? disabledDates
: [disabledDates]
: []),
];
}
/* -------------------------------------------------------------------------------------------------
* Trigger and clear button
* -------------------------------------------------------------------------------------------------*/
type DatePickerTriggerProps = PickerControlProps &
VariantProps & {
ref?: React.Ref;
disabled?: boolean;
/** Nothing selected: the label is the placeholder. */
empty: boolean;
/** Leaves room for the clear button over the trigger's end. */
showClear: boolean;
children: React.ReactNode;
};
function DatePickerTrigger({
variant,
size,
empty,
showClear,
children,
...props
}: DatePickerTriggerProps) {
return (
{children}
);
}
function DatePickerClear({
label,
onClear,
}: {
label: string;
onClear: () => void;
}) {
return (
);
}
/* -------------------------------------------------------------------------------------------------
* Popover content
* -------------------------------------------------------------------------------------------------*/
function DatePickerContent({
anchor,
presets,
presetsLabel,
children,
}: {
/** Positions against this instead of the trigger (the editable field). */
anchor?: React.RefObject;
/** Preset buttons for the rail; nothing renders when empty. */
presets?: React.ReactNode[];
presetsLabel: string;
children: React.ReactNode;
}) {
const panelRef = React.useRef(null);
return (
panelRef.current?.querySelector(
'[data-slot="calendar-day"] button[tabindex="0"]',
) ?? true
}
>
{presets && presets.length > 0 && (
{presets}
)}
{children}
);
}
/* -------------------------------------------------------------------------------------------------
* Editable field
*
* While typing, a hint shows how the text will be read, so "next fri" is
* confirmed as a date before it commits on Enter or blur.
* -------------------------------------------------------------------------------------------------*/
export interface DateFieldReading {
value: T;
/** How the text was understood, shown while typing. */
hint: string;
/** Set when the text is a date that isn't allowed. */
error?: string;
}
type DatePickerFieldProps = PickerControlProps &
VariantProps & {
groupRef: React.Ref;
/** The committed value, written out. Empty when there is none. */
text: string;
read: (text: string) => DateFieldReading | null;
onCommit: (value: T | null) => void;
/** Opens the calendar (ArrowDown in the input). */
onOpenRequest: () => void;
triggerLabel: string;
clearLabel: string;
/** Shown when committing text that can't be read. */
invalidLabel: string;
showClear: boolean;
placeholder?: string;
disabled?: boolean;
};
function DatePickerField({
groupRef,
text,
read,
onCommit,
onOpenRequest,
triggerLabel,
clearLabel,
invalidLabel,
showClear,
variant,
size,
disabled,
className,
onFocus,
onBlur,
onKeyDown,
...props
}: DatePickerFieldProps) {
// `null` while not editing, so the input shows the committed value.
const [draft, setDraft] = React.useState(null);
// A commit was refused (Enter or blur); the draft stays, marked invalid.
const [rejected, setRejected] = React.useState(false);
// A new value from elsewhere (the calendar, a preset, the parent) replaces
// any leftover draft, refused or not.
const [shownText, setShownText] = React.useState(text);
if (shownText !== text) {
setShownText(text);
setDraft(null);
setRejected(false);
}
const inputRef = React.useRef(null);
const hintId = React.useId();
const editing = draft !== null && draft.trim() !== "" && draft !== text;
const reading = editing ? read(draft) : null;
let hint = "";
if (editing && reading) hint = reading.error ?? reading.hint;
else if (editing && rejected) hint = invalidLabel;
const invalid = editing && (reading?.error !== undefined || rejected);
/** Applies the draft. False when it can't be read or isn't allowed. */
const commit = (): boolean => {
if (draft === null || draft === text) return true;
if (draft.trim() === "") {
onCommit(null);
return true;
}
if (!reading || reading.error) return false;
onCommit(reading.value);
return true;
};
const settle = () => {
if (commit()) {
setDraft(null);
setRejected(false);
} else {
setRejected(true);
}
};
return (
}
>
{
// Keep refused text to fix; otherwise start from the value.
if (draft === null) setDraft(text);
event.currentTarget.select();
onFocus?.(event);
}}
onChange={(event) => {
setDraft(event.target.value);
setRejected(false);
}}
onBlur={(event) => {
settle();
onBlur?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "Enter") {
event.preventDefault();
settle();
} else if (event.key === "Escape" && editing) {
// Revert the edit; a second Escape is left to the page.
event.preventDefault();
event.stopPropagation();
setDraft(text);
setRejected(false);
requestAnimationFrame(() => inputRef.current?.select());
} else if (event.key === "ArrowDown") {
event.preventDefault();
onOpenRequest();
}
}}
/>
{/* Announces refused commits only. The visible hint changes on most
keystrokes, so it's linked by aria-describedby, not a live region. */}
{rejected && editing ? hint : ""}
{(hint || showClear) && (
{hint}
{showClear && !editing && (
{
onCommit(null);
// Focusing starts an edit from the old text; the cleared value
// replaces that draft when it renders.
inputRef.current?.focus();
}}
>
)}
)}
);
}
export {
DatePickerClear,
DatePickerContent,
DatePickerField,
DatePickerTrigger,
};
```
Create `hooks/cubby-ui/use-controllable-state.ts`:
```tsx
"use client";
import * as React from "react";
interface UseControllableStateParams {
/** Controlled value. When defined, the hook is in controlled mode. */
value?: T;
/** Initial value for uncontrolled mode. */
defaultValue: T;
onValueChange?: (value: T) => void;
}
/**
* Merges controlled and uncontrolled state into a single `[value, setValue]`
* tuple, mirroring the pattern Radix and Base UI use internally.
*
* `onValueChange` fires synchronously in both modes. Functional updates are
* safe in both modes: they resolve against an eagerly-advanced ref, so
* multiple `setValue` calls in one event tick compose. `setValue` is
* referentially stable across renders.
*
* The ref re-syncs from the prop at the next commit — but only if one happens.
* Known constraint: a controlled parent that ignores `onValueChange` AND never
* re-renders leaves the ref advanced past the real value, and a repeat
* `setValue` to that same value early-returns at the `Object.is` check without
* re-notifying. Acceptable — a controlled parent that never commits is already
* outside the controlled contract — but it is the first thing to check when a
* controlled value appears stuck.
*/
export function useControllableState({
value,
defaultValue,
onValueChange,
}: UseControllableStateParams): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = value !== undefined;
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const current = isControlled ? (value as T) : uncontrolled;
const onChangeRef = React.useRef(onValueChange);
// Mirrors `current`. Re-synced after every commit; eagerly advanced by
// setValue so multiple calls in one event tick compose.
const currentRef = React.useRef(current);
React.useEffect(() => {
onChangeRef.current = onValueChange;
currentRef.current = current;
});
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
const prev = currentRef.current;
const resolved =
typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
if (Object.is(resolved, prev)) return;
if (isControlled) {
// The parent owns the state, but advance the ref eagerly so a second
// functional setValue in the same tick composes on this one instead of
// clobbering it. The ref re-syncs from the prop at the next commit,
// which also corrects it if the parent rejected the change.
currentRef.current = resolved;
onChangeRef.current?.(resolved);
} else {
currentRef.current = resolved;
setUncontrolled(resolved);
onChangeRef.current?.(resolved);
}
},
[isControlled],
);
return [current, setValue];
}
```
Create `lib/cubby-ui/date-utils.ts`:
```tsx
/**
* Small local-time date helpers shared by the calendar, the date pickers, and
* parse-date. Every date here is a local calendar day; times are dropped.
*/
export const DAY_MS = 24 * 60 * 60 * 1000;
/** The same calendar day at local midnight. */
export function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/** Whether two dates fall on the same calendar day, ignoring the time. */
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/**
* Calendar days from `a` to `b` (negative when `b` is earlier), ignoring the
* time of day. Rounded, so a DST change inside the span doesn't skew it.
*/
export function daysBetween(a: Date, b: Date): number {
return Math.round(
(startOfDay(b).getTime() - startOfDay(a).getTime()) / DAY_MS,
);
}
/** `YYYY-MM-DD` in local time, the form ` ` submits. */
export function toISODate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
```
Create `lib/cubby-ui/parse-date.ts`:
```tsx
/**
* Forgiving parsers for dates people type: "tomorrow", "next fri", "in 3
* weeks", "mar 14", "14 March 2027", "3/14", "2026-03-14", and ranges such as
* "Oct 1 – 8, 2026" or "last 30 days". Numeric dates follow the locale's
* day/month order. Everything resolves to local midnight.
*/
import { startOfDay } from "@/lib/cubby-ui/date-utils";
export interface ParseDateOptions {
/** The "today" that relative input resolves against. Defaults to now. */
referenceDate?: Date;
/** BCP 47 locale for month and weekday names and numeric order. */
locale?: string;
}
export interface ParsedDateRange {
from: Date;
to: Date;
}
interface DateParts {
year?: number;
month?: number;
day?: number;
}
type Parsed = { date: Date } | { parts: DateParts };
type Unit = "day" | "week" | "month" | "year";
const UNITS: Record = {
d: "day",
day: "day",
days: "day",
w: "week",
wk: "week",
wks: "week",
week: "week",
weeks: "week",
mo: "month",
mos: "month",
month: "month",
months: "month",
y: "year",
yr: "year",
yrs: "year",
year: "year",
years: "year",
};
const KEYWORDS: Record = {
today: 0,
tod: 0,
now: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
tom: 1,
yesterday: -1,
yday: -1,
};
/** Own entries only, so names like "constructor" aren't found on Object. */
function unitOf(name: string): Unit | undefined {
return Object.hasOwn(UNITS, name) ? UNITS[name] : undefined;
}
function keywordOf(name: string): number | undefined {
return Object.hasOwn(KEYWORDS, name) ? KEYWORDS[name] : undefined;
}
const MIN_YEAR = 1;
const MAX_YEAR = 9999;
/**
* Null for out-of-range years and Invalid Date, which huge input like
* "in 99999999 years" produces and which throws when formatted.
*/
function valid(date: Date | null): Date | null {
if (!date || Number.isNaN(date.getTime())) return null;
const year = date.getFullYear();
return year >= MIN_YEAR && year <= MAX_YEAR ? date : null;
}
const RANGE_SEPARATOR = /\s+(?:-|to|until|till|through|thru)\s+|\s*[–—]\s*/;
/* -------------------------------------------------------------------------------------------------
* Date arithmetic
* -------------------------------------------------------------------------------------------------*/
/** `new Date(y, m, d)`, except years 0–99 stay literal instead of meaning 1900–1999. */
function makeDate(year: number, month: number, day: number): Date {
const date = new Date(2000, month, day);
date.setFullYear(year, month, day);
return date;
}
function daysInMonth(year: number, month: number): number {
return makeDate(year, month + 1, 0).getDate();
}
/** Adds whole units. Months and years clamp to the end of a shorter month. */
function add(date: Date, amount: number, unit: Unit): Date {
if (unit === "day" || unit === "week") {
const days = unit === "week" ? amount * 7 : amount;
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
const months = unit === "year" ? amount * 12 : amount;
const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
const day = Math.min(
date.getDate(),
daysInMonth(target.getFullYear(), target.getMonth()),
);
return new Date(target.getFullYear(), target.getMonth(), day);
}
/* -------------------------------------------------------------------------------------------------
* Locale names
* -------------------------------------------------------------------------------------------------*/
const nameCache = new Map>();
function normalizeName(name: string): string {
return name.toLowerCase().replace(/\.$/, "");
}
/** Month names (long and short) in the locale and English, to month index. */
function monthNames(locale: string | undefined): Map {
const key = `month:${locale ?? ""}`;
const cached = nameCache.get(key);
if (cached) return cached;
const names = new Map();
for (const loc of new Set([locale, "en"])) {
for (const width of ["long", "short"] as const) {
const format = new Intl.DateTimeFormat(loc, { month: width });
for (let month = 0; month < 12; month++) {
names.set(
normalizeName(format.format(new Date(2000, month, 1))),
month,
);
}
}
}
names.set("sept", 8);
nameCache.set(key, names);
return names;
}
/** Weekday names (long and short) in the locale and English, to 0–6. */
function weekdayNames(locale: string | undefined): Map {
const key = `weekday:${locale ?? ""}`;
const cached = nameCache.get(key);
if (cached) return cached;
const names = new Map();
for (const loc of new Set([locale, "en"])) {
for (const width of ["long", "short"] as const) {
const format = new Intl.DateTimeFormat(loc, { weekday: width });
// 2 January 2000 was a Sunday.
for (let weekday = 0; weekday < 7; weekday++) {
names.set(
normalizeName(format.format(new Date(2000, 0, 2 + weekday))),
weekday,
);
}
}
}
for (const [name, weekday] of Object.entries({
tues: 2,
weds: 3,
thur: 4,
thurs: 4,
})) {
names.set(name, weekday);
}
nameCache.set(key, names);
return names;
}
/** Whether the locale writes the day before the month in numeric dates. */
function isDayFirst(locale: string | undefined): boolean {
const parts = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "numeric",
}).formatToParts(new Date(2000, 10, 22));
const day = parts.findIndex((part) => part.type === "day");
const month = parts.findIndex((part) => part.type === "month");
return day < month;
}
/** Expands a two-digit year to the century that lands within 20 years ahead. */
function expandYear(year: number, reference: Date): number {
if (year >= 100) return year;
const current = reference.getFullYear();
let full = Math.floor(current / 100) * 100 + year;
if (full > current + 20) full -= 100;
return full;
}
/* -------------------------------------------------------------------------------------------------
* Single dates
* -------------------------------------------------------------------------------------------------*/
function parseRelative(text: string, reference: Date): Date | null {
const keyword = keywordOf(text);
if (keyword !== undefined) return add(reference, keyword, "day");
// "in 3 days", "+2w", "3 weeks"
const ahead = text.match(/^(?:in\s+|\+)?(\d+)\s*([a-z]+)$/);
const aheadUnit = ahead && unitOf(ahead[2]);
if (ahead && aheadUnit) return add(reference, Number(ahead[1]), aheadUnit);
// "-2w", "3 days ago"
const behind =
text.match(/^-(\d+)\s*([a-z]+)$/) ?? text.match(/^(\d+)\s*([a-z]+)\s+ago$/);
const behindUnit = behind && unitOf(behind[2]);
if (behind && behindUnit) {
return add(reference, -Number(behind[1]), behindUnit);
}
// "next week", "last month", "this year"
const step = text.match(/^(next|last|this)\s+([a-z]+)$/);
const stepUnit = step && unitOf(step[2]);
if (step && stepUnit) {
const direction = { next: 1, last: -1, this: 0 }[step[1] as "next"];
return add(reference, direction, stepUnit);
}
return null;
}
function parseWeekday(
text: string,
reference: Date,
locale: string | undefined,
): Date | null {
const match = text.match(/^(?:(next|last|this|on)\s+)?([^\s\d]+)$/);
if (!match) return null;
const weekday = weekdayNames(locale).get(normalizeName(match[2]));
if (weekday === undefined) return null;
const today = reference.getDay();
if (match[1] === "last") {
// The most recent one before today.
return add(reference, -(((today - weekday + 6) % 7) + 1), "day");
}
if (match[1] === "next") {
// The first one after today.
return add(reference, ((weekday - today + 6) % 7) + 1, "day");
}
// Bare or "this": today if it matches, otherwise the next one.
return add(reference, (weekday - today + 7) % 7, "day");
}
function parseNumeric(
text: string,
reference: Date,
locale: string | undefined,
): DateParts | null {
// Year first is unambiguous: 2026-03-14, 2026/3/14.
const yearFirst = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
if (yearFirst) {
return {
year: Number(yearFirst[1]),
month: Number(yearFirst[2]) - 1,
day: Number(yearFirst[3]),
};
}
const short = text.match(/^(\d{1,2})[-/.](\d{1,2})(?:[-/.](\d{2}|\d{4}))?$/);
if (!short) return null;
const [first, second] = [Number(short[1]), Number(short[2])];
const [day, month] = isDayFirst(locale) ? [first, second] : [second, first];
return {
day,
month: month - 1,
year: short[3] ? expandYear(Number(short[3]), reference) : undefined,
};
}
/** Word dates in any order: "mar 14", "14th of march 2027", "thu oct 1". */
function parseWords(
text: string,
reference: Date,
locale: string | undefined,
): DateParts | null {
const months = monthNames(locale);
const weekdays = weekdayNames(locale);
const parts: DateParts = {};
for (const raw of text.split(" ")) {
const token = normalizeName(raw);
if (!token || token === "of" || token === "the") continue;
// Months first: in some locales a weekday and a month share a short name
// ("mar" is Tuesday and March in Spanish and Italian).
const month = months.get(token);
if (month !== undefined) {
if (parts.month !== undefined) return null;
parts.month = month;
continue;
}
// A weekday alongside a full date ("Thu, Oct 1") is redundant.
if (weekdays.has(token)) continue;
const number = token.match(/^'?(\d+)(?:st|nd|rd|th)?$/);
if (!number) return null;
const value = Number(number[1]);
const isYear =
number[1].length >= 3 || raw.startsWith("'") || parts.day !== undefined;
if (isYear) {
if (parts.year !== undefined) return null;
parts.year = expandYear(value, reference);
} else {
parts.day = value;
}
}
return Object.keys(parts).length > 0 ? parts : null;
}
function parseOne(
input: string,
reference: Date,
locale: string | undefined,
): Parsed | null {
const text = input
.trim()
.toLowerCase()
.replace(/,/g, " ")
.replace(/\s+/g, " ");
if (!text) return null;
const relative =
parseRelative(text, reference) ?? parseWeekday(text, reference, locale);
if (relative) return { date: relative };
const parts =
parseNumeric(text, reference, locale) ??
parseWords(text, reference, locale);
return parts ? { parts } : null;
}
/** Fills missing parts from the reference date; rejects impossible days. */
function resolve(parts: DateParts, reference: Date): Date | null {
const year = parts.year ?? reference.getFullYear();
const month = parts.month ?? reference.getMonth();
// A month on its own ("march 2027") means its first day.
const day =
parts.day ?? (parts.month !== undefined ? 1 : reference.getDate());
if (year < MIN_YEAR || year > MAX_YEAR) return null;
if (month < 0 || month > 11 || day < 1 || day > daysInMonth(year, month)) {
return null;
}
return makeDate(year, month, day);
}
/**
* Reads a typed date. Returns `null` when the text isn't a date it
* understands, or names a day that doesn't exist ("feb 30").
*/
export function parseDate(
input: string,
options: ParseDateOptions = {},
): Date | null {
const reference = startOfDay(options.referenceDate ?? new Date());
const parsed = parseOne(input, reference, options.locale);
if (!parsed) return null;
return valid(
"date" in parsed ? parsed.date : resolve(parsed.parts, reference),
);
}
function dateOf(parsed: Parsed): Date | null {
return "date" in parsed ? parsed.date : null;
}
function checkedRange(range: ParsedDateRange): ParsedDateRange | null {
const from = valid(range.from);
const to = valid(range.to);
return from && to ? { from, to } : null;
}
/* -------------------------------------------------------------------------------------------------
* Ranges
* -------------------------------------------------------------------------------------------------*/
function parseRelativeRange(
text: string,
reference: Date,
): ParsedDateRange | null {
// "last 7 days", "past 2 weeks", "next 30 days"
const match = text.match(/^(last|past|next)\s+(\d+)\s*([a-z]+)$/);
const unit = match && unitOf(match[3]);
if (!match || !unit) return null;
const amount = Number(match[2]);
if (amount < 1) return null;
if (match[1] === "next") {
return {
from: reference,
to: add(add(reference, amount, unit), -1, "day"),
};
}
return { from: add(add(reference, -amount, unit), 1, "day"), to: reference };
}
/**
* Reads a typed range: two dates joined by a dash, "to", or "until", or a
* relative span like "last 7 days". The second date borrows a missing month
* or year from the first ("Oct 1 – 8"), the first borrows a missing year from
* the second ("Oct 1 – 8, 2026"), and a range written backwards is flipped. A
* single date reads as a one-day range.
*/
export function parseDateRange(
input: string,
options: ParseDateOptions = {},
): ParsedDateRange | null {
const reference = startOfDay(options.referenceDate ?? new Date());
const { locale } = options;
const text = input.trim().toLowerCase().replace(/\s+/g, " ");
if (!text) return null;
const relative = parseRelativeRange(text, reference);
if (relative) return checkedRange(relative);
const sides = text.split(RANGE_SEPARATOR);
if (sides.length === 1) {
const date = parseDate(text, options);
return date ? { from: date, to: date } : null;
}
if (sides.length !== 2) return null;
const start = parseOne(sides[0], reference, locale);
const end = parseOne(sides[1], reference, locale);
if (!start || !end) return null;
const startParts = "parts" in start ? { ...start.parts } : null;
const endParts = "parts" in end ? { ...end.parts } : null;
// Whether a side's year came from the other side rather than the text.
let startYearBorrowed = false;
let endYearBorrowed = false;
if (startParts && endParts) {
endParts.month ??= startParts.month;
if (endParts.year === undefined && startParts.year !== undefined) {
endParts.year = startParts.year;
endYearBorrowed = true;
} else if (startParts.year === undefined && endParts.year !== undefined) {
startParts.year = endParts.year;
startYearBorrowed = true;
}
}
let from = startParts ? resolve(startParts, reference) : dateOf(start);
if (!from) return null;
// A bare end ("today – 14") fills in from the start, not from today.
let to = endParts ? resolve(endParts, from) : dateOf(end);
if (!to) return null;
if (to < from && to.getMonth() < from.getMonth()) {
// Running backwards across months with an inferred year means the range
// crosses New Year: "Dec 28 – Jan 4" ends in the January after.
if (startParts && startYearBorrowed) {
from = resolve({ ...startParts, year: to.getFullYear() - 1 }, reference);
} else if (endParts && (endYearBorrowed || endParts.year === undefined)) {
to = resolve({ ...endParts, year: from.getFullYear() + 1 }, from);
}
if (!from || !to) return null;
}
return checkedRange(to < from ? { from: to, to: from } : { from, to });
}
```
## Usage [#usage]
**Imports:**
```tsx
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
```
**Basic Usage:**
```tsx
```
## Examples [#examples]
### Controlled [#controlled]
Use `value` and `onValueChange` to control the date. An empty field is `null`; add `clearable` for a button that empties it.
```tsx
// date-picker-controlled.tsx
"use client";
import { useState } from "react";
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
export default function DatePickerControlled() {
const [date, setDate] = useState(new Date());
return (
{date ? date.toDateString() : "No date"}
);
}
```
### Typing a Date [#typing-a-date]
Use `editable` to make the field a text input, with the calendar on a button beside it. It reads input such as "today", "tomorrow", "next fri", "in 2 weeks", "mar 14", "14 March 2027", "3/14" (in the locale's day and month order), or "2026-03-14". A hint on the right shows how the picker reads the text before the value changes. `Enter` or leaving the field commits; `Escape` undoes the edit.
```tsx
// date-picker-editable.tsx
"use client";
import { useState } from "react";
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
export default function DatePickerEditable() {
const [date, setDate] = useState(null);
return (
{date ? date.toDateString() : "No date"}
);
}
```
### Presets [#presets]
Use `presets` to add one-click shortcuts beside the calendar. Give each a function instead of a fixed date so relative presets like "Tomorrow" resolve when the picker opens.
```tsx
// date-picker-presets.tsx
import {
DatePicker,
type DatePickerPreset,
} from "@/components/ui/cubby-ui/date-picker/date-picker";
function addDays(days: number): Date {
const date = new Date();
date.setDate(date.getDate() + days);
return date;
}
/** The next given weekday (0 = Sunday), never today. */
function next(weekday: number): Date {
const date = new Date();
date.setDate(date.getDate() + (((weekday - date.getDay() + 6) % 7) + 1));
return date;
}
const presets: DatePickerPreset[] = [
{ label: "Today", value: () => new Date() },
{ label: "Tomorrow", value: () => addDays(1) },
{ label: "Next Monday", value: () => next(1) },
{ label: "In a week", value: () => addDays(7) },
{ label: "In a month", value: () => addDays(30) },
];
export default function DatePickerPresets() {
return ;
}
```
### Min and Max [#min-and-max]
Use `minDate` and `maxDate` to block days outside a window and stop navigation past it. Use `disabledDates` for other blocks, such as weekends.
```tsx
// date-picker-min-max.tsx
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
const today = new Date();
const inSixtyDays = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 60,
);
export default function DatePickerMinMax() {
return (
);
}
```
### In a Form [#in-a-form]
Use `name` to submit the date as `YYYY-MM-DD` through a hidden input. An `id` goes on the trigger, so a `` can name it.
```tsx
// date-picker-form.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/cubby-ui/button";
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
export default function DatePickerForm() {
const [submitted, setSubmitted] = useState(null);
return (
);
}
```
### Custom Placeholder and Format [#custom-placeholder-and-format]
Use `placeholder` to set the empty text and `format` to control how the date is written. `format` takes the `Date` and returns a string.
```tsx
// date-picker-custom-placeholder-and-format.tsx
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
const longDate = new Intl.DateTimeFormat(undefined, {
weekday: "short",
month: "long",
day: "numeric",
});
export default function DatePickerCustomPlaceholderAndFormat() {
return (
longDate.format(date)}
/>
);
}
```
### Different Widths [#different-widths]
Use a width utility in `className` to size the field. The default is `w-60`.
```tsx
// date-picker-different-widths.tsx
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
export default function DatePickerDifferentWidths() {
return (
);
}
```
### Disabled State [#disabled-state]
Use `disabled` to stop the picker from opening.
```tsx
// date-picker-disabled-state.tsx
import { DatePicker } from "@/components/ui/cubby-ui/date-picker/date-picker";
export default function DatePickerDisabledState() {
return ;
}
```
## Accessibility [#accessibility]
The trigger is a button that opens the calendar in a popover and moves focus into it. Inside, use the arrow keys to move between days, `PageUp` and `PageDown` to change months, and `Enter` or `Space` to pick. Picking a date closes the popover and returns focus to the trigger; `Escape` closes it without a change. The clear button is a separate control after the trigger, labelled "Clear date".
With `editable`, the field is a text input (label it with `id` and a ``, or `aria-label`), and the calendar opens from a "Choose date" button beside it or with `ArrowDown` in the input. The hint is a polite live region, so screen readers announce how the text was read ("Fri, Oct 9, 2026", "Unavailable"). Committing text the picker can't read sets `aria-invalid` on the input.
## API Reference [#api-reference]
DatePicker composes [`Calendar`](/docs/components/calendar) inside a [`Popover`](/docs/components/popover), with a trigger styled like an [`Input`](/docs/components/input). Props not listed below (`id`, `aria-*`, event handlers) go to the trigger button.
### Props [#props]
#### DatePicker [#datepicker]
- **value** (type: `object`, full: `Date | null`)
The selected date, or `null` for none. Pair with `onValueChange` for
controlled use.
- **defaultValue** (type: `object`, full: `Date | null`) - Default: `null`
The starting date when uncontrolled.
- **onValueChange**
void" simpleType="function">
Fires when a date is picked or cleared.
- **open** (type: `boolean`)
Whether the popover is open. Pair with `onOpenChange`, or use `defaultOpen`.
- **defaultOpen** (type: `boolean`) - Default: `false`
Whether the popover starts open when uncontrolled.
- **onOpenChange**
void" simpleType="function">
Fires when the popover opens or closes.
- **placeholder** (type: `string`) - Default: `"Pick a date"`
Text shown on the trigger when no date is selected.
- **format**
string" simpleType="function">
Writes the date on the trigger. Defaults to the locale's medium date, such as
"Oct 1, 2026".
- **editable** (type: `boolean`) - Default: `false`
Makes the field a text input that reads typed dates, with the calendar on a
button beside it. Rest props go to the input instead of the trigger.
- **clearable** (type: `boolean`) - Default: `false`
Shows a button that clears the date.
- **minDate** (type: `Date`)
Earliest selectable date. Also stops navigation before its month.
- **maxDate** (type: `Date`)
Latest selectable date. Also stops navigation after its month.
- **disabledDates** (type: `Matcher | Matcher[]`)
Extra dates to block, as DayPicker matchers.
- **presets**
Date) }[]" simpleType="array">
One-click shortcuts shown beside the calendar. Picking one commits it and
closes the popover.
- **name** (type: `string`)
Submits the date as `YYYY-MM-DD` under this name in a form.
- **variant** (type: `"default" | "outline"`) - Default: `"default"`
Sets the trigger background, matching `Input`. `outline` uses the outline
Button's fill and hairline edge.
- **size** (type: `"default" | "sm"`) - Default: `"default"`
Trigger height, matching `Input`.
- **disabled** (type: `boolean`) - Default: `false`
Prevents the popover from opening.
- **calendarProps**
" simpleType="object">
Passed to the calendar: `locale`, `weekStartsOn`, `showWeekNumber`, and other
DayPicker props.
- **labels**
" simpleType="object">
Overrides for the picker's own text, merged over English defaults:
`chooseDate`, `clear`, `presets`, `invalid`, and `unavailable`. Use it to
reword or translate.
- **className** (type: `string`)
Classes for the root element. Use a width utility to size the field.
## Upgrading [#upgrading]
If you're reinstalling over an earlier version, check these changes:
| Before | Now |
| ---------------------------------------------------- | ----------------------------------------------------------------------- |
| `variant="elevated"` for fields on Cards and Dialogs | Removed. The default tint works on any surface, so drop the prop. |
| One filled style | `variant="outline"` adds a bordered style, matching the outline Button. |
# Date Range Picker (/docs/components/date-range-picker)
The date range picker is a field that opens a calendar popover for choosing a start and end date. Use it for report ranges, booking windows, and date filters.
To select a single date, use [`DatePicker`](/docs/components/date-picker) instead.
## Preview [#preview]
```tsx
// date-range-picker-basic.tsx
"use client";
import { useState } from "react";
import {
DateRangePicker,
type DateRangeValue,
} from "@/components/ui/cubby-ui/date-range-picker";
function addDays(date: Date, days: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
export default function DateRangePickerBasic() {
const [range, setRange] = useState(() => {
const today = new Date();
return { from: today, to: addDays(today, 7) };
});
return ;
}
```
## Installation [#installation]
### Installation
**CLI:**
```bash
npx shadcn@latest add @cubby-ui/date-range-picker
```
**Manual:**
1. Install dependencies:
```bash
npm install react-day-picker class-variance-authority
```
2. Copy the component source code to your project:
Create `components/ui/cubby-ui/date-range-picker.tsx`:
```tsx
"use client";
import * as React from "react";
import {
rangeContainsModifiers,
type DateRange,
type Matcher,
type PropsBase,
} from "react-day-picker";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import {
Calendar,
CalendarPreset,
type CalendarLabelsProp,
} from "@/components/ui/cubby-ui/calendar/calendar";
import {
DatePickerClear,
DatePickerContent,
DatePickerField,
DatePickerTrigger,
toDisabledMatchers,
type DateFieldReading,
type PickerControlProps,
} from "@/registry/default/date-picker/date-picker-parts";
import { useControllableState } from "@/hooks/cubby-ui/use-controllable-state";
import type { inputVariants } from "@/components/ui/cubby-ui/input";
import {
daysBetween,
isSameDay,
startOfDay,
toISODate,
} from "@/lib/cubby-ui/date-utils";
import { parseDateRange } from "@/lib/cubby-ui/parse-date";
import { Popover } from "@/components/ui/cubby-ui/popover";
export type { DateRange };
/** A complete range: both ends set. */
export interface DateRangeValue {
from: Date;
to: Date;
}
export interface DateRangePreset {
label: string;
/** A range, or a function so relative presets ("Last 7 days") stay current. */
value: DateRangeValue | (() => DateRangeValue);
}
/** Copy overrides for the picker's own text, for wording and translation. */
export interface DateRangePickerLabels {
/** The calendar button beside an `editable` field. */
chooseDates: string;
clear: string;
/** The group of presets, for screen readers. */
presets: string;
/** Status line before anything is picked. */
pickStart: string;
/** Status line once the start is picked; `start` is already formatted. */
pickEnd: (start: string) => string;
/**
* A range's length, after the dates in the status line and the typing
* hint. Counts both ends; return nights (`days - 1`) for stays.
*/
duration: (days: number) => string;
/** Typed text that can't be read as a range (`editable`). */
invalid: string;
/** A typed range that crosses a blocked date or the min/max bounds. */
unavailable: string;
/** A typed range shorter than `minNights`. */
tooShort: (minNights: number) => string;
/** A typed range longer than `maxNights`. */
tooLong: (maxNights: number) => string;
}
const DEFAULT_LABELS: DateRangePickerLabels = {
chooseDates: "Choose dates",
clear: "Clear date range",
presets: "Presets",
pickStart: "Pick a start date",
pickEnd: (start) => `${start} – pick an end date`,
duration: (days) => `${days} ${days === 1 ? "day" : "days"}`,
invalid: "Not a date range",
unavailable: "Unavailable",
tooShort: (nights) =>
`At least ${nights} ${nights === 1 ? "night" : "nights"}`,
tooLong: (nights) => `At most ${nights} ${nights === 1 ? "night" : "nights"}`,
};
export type DateRangePickerProps = PickerControlProps &
VariantProps & {
/** The selected range. `null` means nothing is selected. */
value?: DateRangeValue | null;
defaultValue?: DateRangeValue | null;
/** Fires once both ends are picked, or with `null` when cleared. */
onValueChange?: (value: DateRangeValue | null) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
/** Formats the range on the trigger. Defaults to a compact locale range. */
format?: (range: DateRangeValue) => string;
/** Shows a button that clears the range. */
clearable?: boolean;
/**
* Makes the field a text input: type "mar 3 – 12", "today to fri" or
* "last 30 days", and the calendar opens from the button beside it.
*/
editable?: boolean;
disabled?: boolean;
/** Earliest selectable date. Also stops navigation before its month. */
minDate?: Date;
/** Latest selectable date. Also stops navigation after its month. */
maxDate?: Date;
/** Extra dates to block, as DayPicker matchers. */
disabledDates?: Matcher | Matcher[];
/** Shortest allowed range, in nights (DayPicker's `min`). */
minNights?: number;
/** Longest allowed range, in nights (DayPicker's `max`). */
maxNights?: number;
/** Months shown side by side. Defaults to 2, or 1 on small screens. */
numberOfMonths?: number;
/**
* Shows a line under the calendar with the range being picked and its
* length. Hidden, it still announces each step to screen readers.
*/
showStatus?: boolean;
/** One-click ranges shown beside the calendar. */
presets?: DateRangePreset[];
/** Submits the ends as `YYYY-MM-DD` under `${name}From` and `${name}To`. */
name?: string;
/**
* Passed through to the calendar (locale, week start, and so on). Its
* `labels` take DayPicker's and the calendar's own screen-reader text.
*/
calendarProps?: Omit, "labels"> & {
labels?: CalendarLabelsProp;
};
/** Copy overrides for the picker's own text, merged over the defaults. */
labels?: Partial;
};
/** Days in a range, counting both ends. */
function countDays(range: DateRangeValue): number {
return daysBetween(range.from, range.to) + 1;
}
function resolvePreset(preset: DateRangePreset): DateRangeValue {
const range =
typeof preset.value === "function" ? preset.value() : preset.value;
return { from: startOfDay(range.from), to: startOfDay(range.to) };
}
/**
* ICU versions disagree on thin vs regular spaces around the range dash, which
* breaks hydration between Node and the browser, so those are normalised.
*/
function formatSpan(
formatter: Intl.DateTimeFormat,
range: DateRangeValue,
): string {
return isSameDay(range.from, range.to)
? formatter.format(range.from)
: formatter
.formatRange(range.from, range.to)
.replace(/[\u2009\u202f]/g, " ");
}
function useMediaQuery(query: string): boolean {
return React.useSyncExternalStore(
(onChange) => {
const list = window.matchMedia(query);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}
function DateRangePicker({
value: valueProp,
defaultValue = null,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
placeholder = "Pick a date range",
format,
clearable = false,
editable = false,
disabled,
minDate,
maxDate,
disabledDates,
minNights,
maxNights,
numberOfMonths,
showStatus = false,
presets,
name,
calendarProps,
labels,
variant,
size,
className,
...props
}: DateRangePickerProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onValueChange,
});
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onValueChange: onOpenChange,
});
const triggerRef = React.useRef(null);
const groupRef = React.useRef(null);
const wide = useMediaQuery("(min-width: 640px)");
// The range being picked. It only reaches `value` once both ends are set,
// so closing halfway leaves the committed range untouched.
const [draft, setDraft] = React.useState(
value ?? undefined,
);
// Reset the draft on open, and whenever the value changes from outside.
const [wasOpen, setWasOpen] = React.useState(open);
const valueKey = value ? `${value.from.getTime()}:${value.to.getTime()}` : "";
const [lastValueKey, setLastValueKey] = React.useState(valueKey);
if (open !== wasOpen || valueKey !== lastValueKey) {
setWasOpen(open);
setLastValueKey(valueKey);
if (open) setDraft(value ?? undefined);
}
const copy = { ...DEFAULT_LABELS, ...labels };
const locale = calendarProps?.locale?.code;
const dayFormat = new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
const formatRange = (range: DateRangeValue) =>
format?.(range) ?? formatSpan(dayFormat, range);
const formatHint = (range: DateRangeValue) => {
const thisYear = new Date().getFullYear();
return range.from.getFullYear() === thisYear &&
range.to.getFullYear() === thisYear
? formatSpan(
new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }),
range,
)
: formatRange(range);
};
const disabledMatchers = toDisabledMatchers(minDate, maxDate, disabledDates);
/** Why a range can't be picked, or undefined when it can. */
const rangeError = (range: DateRangeValue): string | undefined => {
const nights = countDays(range) - 1;
if (
disabledMatchers.length > 0 &&
rangeContainsModifiers(range, disabledMatchers)
) {
return copy.unavailable;
}
if (minNights !== undefined && nights < minNights) {
return copy.tooShort(minNights);
}
if (maxNights !== undefined && nights > maxNights) {
return copy.tooLong(maxNights);
}
return undefined;
};
const commit = (range: DateRangeValue | null) => {
setValue(range);
setOpen(false);
};
const handleSelect = (range: DateRange | undefined) => {
if (range?.from && range.to) {
commit({ from: range.from, to: range.to });
return;
}
setDraft(range);
};
const status = (() => {
if (!draft?.from) return copy.pickStart;
if (!draft.to) return copy.pickEnd(dayFormat.format(draft.from));
const range = { from: draft.from, to: draft.to };
return `${formatRange(range)} · ${copy.duration(countDays(range))}`;
})();
const readText = (text: string): DateFieldReading | null => {
const range = parseDateRange(text, { locale });
if (!range) return null;
return {
value: range,
hint: `${formatHint(range)} · ${copy.duration(countDays(range))}`,
error: rangeError(range),
};
};
const showClear = clearable && value !== null && !disabled;
return (
setOpen(next)}>
{editable ? (
setOpen(true)}
triggerLabel={copy.chooseDates}
clearLabel={copy.clear}
invalidLabel={copy.invalid}
showClear={showClear}
placeholder={placeholder}
disabled={disabled}
variant={variant}
size={size}
{...props}
/>
) : (
{value ? formatRange(value) : placeholder}
)}
{
const range = resolvePreset(preset);
return (
commit(range)}
>
{preset.label}
);
})}
>
0 ? disabledMatchers : undefined
}
startMonth={calendarProps?.startMonth ?? minDate}
endMonth={calendarProps?.endMonth ?? maxDate}
/>
{/* Wraps rather than truncates: a cross-year range or a longer
locale can run past one month's width. */}
{status}
{showClear && !editable && (
{
setValue(null);
triggerRef.current?.focus();
}}
/>
)}
{name && (
<>
>
)}
);
}
export { DateRangePicker };
```
Create `hooks/cubby-ui/use-controllable-state.ts`:
```tsx
"use client";
import * as React from "react";
interface UseControllableStateParams {
/** Controlled value. When defined, the hook is in controlled mode. */
value?: T;
/** Initial value for uncontrolled mode. */
defaultValue: T;
onValueChange?: (value: T) => void;
}
/**
* Merges controlled and uncontrolled state into a single `[value, setValue]`
* tuple, mirroring the pattern Radix and Base UI use internally.
*
* `onValueChange` fires synchronously in both modes. Functional updates are
* safe in both modes: they resolve against an eagerly-advanced ref, so
* multiple `setValue` calls in one event tick compose. `setValue` is
* referentially stable across renders.
*
* The ref re-syncs from the prop at the next commit — but only if one happens.
* Known constraint: a controlled parent that ignores `onValueChange` AND never
* re-renders leaves the ref advanced past the real value, and a repeat
* `setValue` to that same value early-returns at the `Object.is` check without
* re-notifying. Acceptable — a controlled parent that never commits is already
* outside the controlled contract — but it is the first thing to check when a
* controlled value appears stuck.
*/
export function useControllableState({
value,
defaultValue,
onValueChange,
}: UseControllableStateParams): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = value !== undefined;
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const current = isControlled ? (value as T) : uncontrolled;
const onChangeRef = React.useRef(onValueChange);
// Mirrors `current`. Re-synced after every commit; eagerly advanced by
// setValue so multiple calls in one event tick compose.
const currentRef = React.useRef(current);
React.useEffect(() => {
onChangeRef.current = onValueChange;
currentRef.current = current;
});
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
const prev = currentRef.current;
const resolved =
typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
if (Object.is(resolved, prev)) return;
if (isControlled) {
// The parent owns the state, but advance the ref eagerly so a second
// functional setValue in the same tick composes on this one instead of
// clobbering it. The ref re-syncs from the prop at the next commit,
// which also corrects it if the parent rejected the change.
currentRef.current = resolved;
onChangeRef.current?.(resolved);
} else {
currentRef.current = resolved;
setUncontrolled(resolved);
onChangeRef.current?.(resolved);
}
},
[isControlled],
);
return [current, setValue];
}
```
Create `lib/cubby-ui/date-utils.ts`:
```tsx
/**
* Small local-time date helpers shared by the calendar, the date pickers, and
* parse-date. Every date here is a local calendar day; times are dropped.
*/
export const DAY_MS = 24 * 60 * 60 * 1000;
/** The same calendar day at local midnight. */
export function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/** Whether two dates fall on the same calendar day, ignoring the time. */
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/**
* Calendar days from `a` to `b` (negative when `b` is earlier), ignoring the
* time of day. Rounded, so a DST change inside the span doesn't skew it.
*/
export function daysBetween(a: Date, b: Date): number {
return Math.round(
(startOfDay(b).getTime() - startOfDay(a).getTime()) / DAY_MS,
);
}
/** `YYYY-MM-DD` in local time, the form ` ` submits. */
export function toISODate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
```
Create `lib/cubby-ui/parse-date.ts`:
```tsx
/**
* Forgiving parsers for dates people type: "tomorrow", "next fri", "in 3
* weeks", "mar 14", "14 March 2027", "3/14", "2026-03-14", and ranges such as
* "Oct 1 – 8, 2026" or "last 30 days". Numeric dates follow the locale's
* day/month order. Everything resolves to local midnight.
*/
import { startOfDay } from "@/lib/cubby-ui/date-utils";
export interface ParseDateOptions {
/** The "today" that relative input resolves against. Defaults to now. */
referenceDate?: Date;
/** BCP 47 locale for month and weekday names and numeric order. */
locale?: string;
}
export interface ParsedDateRange {
from: Date;
to: Date;
}
interface DateParts {
year?: number;
month?: number;
day?: number;
}
type Parsed = { date: Date } | { parts: DateParts };
type Unit = "day" | "week" | "month" | "year";
const UNITS: Record = {
d: "day",
day: "day",
days: "day",
w: "week",
wk: "week",
wks: "week",
week: "week",
weeks: "week",
mo: "month",
mos: "month",
month: "month",
months: "month",
y: "year",
yr: "year",
yrs: "year",
year: "year",
years: "year",
};
const KEYWORDS: Record = {
today: 0,
tod: 0,
now: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
tom: 1,
yesterday: -1,
yday: -1,
};
/** Own entries only, so names like "constructor" aren't found on Object. */
function unitOf(name: string): Unit | undefined {
return Object.hasOwn(UNITS, name) ? UNITS[name] : undefined;
}
function keywordOf(name: string): number | undefined {
return Object.hasOwn(KEYWORDS, name) ? KEYWORDS[name] : undefined;
}
const MIN_YEAR = 1;
const MAX_YEAR = 9999;
/**
* Null for out-of-range years and Invalid Date, which huge input like
* "in 99999999 years" produces and which throws when formatted.
*/
function valid(date: Date | null): Date | null {
if (!date || Number.isNaN(date.getTime())) return null;
const year = date.getFullYear();
return year >= MIN_YEAR && year <= MAX_YEAR ? date : null;
}
const RANGE_SEPARATOR = /\s+(?:-|to|until|till|through|thru)\s+|\s*[–—]\s*/;
/* -------------------------------------------------------------------------------------------------
* Date arithmetic
* -------------------------------------------------------------------------------------------------*/
/** `new Date(y, m, d)`, except years 0–99 stay literal instead of meaning 1900–1999. */
function makeDate(year: number, month: number, day: number): Date {
const date = new Date(2000, month, day);
date.setFullYear(year, month, day);
return date;
}
function daysInMonth(year: number, month: number): number {
return makeDate(year, month + 1, 0).getDate();
}
/** Adds whole units. Months and years clamp to the end of a shorter month. */
function add(date: Date, amount: number, unit: Unit): Date {
if (unit === "day" || unit === "week") {
const days = unit === "week" ? amount * 7 : amount;
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}
const months = unit === "year" ? amount * 12 : amount;
const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
const day = Math.min(
date.getDate(),
daysInMonth(target.getFullYear(), target.getMonth()),
);
return new Date(target.getFullYear(), target.getMonth(), day);
}
/* -------------------------------------------------------------------------------------------------
* Locale names
* -------------------------------------------------------------------------------------------------*/
const nameCache = new Map>();
function normalizeName(name: string): string {
return name.toLowerCase().replace(/\.$/, "");
}
/** Month names (long and short) in the locale and English, to month index. */
function monthNames(locale: string | undefined): Map {
const key = `month:${locale ?? ""}`;
const cached = nameCache.get(key);
if (cached) return cached;
const names = new Map