# SUI
> SUI is a collection of composable React components built on Base UI and Tailwind CSS. These documents include complete examples from the workspace.
# CLI
Search, inspect, install, and update SUI source with shadcn.
Page: https://sui.draco.dev/docs/cli
SUI uses the shadcn CLI. Complete the [registry configuration](/docs/installation#prepare-the-consuming-application) in the consuming application, then run commands from that application's directory.
## Search and inspect
```bash
bunx --bun shadcn@latest search @sui -q input
bunx --bun shadcn@latest view @sui/sensitive-input
```
Search matches registry names and descriptions. View shows a manifest's files and dependencies before you install it. The [registry guide](/docs/registry) explains the catalog and item formats.
## Install source
```bash
bunx --bun shadcn@latest add @sui/button @sui/sensitive-input
bunx --bun shadcn@latest add @sui/data-table
```
The CLI copies required source files into your configured `ui`, `components`, `lib`, and `hooks` directories, installs package dependencies, and merges SUI styles into the CSS file in `components.json`. Files become part of your application and can be edited locally.
Each item carries its local dependency closure. Installing a component does not require first installing the entire library. Use `@sui/sui` for the complete collection, `@sui/sui-style` for shared CSS, or `@sui/sui-theme` for the theme helpers and styles.
Editor and the complete collection require the local Monaco worker setup described in [compatibility](/docs/compatibility#editor-workers).
## Review an update
```bash
bunx --bun shadcn@latest add @sui/button --dry-run
bunx --bun shadcn@latest add @sui/button --diff
```
An update can affect shared dependencies and CSS as well as the requested component. Compare changes with your local edits before accepting an overwrite. Installation is a source copy; installed files do not automatically follow future changes in the SUI repository.
## Common issues
| Symptom | Check |
| --- | --- |
| Unknown `@sui` registry | Run from the consuming application and check its `components.json`. |
| GitHub returns `404` | Check the configured URL and whether the requested item is available in the registry. |
| Import path differs from a docs example | Follow configured aliases, rather than workspace paths. See [import mapping](/docs/installation#import-installed-source). |
| Missing animation or theme styles | Confirm the configured CSS file is imported by the application. |
| Monaco worker import fails | Use a compatible `?worker` loader and include the installed declaration file. |
For installation through an assistant, see [MCP](/docs/mcp). For API and example text, see [LLMs](/docs/llms-txt).
---
# Compatibility
Application responsibilities, SSR, worker loading, and browser capabilities.
Page: https://sui.draco.dev/docs/compatibility
SUI targets React 19 and Tailwind CSS 4 with Base UI primitives. Source installation uses your application's aliases and CSS entrypoint; see [installation](/docs/installation). Modern CSS features, including OKLCH colors and backdrop filters, are part of the visual system.
## SSR and application state
The documentation site uses TanStack Start and SSR. Browser-dependent viewers and the editor initialize after mounting, with base content or skeletons while loading. In an application with server and client component boundaries, keep interactive components on the client side and preserve installed `use client` directives.
Theme persistence, language routing, validation, and business requests belong to the application. ThemeToggle and LocaleToggle receive controlled values and callbacks. Blocks provide English defaults and accept `labels`; pass translations from your application's locale system. Use [Field](/docs/components/field) to compose labels, descriptions, and validation messages.
## Editor workers
[Editor](/docs/components/editor) loads Monaco and its workers locally. Its installed source uses Vite-compatible `?worker` imports. A different bundler requires an equivalent worker integration; installing source does not configure that bundler automatically. Include the installed `worker.d.ts` in TypeScript's source paths.
CodeViewer and DiffViewer can still show and copy raw content if syntax highlighting fails. The editor and viewers share the Shiki-based highlighting infrastructure.
## Browser features
| Feature | Application consideration |
| --- | --- |
| Clipboard | Browser permissions and a secure context determine availability; the components provide failure feedback. |
| Fullscreen | Browser fullscreen is optional; permission or platform restrictions can prevent entry. |
| Glass | CSS + SVG is the default. Optional WebGPU enhancement falls back when capabilities or capture fail. |
| Theme transitions | The toggle respects reduced motion and can apply the theme without a view transition. |
| HTML preview | HtmlViewer defaults to a sandbox with scripts allowed and without same-origin permission. Editor's built-in HTML preview disables scripts. |
See [Glass limitations](/docs/components/glass#limits) for cross-origin images, frames, video, Canvas, and capture cadence. A transparent surface needs to remain readable over its actual background; use an ordinary or denser surface when needed.
## Accessibility and direction
Compose visible labels or accessible names for icon-only actions. Keep titles in dialogs, provide image descriptions, and use externally supplied labels for loading and feedback. Keyboard and focus behavior come from the underlying primitives; replacing native controls with custom markup can change those behaviors.
Use [Direction](/docs/components/direction) for RTL context and keep the application's `dir` attribute consistent. Loader and theme transitions honor reduced-motion preferences; custom animations added by the application need the same consideration.
## Verification
After installation, run the consuming application's type checks and production build. Test its keyboard flow, forms, overlays, copy feedback, themes, and translated labels. The SUI documentation examples exercise components locally; they do not verify your application's routing, persistence, API requests, or deployment.
---
# Design guidelines
Complete typography, spacing, surface, and interaction rules with recommended and avoid examples for every rule.
Page: https://sui.draco.dev/docs/design-guidelines
Apply all of these rules when composing application interfaces with SUI. Every rule includes a working visual comparison and copyable code. Styles in the avoid column are teaching examples and should not be used in product interfaces.
## Shared styles and semantic colors
Import `@workspace/ui/globals.css` and compose components from `@workspace/ui/components/*`. Style native text elements with Tailwind size and weight utilities. Use semantic tokens such as `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`, `bg-primary`, `border-border`, and `ring-ring`; keep destructive actions on their independent `destructive` tokens.
The default appearance uses Apple-inspired neutral surfaces and a blue accent. Select shared palettes with `data-color`: `default`, `bamboo`, `mauve`, `mist`, `sand`, `pine`, or `rose`. Remove the attribute or select `default` to reset the appearance; use `dark` for dark mode. Accent, focus, and chart colors change together while backgrounds and body text remain neutral. See [Theming](/docs/theming) for configuration.
```css
@import "@workspace/ui/globals.css";
```
```html
```
## Use 14px for content text
All content text—body, buttons, data, other interactables—must be 14px in size. 16px and above are restricted to headings and subheadings.
### Example: design-guidelines-content-text-size
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return
{zh ? "内容文字" : "Content text"}
;
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return {zh ? "内容文字" : "Content text"}
;
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
Content text
;
```
**Avoid**
```tsx
Content text
;
```
## Always sentence case headings
Never capitalize or uppercase headings. Product names must be title-cased.
The avoid column shows both title case and an `uppercase` transformation.
### Example: design-guidelines-heading-case
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample() {
return Recent requests ;
}
function AvoidSample() {
return (
Recent Requests
Recent requests
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
Recent requests ;
```
**Avoid**
```tsx
Recent Requests ;
```
```tsx
Recent requests ;
```
## Never change the font’s tracking
Do not use the `tracking-*` classes to change the spacing between characters.
### Example: design-guidelines-font-tracking
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "项目指标" : "Project metrics"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "项目指标" : "Project metrics"}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
Project metrics ;
```
**Avoid**
```tsx
Project metrics ;
```
## Never use font-bold
Use `font-semibold` for headings and `font-medium` for bold inline text. Never use `font-bold`.
### Example: design-guidelines-font-weight
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
<>
{zh ? "账户设置" : "Account settings"}
{zh ? "必填" : "required"}
>
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
<>
{zh ? "账户设置" : "Account settings"}
{zh ? "必填" : "required"}
>
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
<>
Account settings
required
>;
```
**Avoid**
```tsx
<>
Account settings
required
>;
```
## Put related text closer together
Related text should have smaller spacing around it than the content it belongs to.
### Example: design-guidelines-related-text-spacing
```tsx
import { Button } from "@workspace/ui/components/button";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "网站分析" : "Web analytics"}
{zh
? "无需修改代码即可衡量网站访问量"
: "Measure site traffic without changing your code."}
{zh ? "配置" : "Configure"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "网站分析" : "Web analytics"}
{zh
? "无需修改代码即可衡量网站访问量"
: "Measure site traffic without changing your code."}
{zh ? "配置" : "Configure"}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Button } from "@workspace/ui/components/button";
Web analytics
Measure site traffic without changing your code.
Configure
;
```
**Avoid**
```tsx
import { Button } from "@workspace/ui/components/button";
Web analytics
Measure site traffic without changing your code.
Configure
;
```
## Optically align spacing around text
Spacing around text should take into account its line height. Typically this means vertical spacing should be slightly smaller than horizontal.
### Example: design-guidelines-text-spacing
```tsx
import { Card } from "@workspace/ui/components/card";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return {zh ? "内容文字" : "Content text"} ;
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return {zh ? "内容文字" : "Content text"} ;
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Card } from "@workspace/ui/components/card";
Content text ;
```
**Avoid**
```tsx
import { Card } from "@workspace/ui/components/card";
Content text ;
```
## Never transition colors for hover states
Color changes on hover must be immediate. Transitions on fast interactions make the UI feel sluggish.
Move the pointer between the two buttons to compare their response. SUI buttons can still transition `transform` and `box-shadow`; the hover color changes immediately.
### Example: design-guidelines-hover-color-transitions
```tsx
import { Button } from "@workspace/ui/components/button";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "悬停查看" : "Hover me"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "悬停查看" : "Hover me"}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Button } from "@workspace/ui/components/button";
Hover me
;
```
**Avoid**
```tsx
import { Button } from "@workspace/ui/components/button";
Hover me
;
```
## Never use borders with drop shadows
Use `ring-1 ring-border` to create a transparent border that maintains sharp edges. Do not combine `border` with a drop shadow on the same surface.
SUI Card already supplies a shadow and a subtle ring. The avoid example disables that ring to show the border-and-shadow combination explicitly.
### Example: design-guidelines-shadow-borders
```tsx
import { Card } from "@workspace/ui/components/card";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "内容文字" : "Content text"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "内容文字" : "Content text"}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Card } from "@workspace/ui/components/card";
Content text ;
```
**Avoid**
```tsx
import { Card } from "@workspace/ui/components/card";
Content text
;
```
## Use concentric border radii
When borders or rings are 8px or less apart, their corner radii must be mathematically concentric: outer radius = inner radius + padding.
At the default SUI radius, `rounded-lg` is 10px, `rounded-xl` is 14px, and `p-1` is 4px. The radius scale uses fixed offsets: `xl` adds `0.25rem`, `2xl` adds `0.5rem`, `3xl` adds `0.75rem`, and `4xl` adds `1rem` to the base radius. Changing `--radius` preserves those gaps. `sm` and `md` subtract `0.25rem` and `0.125rem`, clamped at zero.
Pair `rounded-xl` outside with `rounded-lg` inside and `p-1`; use `rounded-2xl` outside with `p-2`. For other padding values, calculate the outer radius as the inner radius plus that padding. The scale alone does not make arbitrary combinations concentric. Change the base radius below to compare the two examples.
If the outer container uses a `border`, include its width in the distance between the two boxes. A `ring` does not occupy layout space.
### Example: design-guidelines-concentric-border-radius
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "内容文字" : "Content text"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "内容文字" : "Content text"}
);
}
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [radius, setRadius] = useState(10);
return (
{zh ? "基准圆角" : "Base radius"}
{[0, 4, 10, 16].map((value) => (
setRadius(value)}
>
{value}px
))}
}
avoid={
}
/>
);
}
import { Button } from "@workspace/ui/components/button";
import { type CSSProperties, useState } from "react";
```
**Recommended**
```tsx
;
```
**Avoid**
```tsx
;
```
## Align icons with the first line of text
Inline icons must be optically the same size as and be center-aligned with text. Use `h-lh flex items-center` for multi-line alignment.
The avoid column includes both a missing line-height wrapper and vertical centering against the entire paragraph. Decorative icons use `aria-hidden`; icon-only actions need an accessible name.
### Example: design-guidelines-icon-alignment
```tsx
import { InfoIcon } from "lucide-react";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh
? "这段文字可能会换行,但图标仍应与第一行保持对齐"
: "Text that may wrap onto multiple lines and still align with the icon."}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh
? "这段文字可能会换行,但图标仍应与第一行保持对齐"
: "Text that may wrap onto multiple lines and still align with the icon."}
{zh
? "这段文字可能会换行,但图标仍应与第一行保持对齐"
: "Text that may wrap onto multiple lines and still align with the icon."}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { InfoIcon } from "lucide-react";
Text that may wrap onto multiple lines and still align with the icon.
;
```
**Avoid**
```tsx
import { InfoIcon } from "lucide-react";
Text that may wrap onto multiple lines and still align with the icon.
;
```
```tsx
import { InfoIcon } from "lucide-react";
Text that may wrap onto multiple lines and still align with the icon.
;
```
## Reduce the font size of inline monospaced text
Monospaced text should have a slightly smaller font size (~0.9em) when mixed with regular text.
### Example: design-guidelines-inline-monospace-size
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "编辑 " : "Edit "}
config.ts
{zh ? " 后继续" : " to continue."}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "编辑 " : "Edit "}
config.ts
{zh ? " 后继续" : " to continue."}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
Edit config.ts to continue.
;
```
**Avoid**
```tsx
Edit config.ts to continue.
;
```
## Use a border to separate sticky elements
Use `border` to separate sticky elements from the content.
Scroll each column to inspect the boundary under its sticky header. A separating border is appropriate here because the header has no drop shadow.
### Example: design-guidelines-sticky-borders
```tsx
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "最近请求" : "Recent requests"}
{Array.from({ length: 8 }, (_, index) => ({
id: `request-${index + 1}`,
number: index + 1,
})).map((request) => (
{zh ? "请求" : "Request"} {request.number}
))}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "最近请求" : "Recent requests"}
{Array.from({ length: 8 }, (_, index) => ({
id: `request-${index + 1}`,
number: index + 1,
})).map((request) => (
{zh ? "请求" : "Request"} {request.number}
))}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
Recent requests
{Array.from({ length: 8 }, (_, index) => ({
id: `request-${index + 1}`,
number: index + 1,
})).map((request) => (
Request {request.number}
))}
;
```
**Avoid**
```tsx
Recent requests
{Array.from({ length: 8 }, (_, index) => ({
id: `request-${index + 1}`,
number: index + 1,
})).map((request) => (
Request {request.number}
))}
;
```
## Maintain content size during collapse animations
Collapsible content must maintain its content size while closing to avoid its content shifting during animations.
Toggle each panel and watch the paragraph. Animate the outer wrapper from 256px to 0 while keeping the recommended inner content at `w-64`; the avoid example shrinks the text container and changes its line breaks. Reduced motion removes the transition.
### Example: design-guidelines-collapse-content-size
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { useReducedMotion } from "@workspace/ui/hooks/use-reduced-motion";
import { motion } from "motion/react";
import { useState } from "react";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(true);
const reduce = useReducedMotion();
return (
setOpen(!open)}>
{zh ? "切换面板" : "Toggle panel"}
{zh
? "面板关闭时,这段文字应保持相同的换行,不要在动画过程中重新排版"
: "Text should keep the same line breaks while this panel closes."}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(true);
const reduce = useReducedMotion();
return (
setOpen(!open)}>
{zh ? "切换面板" : "Toggle panel"}
{zh
? "面板关闭时,这段文字应保持相同的换行,不要在动画过程中重新排版"
: "Text should keep the same line breaks while this panel closes."}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Button } from "@workspace/ui/components/button";
import { useReducedMotion } from "@workspace/ui/hooks/use-reduced-motion";
import { motion } from "motion/react";
import { useState } from "react";
export function RecommendedExample() {
const [open, setOpen] = useState(true);
const reduce = useReducedMotion();
return (
setOpen(!open)}>
Toggle panel
Text should keep the same line breaks while this panel closes.
);
}
```
**Avoid**
```tsx
import { Button } from "@workspace/ui/components/button";
import { useReducedMotion } from "@workspace/ui/hooks/use-reduced-motion";
import { motion } from "motion/react";
import { useState } from "react";
export function AvoidExample() {
const [open, setOpen] = useState(true);
const reduce = useReducedMotion();
return (
setOpen(!open)}>
Toggle panel
Text should keep the same line breaks while this panel closes.
);
}
```
## Never stack elevated cards
Never stack elevated cards on top of one another.
Use a plain container, spacing, or separators to group content inside a card. The recommended heading sits outside the elevated surface.
### Example: design-guidelines-layer-card-nesting
```tsx
import { Card } from "@workspace/ui/components/card";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "最近请求" : "Recent requests"}
{zh ? "请求数据" : "Request data"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "最近请求" : "Recent requests"}
{zh ? "请求数据" : "Request data"}
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Card } from "@workspace/ui/components/card";
Recent requests
Request data
;
```
**Avoid**
```tsx
import { Card } from "@workspace/ui/components/card";
Recent requests
Request data
;
```
## Never conditionally render dialogs
Conditionally rendering dialogs disables their open/close animation. Use the `open` prop to determine if a dialog should be visible or not.
Open and close both dialogs. The recommended `Dialog` remains in the React tree while Base UI manages the popup’s presence, exit animation, focus, and dismissal. In the avoid example, setting `open` to false immediately removes the entire dialog tree. Always include a title and description.
### Example: design-guidelines-dialog-rendering
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { useState } from "react";
import { DesignComparison } from "../design-guidelines-comparison";
import type { ExampleProps } from "../types";
function RecommendedSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(false);
return (
}>
{zh ? "打开弹窗" : "Open dialog"}
{zh ? "编辑项目" : "Edit project"}
{zh ? "更新此项目的设置" : "Update this project’s settings."}
}>
{zh ? "关闭" : "Close"}
);
}
function AvoidSample({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(false);
return (
<>
setOpen(true)}>
{zh ? "打开弹窗" : "Open dialog"}
{open && (
{zh ? "编辑项目" : "Edit project"}
{zh ? "更新此项目的设置" : "Update this project’s settings."}
}>
{zh ? "关闭" : "Close"}
)}
>
);
}
export default function Example({ locale }: ExampleProps) {
return (
}
avoid={ }
/>
);
}
```
**Recommended**
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { useState } from "react";
export function RecommendedExample() {
const [open, setOpen] = useState(false);
return (
}>
Open dialog
Edit project
Update this project’s settings.
}>Close
);
}
```
**Avoid**
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { useState } from "react";
export function AvoidExample() {
const [open, setOpen] = useState(false);
return (
<>
setOpen(true)}>
Open dialog
{open && (
Edit project
Update this project’s settings.
}>
Close
)}
>
);
}
```
---
# Installation
Install editable SUI source with the shadcn CLI.
Page: https://sui.draco.dev/docs/installation
Install SUI source through its shadcn registry. Components, styles, and dependencies integrate with your application configuration.
## Install through the registry
### Prepare the consuming application
Use React 19, Tailwind CSS 4, and a shadcn project configured for Base UI. In an existing application that has not initialized shadcn, run:
```bash
bunx --bun shadcn@latest init --base base
```
Merge this entry into the application's existing `components.json`. Preserve its CSS path and aliases:
```json
{
"registries": {
"@sui": "https://raw.githubusercontent.com/draco-china/sui/main/registry/r/{name}.json"
}
}
```
The catalog URL is `https://raw.githubusercontent.com/draco-china/sui/main/registry/r/registry.json`. Individual manifests use the same directory.
### Search, inspect, and install
Run the following commands from the consuming application directory:
```bash
bunx --bun shadcn@latest search @sui -q button
bunx --bun shadcn@latest view @sui/button
bunx --bun shadcn@latest add @sui/button @sui/editor
```
Install the business blocks by their registry names:
```bash
bunx --bun shadcn@latest add @sui/data-table @sui/delete-resource @sui/tanstack-form
```
To install the complete collection, including all components and blocks:
```bash
bunx --bun shadcn@latest add @sui/sui
```
Each manifest includes the item's local component dependencies, hooks, helpers, types, package dependencies, and SUI CSS and theme tokens. The CLI installs source into the directories configured by your aliases and merges styles into the configured Tailwind CSS file. Standalone entries `@sui/sui-style` and `@sui/sui-theme` provide the shared styles and theme helpers.
Review the generated files and CSS when adding SUI to an application that already has components with the same names or custom theme values. Preview an update before applying it:
```bash
bunx --bun shadcn@latest add @sui/button --dry-run
bunx --bun shadcn@latest add @sui/button --diff
```
Editor uses locally bundled Monaco workers and requires a Vite-compatible `?worker` loader. Keep the installed `worker.d.ts` in your TypeScript include paths. This also applies when installing the complete collection.
Blocks default to English. Pass labels from your application's locale configuration to customize their visible text; see the [Data Table](/docs/blocks/data-table), [Delete Resource](/docs/blocks/delete-resource), and [TanStack Form](/docs/blocks/tanstack-form) references.
### Import installed source
With the default shadcn aliases, import from your own application:
```tsx
import { Button } from "@/components/ui/button";
export function SaveButton() {
return Save changes ;
}
```
Use the actual aliases in your `components.json` if they differ. Examples throughout this reference use workspace imports such as `@workspace/ui/components/button`. For a registry installation, replace `@workspace/ui/components/*` with your `ui` alias, `@workspace/ui/blocks/*` with your `components` alias followed by `/blocks/*`, and hook or helper imports with your `hooks` or `lib` alias. For example, the default aliases use `@/components/ui/button`, `@/components/blocks/data-table`, and `@/hooks/use-mobile`. The CLI adapts imports inside the installed files automatically.
## Connect the shadcn MCP server
The [MCP guide](/docs/mcp) covers client configuration, project working directories, example requests, and troubleshooting. MCP shares this application's `@sui` registry configuration. For API documentation and complete example text, use [LLMs](/docs/llms-txt).
See [Theming](/docs/theming) for CSS tokens and [Compatibility](/docs/compatibility) for runtime requirements.
---
# Introduction
Start with a component. Build an interface that feels like yours.
Page: https://sui.draco.dev/docs
SUI is a collection of composable React components built with Base UI and Tailwind CSS 4. It brings form controls, navigation, feedback, and content viewers into one visual language. The source lives in `packages/ui`, where you can read it, change it, and compose it for your application.
## Start here
1. **Connect your application.** Follow [Installation](/docs/installation) to add the workspace dependency, load the shared styles, and configure Tailwind to scan the component source.
2. **Try a component.** Browse the [component directory](/docs/components). Each page pairs an interactive preview with the complete example source.
3. **Make it your own.** Use [Theming](/docs/theming) for colors and CSS tokens, and [Design guidelines](/docs/design-guidelines) for typography, spacing, surfaces, and motion.
SUI currently uses the private `@workspace/ui` package inside this Bun workspace. There is no published SUI registry or standalone install command. Start with the installation guide before copying an example.
## Your first component
Once your application is connected, import components from their individual modules:
```tsx
import { Button } from "@workspace/ui/components/button";
export function SaveButton() {
return Save changes ;
}
```
The previews and your application use the same shared package. The code tab shows the full example file, including imports and composition; English and Chinese pages share the same examples.
## Find the right pieces
| What you are building | Start with |
| --- | --- |
| Forms and settings | [Field](/docs/components/field), [Input](/docs/components/input), [Select](/docs/components/select), [Switch](/docs/components/switch) |
| Navigation and feedback | [Tabs](/docs/components/tabs), [Dialog](/docs/components/dialog), [Toast](/docs/components/toast) |
| Content and conversations | [Message](/docs/components/message), [Markdown Viewer](/docs/components/markdown-viewer), [Code Viewer](/docs/components/code-viewer) |
| Optional glass surfaces | [Glass](/docs/components/glass) |
## Compose with intent
Components expose named parts. A dialog combines a trigger, content, title, description, and actions. A field combines a label, control, description, and error. Keep related parts together to preserve their behavior and give each interaction a clear purpose.
Base UI primitives provide keyboard interactions, focus management, and ARIA semantics. Your application still supplies meaningful labels, useful descriptions, and understandable feedback. Test custom compositions with a keyboard and assistive technology.
## Work with the source
- `packages/ui` contains components, shared hooks, CSS tokens, and accent themes.
- `apps/docs` contains the bilingual reference site and runnable examples.
- [CSS utilities](/docs/utils) cover shared effects such as Scroll Fade and Shimmer.
For coding assistants, [llms.txt](/llms.txt) provides the documentation index and [llms-full.txt](/llms-full.txt) includes the full reference and example source. Individual pages also offer Markdown through the page actions.
---
# LLMs
Read SUI documentation and complete examples as plain text.
Page: https://sui.draco.dev/docs/llms-txt
Use the LLM endpoints to give an assistant the current SUI APIs and examples. These endpoints read documentation. Use [MCP](/docs/mcp) or the [CLI](/docs/cli) when you want to install component source.
## Documentation endpoints
| Content | English | 简体中文 |
| --- | --- | --- |
| Index of pages | [`/llms.txt`](/llms.txt) | [`/zh-CN/llms.txt`](/zh-CN/llms.txt) |
| Complete reference | [`/llms-full.txt`](/llms-full.txt) | [`/zh-CN/llms-full.txt`](/zh-CN/llms-full.txt) |
| One component | [Button Markdown](/api/llms-markdown?locale=en-US&slug=components/button) | [Button Markdown](/api/llms-markdown?locale=zh-CN&slug=components/button) |
Use these paths on the current documentation origin. GitHub raw hosts the [registry](/docs/registry) JSON, while the documentation site supplies these text endpoints.
The index groups guides, components, Blocks, and utilities and links to their Markdown. Start with the index and read the relevant pages. The complete reference includes every page and can be much larger than one assistant request needs.
## Read one page
`/api/llms-markdown` accepts `locale` and `slug`:
| Parameter | Values | Default |
| --- | --- | --- |
| `locale` | `en-US`, `zh-CN` | `en-US` |
| `slug` | Document path without `/docs/`, such as `components/button`, `blocks/data-table`, or `theming` | Introduction when omitted |
Unknown locales and documents return `404`. An explicit `/en-US/llms.txt` or `/en-US/llms-full.txt` redirects to the corresponding English path without the prefix.
[Read Sensitive Input Markdown](/api/llms-markdown?locale=en-US&slug=components/sensitive-input)
The result includes the title, description, page URL, API text, and complete source for the page's shared examples. Preview and Code read the same example files, so the text reference stays aligned with the runnable documentation.
## Copy from a document
The page header provides **Copy Markdown** and **Open**. Copy Markdown returns the original MDX document; Open can supply the expanded LLM Markdown to an assistant. For programmatic use, `/api/markdown` returns the original document and `/api/llms-markdown` expands shared examples into code fences.
## Example request
```text
Read SUI's llms.txt, then the Field and Sensitive Input pages.
Compose a labeled secret input with an external validation message.
Keep validation and translated feedback in my application.
Use the import aliases from my components.json when installing source.
```
Examples use workspace imports such as `@workspace/ui/components/button`. After source installation, use your application's configured aliases; the [installation guide](/docs/installation#import-installed-source) explains the mapping. Reading documentation does not install files or configure your MCP client.
---
# MCP
Connect AI assistants to the SUI registry.
Page: https://sui.draco.dev/docs/mcp
Use the official [shadcn MCP server](https://ui.shadcn.com/docs/mcp) to browse the SUI registry from your AI assistant. The server runs locally over stdio and reads the consuming application's `components.json`. SUI supplies registry JSON files; the registry URL is an HTTP data source, not an MCP endpoint.
For API guidance and complete usage examples, provide the assistant with [LLMs documentation](/docs/llms-txt). MCP queries and inspects installable source. Its `get_add_command_for_items` tool returns a CLI command; the assistant then runs the command in your application to install the files.
## Configure the application
Prepare the consuming application using [Installation](/docs/installation). Merge this registry entry into its existing `components.json`, preserving the application's aliases and CSS configuration:
```json
{
"registries": {
"@sui": "https://raw.githubusercontent.com/draco-china/sui/main/registry/r/{name}.json"
}
}
```
The catalog is `https://raw.githubusercontent.com/draco-china/sui/main/registry/r/registry.json`. CLI and MCP share this configuration.
Open the consuming application in your MCP client and start the server from the directory containing its `components.json`. In a monorepo, use the application directory rather than a root directory without that file. The same configuration works with the [CLI](/docs/cli).
## Configure the client
These examples use Bun to launch the official server:
```bash
bunx --bun shadcn@latest mcp
```
The MCP client starts this process and communicates over stdin/stdout. Merge the relevant configuration into your project's existing client file, then restart or enable the server. Bun must be available in the client process's environment.
### Claude Code
Use the project's `.mcp.json`:
```json
{
"mcpServers": {
"shadcn": {
"command": "bunx",
"args": ["--bun", "shadcn@latest", "mcp"]
}
}
}
```
Restart Claude Code and use `/mcp` to check the connection.
### Cursor
Use the project's `.cursor/mcp.json`:
```json
{
"mcpServers": {
"shadcn": {
"command": "bunx",
"args": ["--bun", "shadcn@latest", "mcp"]
}
}
}
```
Enable the shadcn server in Cursor's MCP settings and check that its tools are listed.
### VS Code
For GitHub Copilot, use the project's `.vscode/mcp.json`. VS Code uses the `servers` key:
```json
{
"servers": {
"shadcn": {
"command": "bunx",
"args": ["--bun", "shadcn@latest", "mcp"]
}
}
}
```
Open the file and start the server through VS Code's MCP controls. The client file locations and keys follow the [official shadcn configuration guide](https://ui.shadcn.com/docs/mcp#configuration).
### Codex
Merge this into the consuming application's `.codex/config.toml`. Replace the placeholder with the application's absolute directory:
```toml
[mcp_servers.shadcn]
command = "bunx"
args = ["--bun", "shadcn@latest", "mcp"]
cwd = "/absolute/path/to/app"
```
Codex loads project configuration for trusted projects. Open or restart the project after editing and verify that the server's tools are available. See the official OpenAI documentation for [project configuration](https://learn.chatgpt.com/docs/config-file/config-basic) and [MCP settings](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).
## Browse, inspect, and install
Ask the assistant to use the `@sui` namespace explicitly:
> Search @sui for table components, inspect @sui/data-table and its dependencies, then install it in this application and adapt the documentation example to my aliases.
The server exposes these tools for the workflow:
| Tool | Purpose |
| --- | --- |
| `get_project_registries` | Confirm that the application has configured `@sui`. |
| `list_items_in_registries` | List entries in `@sui`. |
| `search_items_in_registries` | Search names and descriptions in `@sui`. |
| `view_items_in_registries` | Inspect an item's manifest and file contents. |
| `get_add_command_for_items` | Return the CLI command for the selected items. |
For example, search with `registries: ["@sui"]` and `query: "table"`, then inspect `items: ["@sui/data-table"]`. After obtaining the add command, the assistant runs it from the consuming application directory. With Bun, the equivalent command is:
```bash
bunx --bun shadcn@latest add @sui/data-table
```
Review the installed files, styles, and dependencies. See [CLI](/docs/cli) for previewing changes and [Compatibility](/docs/compatibility) for runtime requirements.
SUI's catalog currently contains installable components, blocks, styles, and helpers, without separate demo or example entries. Use [LLMs documentation](/docs/llms-txt) for complete examples and API guidance rather than expecting MCP's example search to return `@sui/*-demo` items.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| GitHub registry returns 404 | Check the configured URL, access to the repository, and whether the requested item exists. |
| Unknown registry or missing `@sui` | Check the namespace in the consuming application's `components.json`, then call `get_project_registries`. |
| Server reads a different project | Check its working directory. It must point to the consuming application containing `components.json`; restart after changing it. |
| `bunx` is not found | Ensure the client process can find Bun in `PATH`, or use the absolute path to the `bunx` executable in its server configuration. |
| Tools appear but installation does not happen | `get_add_command_for_items` returns a command. The assistant still needs to execute it in the application. |
To isolate registry problems, run these commands from the same application directory as MCP:
```bash
bunx --bun shadcn@latest search @sui -q button
bunx --bun shadcn@latest view @sui/button
```
See [Registry](/docs/registry) for configuration and item contents, and [CLI](/docs/cli) for installation and updates.
---
# Registry
Understand SUI manifests, shared styles, and GitHub distribution.
Page: https://sui.draco.dev/docs/registry
The SUI registry distributes editable component source through the [CLI](/docs/cli) and [MCP](/docs/mcp). Configure the `@sui` namespace as described in [installation](/docs/installation).
## GitHub files
```json
{
"registries": {
"@sui": "https://raw.githubusercontent.com/draco-china/sui/main/registry/r/{name}.json"
}
}
```
`registry/r/registry.json` is the searchable catalog. `registry/r/button.json`, for example, contains the installation payload for Button. Names do not have a locale prefix; installed components use English defaults and accept application-owned text.
| Item | Content |
| --- | --- |
| Component name, such as `button` | Component, transitive local files, dependencies, CSS, and theme tokens |
| `data-table`, `delete-resource`, `tanstack-form` | A Block and its required source and styles |
| `sui-style` | Shared theme and component CSS |
| `sui-theme` | Theme helpers and shared styles |
| `sui` | All components and Blocks |
## Files and dependencies
Manifests use shadcn's registry schema. `dependencies` and `devDependencies` declare package versions. `files` contains source and targets resolved against application aliases; `css` and `cssVars` merge into the configured stylesheet.
Each item is self-contained, including local hooks, helpers, declarations, and other components it imports. This avoids depending on a consumer's namespace or accidentally resolving a same-named component from another registry. The catalog describes items without duplicating their source; the individual payloads carry full content.
The CLI rewrites imports to your aliases. Workspace imports in documentation are explained in the [import mapping](/docs/installation#import-installed-source). Installed theme values and source are editable; review their differences when adding SUI to an existing design system.
## Update installed source
Preview changes with the [CLI](/docs/cli#review-an-update) before updating components and shared dependencies. Installation copies source; files do not automatically follow library changes. Keep your application's package-manager lockfile and review styles alongside local edits.
LLM documentation lives on the documentation site's [text endpoints](/docs/llms-txt). Registry JSON supplies installation data; an MCP client starts the shadcn stdio server separately.
---
# Theming
Semantic colors, light and dark surfaces, and reusable palettes.
Page: https://sui.draco.dev/docs/theming
SUI colors describe the role of an element. A card uses `card` and `card-foreground`; a primary action uses `primary` and `primary-foreground`. The same component adapts when its palette or appearance changes.
Shared CSS color variables use OKLCH, including the seven presets and custom palette output. These values preserve the existing colors. Custom input and preset seeds remain HEX, and contrast is measured using sRGB relative luminance. The shared package provides general semantic variables; this documentation app uses `accent-foreground` for links and `ring` for focus directly.
## Semantic usage
Choose a token by purpose, then keep the surface and its matching foreground together. Use the component's built-in variant when it already expresses that purpose.
**Recommended**
```tsx
import { Button } from "@workspace/ui/components/button";
Your changes are ready.
Save changes
;
```
**Avoid for product UI**
```tsx
Your changes are ready.
Save changes
;
```
Fixed colors can be appropriate for illustrations or external brand assets. Product surfaces, controls, and text should follow semantic tokens so they stay consistent across themes. SUI does not add a lint rule that forbids primitive colors.
## Surface hierarchy
Start with the page canvas and layer content using the surface that matches its role. The default theme uses an Apple-inspired gray canvas, white surfaces, and blue actions; dark mode uses deep gray surfaces and brighter blue accents. Inter remains the font.
| Role | Surface | Matching text | Typical use |
| --- | --- | --- | --- |
| Page | `bg-background` | `text-foreground` | Application canvas |
| Card | `bg-card` | `text-card-foreground` | Grouped content |
| Floating | `bg-popover` | `text-popover-foreground` | Menus and popovers |
| Secondary | `bg-secondary` | `text-secondary-foreground` | Secondary controls |
| Quiet | `bg-muted` | `text-muted-foreground` | Supporting content |
| Selected | `bg-accent` | `text-accent-foreground` | Selection and hover |
Surfaces express purpose rather than a fixed brightness ladder. `card` and `popover` may share a value while preserving different roles.
## Primary and status
Use `primary` for the main action and `primary-foreground` for its text. Use `accent` for selected or hovered items. Links use `accent-foreground`, which can differ from the primary seed to maintain text contrast on neutral surfaces.
Selection colors also apply to pressed Toggle and ToggleGroup items, Command highlights, current navigation links, selected options and menu items, selected table rows, choice cards, and calendar ranges. Filled selections, including Tabs, current pagination links, checkboxes, and radio buttons, use `primary` with `primary-foreground`. Focus indicators use `ring`.
Use `destructive` for destructive actions and invalid input. Its color remains independent of the selected accent. SUI does not define separate success, warning, or information color tokens; express those states with clear text and icons, or define application-specific semantic tokens.
```tsx
import { Button } from "@workspace/ui/components/button";
Delete workspace ;
```
## Text
Pair foreground tokens with their intended surfaces. `foreground` is the default body color; `muted-foreground` supports descriptions and secondary labels. A lighter shade alone should not communicate disabled, invalid, or selected state: retain accessible labels and the control's state attributes.
## Borders and focus
`border` separates surfaces. Existing Input uses `bg-input/50` for its fill. `ring` marks keyboard focus. Keep the outline visible when customizing interactive elements.
```tsx
Explore colors
;
```
## Charts
Use `chart-1` through `chart-5` for data series. Every preset supplies five coordinated colors. These colors distinguish series; add legends, labels, or patterns instead of relying only on hue. Chart colors are not substitutes for text or status tokens.
## Token reference
Browse the actual default tokens by role. Each row shows its utility, its light and dark value, and buttons to copy the variable, utility, or value. The catalog reads `globals.css`; it does not maintain a separate color table.
### Example: theming-semantic-colors
```tsx
import { Button } from "@workspace/ui/components/button";
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import { useState } from "react";
import { previewTokens, tokenValue } from "../support/theming-tokens";
import type { ExampleProps } from "../types";
const groups = [
{
en: "Surfaces",
zh: "表面",
tokens: [
["background", "bg-background", "Page canvas", "页面画布"],
["card", "bg-card", "Card surface", "卡片表面"],
["popover", "bg-popover", "Floating surface", "浮层表面"],
["secondary", "bg-secondary", "Secondary control", "次要控件"],
["muted", "bg-muted", "Quiet surface", "低强调表面"],
["accent", "bg-accent", "Selection and hover", "选中与悬停"],
],
},
{
en: "Text",
zh: "正文",
tokens: [
["foreground", "text-foreground", "Page text", "页面正文"],
["card-foreground", "text-card-foreground", "Text on card", "卡片正文"],
[
"popover-foreground",
"text-popover-foreground",
"Text on floating surface",
"浮层正文",
],
[
"secondary-foreground",
"text-secondary-foreground",
"Text on secondary control",
"次要控件正文",
],
[
"muted-foreground",
"text-muted-foreground",
"Supporting text",
"辅助文字",
],
[
"accent-foreground",
"text-accent-foreground",
"Text on selection",
"选中态正文",
],
],
},
{
en: "Primary",
zh: "主色",
tokens: [
["primary", "bg-primary", "Primary action", "主要操作"],
[
"primary-foreground",
"text-primary-foreground",
"Text on primary action",
"主要操作正文",
],
],
},
{
en: "Borders and focus",
zh: "边框与焦点",
tokens: [
["border", "border-border", "Surface boundary", "表面边界"],
["input", "bg-input/50", "Input fill", "输入控件填充"],
["ring", "ring-ring", "Keyboard focus", "键盘焦点"],
],
},
{
en: "Status",
zh: "状态",
tokens: [
[
"destructive",
"text-destructive",
"Destructive action or invalid input",
"危险操作或无效输入",
],
],
},
{
en: "Charts",
zh: "图表",
tokens: [1, 2, 3, 4, 5].map((index) => [
`chart-${index}`,
`fill-chart-${index}`,
`Data series ${index}`,
`数据系列 ${index}`,
]),
},
{
en: "Sidebar",
zh: "侧栏",
tokens: [
["sidebar", "bg-sidebar", "Sidebar surface", "侧栏表面"],
[
"sidebar-foreground",
"text-sidebar-foreground",
"Sidebar text",
"侧栏正文",
],
[
"sidebar-primary",
"bg-sidebar-primary",
"Sidebar primary action",
"侧栏主要操作",
],
[
"sidebar-primary-foreground",
"text-sidebar-primary-foreground",
"Text on sidebar action",
"侧栏主要操作正文",
],
[
"sidebar-accent",
"bg-sidebar-accent",
"Sidebar selected item",
"侧栏选中项",
],
[
"sidebar-accent-foreground",
"text-sidebar-accent-foreground",
"Selected sidebar text",
"侧栏选中项正文",
],
[
"sidebar-border",
"border-sidebar-border",
"Sidebar boundary",
"侧栏边界",
],
[
"sidebar-ring",
"ring-sidebar-ring",
"Sidebar keyboard focus",
"侧栏键盘焦点",
],
],
},
];
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
dark: "深色",
light: "浅色",
}
: {
dark: "Dark",
light: "Light",
};
const [selected, setSelected] = useState(0);
const group = groups[selected] ?? groups[0];
const labels = {
copy: zh ? "复制" : "Copy",
copied: zh ? "已复制" : "Copied",
failed: zh
? "复制失败,请手动复制"
: "Copy failed. Copy the text manually.",
};
return (
{zh ? "颜色用途" : "Color roles"}
{groups.map((item, index) => (
setSelected(index)}
>
{zh ? item.zh : item.en}
))}
{group?.tokens.map(([token, utility, en, cn]) => (
{zh ? cn : en}
{`--${token}`}
{utility ?? ""}
{[false, true].map((dark) => {
const value = tokenValue(
previewTokens("default", dark),
`--${token}`,
);
return (
{dark ? stateLabels.dark : stateLabels.light}
{value}
);
})}
))}
);
}
```
## Appearance
Set `dark` on a parent to use dark tokens. Without that class, the application uses light tokens. Mode and palette are separate choices: `dark` controls appearance, while `data-color` selects a palette.
```html
```
The following two panels use the same SUI components with isolated default light and dark variables. Edit the field, toggle the switch, or save changes to try the controls.
### Example: theming-mode-comparison
```tsx
import { cn } from "cn";
import { ThemePreview } from "../support/theming-preview";
import { previewStyle } from "../support/theming-tokens";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
dark: "深色",
light: "浅色",
}
: {
dark: "Dark",
light: "Light",
};
return (
{[false, true].map((dark) => (
{dark ? stateLabels.dark : stateLabels.light}
))}
);
}
```
For the whole documentation site, open the appearance panel and choose **Light**, **Dark**, or **System**. System is the default and follows the device preference.
## Preset palettes
Choose **Default** or one of seven presets: Bamboo, Mauve, Mist, Sand, Pine, Rose, and Lime. Default uses the base tokens in `globals.css`. Each preset has a complete five-color palette, with the colors mapped in order to `chart-1` through `chart-5`. Its primary seed controls `primary` independently; it does not have to be the first chart color.
The cards below show the full five-color palette, copyable color values, and the same interactive component preview for every theme. Use the shared light/dark control to compare them under the same appearance. The default theme has its own complete card.
Surfaces, body text, and destructive colors keep their roles when the accent changes. Primary actions, selected states, focus rings, sidebar accents, and charts follow the palette. Accessible foreground and focus colors can differ from the seed to maintain contrast. These previews stay local and do not change the site theme or browser storage.
### Example: theming-palette-playground
```tsx
import { Button } from "@workspace/ui/components/button";
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import { themePresets } from "@workspace/ui/lib/theme/theme";
import { cn } from "cn";
import { useState } from "react";
import { ThemePreview } from "../support/theming-preview";
import {
previewStyle,
previewTokens,
tokenValue,
} from "../support/theming-tokens";
import type { ExampleProps } from "../types";
const palettes = [
{ id: "default" as const, en: "Default", zh: "默认" },
...themePresets,
];
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [dark, setDark] = useState(false);
const mode = zh
? { dark: "深色预览", light: "浅色预览" }
: { dark: "Dark preview", light: "Light preview" };
return (
{zh
? "每套配色包含五个协调色"
: "Each palette contains five coordinated colors"}
setDark(!dark)}
>
{dark ? mode.dark : mode.light}
{palettes.map((preset) => {
const tokens = previewTokens(preset.id, dark);
const colors =
"palette" in preset
? preset.palette
: [1, 2, 3, 4, 5].map((index) =>
tokenValue(tokens, `--chart-${index}`),
);
return (
{zh ? preset.zh : preset.en}
{"color" in preset && (
{preset.color}
)}
{colors.map((color, index) => (
{"palette" in preset ? color : `chart-${index + 1}`}
))}
);
})}
{zh
? "明暗切换仅作用于这些预览,不改变整站主题或浏览器存储"
: "Appearance switches affect these previews without changing the site theme or browser storage."}
);
}
```
Lime uses a bright `#D5F267` primary with black text. Accessible links and focus rings use deeper olive shades in light mode; dark mode keeps the lime accents bright against neutral dark surfaces. Choose **Lime** in the theme panel or set `data-color="lime"`.
## Custom HEX
The preview and the site theme panel accept three- or six-digit HEX values, with or without `#`. Shorthand expands to six digits. Invalid input displays an error and preserves the current valid theme.
The primary seed stays unchanged. The shared calculation chooses black or white primary text and adjusts links, selected text, and focus colors against the light and dark surfaces. It checks ordinary text at 4.5:1 and focus colors at 3:1. Use the matching foreground token rather than assuming white text works on every primary color.
### Example: theming-custom-color
```tsx
import { Button } from "@workspace/ui/components/button";
import { ColorPicker } from "@workspace/ui/components/color-picker";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { normalizeHex } from "@workspace/ui/lib/theme/theme";
import { cn } from "cn";
import { useId, useState } from "react";
import { colorPickerLabels } from "../../lib/color-picker-labels";
import { ThemePreview } from "../support/theming-preview";
import { previewStyle } from "../support/theming-tokens";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const id = useId();
const [dark, setDark] = useState(false);
const [input, setInput] = useState("#0066CC");
const [custom, setCustom] = useState("#0066CC");
const [invalid, setInvalid] = useState(false);
const mode = zh
? { dark: "深色预览", light: "浅色预览" }
: { dark: "Dark preview", light: "Light preview" };
return (
{invalid && (
{zh
? "输入三位或六位 HEX;当前有效配色保持不变"
: "Enter a three- or six-digit HEX. The current valid palette is unchanged."}
)}
);
}
```
## Shared theme files
Import the UI stylesheet to include the default tokens, component styles, and all seven presets:
```css
@import "@workspace/ui/globals.css";
```
The seven files live in `packages/ui/src/styles/themes`: `bamboo.css`, `mauve.css`, `mist.css`, `sand.css`, `pine.css`, `rose.css`, and `lime.css`. A setup that already loads SUI base tokens can import an individual preset:
```css
@import "@workspace/ui/themes/pine.css";
```
Select it on the root or a container with `data-color="pine"`. A dark ancestor enables its dark palette. To reset the root, use `data-color="default"` or remove the attribute and clear custom inline tokens. The default comes directly from `globals.css`; it has no separate theme file. A nested container without its own overrides inherits its parent's colors.
## Custom application themes
Use the shared helpers for custom input. Validate first, clear previous inline accent tokens, and calculate overrides only for a custom seed:
```tsx
import {
createThemeTokens,
getThemeId,
normalizeHex,
themeTokenNames,
} from "@workspace/ui/lib/theme/theme";
function applyTheme(input: string | null, dark: boolean) {
const seed = input === null ? null : normalizeHex(input);
if (input !== null && seed === null) return;
const root = document.documentElement;
root.classList.toggle("dark", dark);
root.dataset.color = getThemeId(seed);
for (const name of themeTokenNames) root.style.removeProperty(name);
if (root.dataset.color === "custom") {
for (const [name, value] of Object.entries(createThemeTokens(seed, dark))) {
root.style.setProperty(name, value);
}
}
}
```
Pass `null` to restore Default. Recalculate custom overrides when appearance changes. The shared package supplies colors and calculation; your application owns mode selection, persistence, and initial restoration. Use `text-accent-foreground` for accessible links and `outline-ring` for focus. The documentation stylesheet maps Fumadocs's `--color-fd-*` variables to the shared semantic tokens; these framework aliases are not part of the shared theme files or `createThemeTokens` output.
## Creating a theme
For a reusable preset, add its stable ID, bilingual name, seed, and five chart colors to `themePresets` in `packages/ui/src/lib/theme/theme.ts`. The generator combines that definition with the current base semantic tokens in `globals.css`.
```sh
bun run --cwd packages/ui themes:generate
```
Import the resulting individual file in `globals.css`, then use its ID with `data-color`. Regenerate after changing a preset or the base tokens. Review both modes, matching foregrounds, keyboard focus, and chart legends before adopting a palette.
## Persistence
The header provides separate Appearance and Accent color controls. Appearance selects Light, Dark, or System; Accent color selects a preset palette or custom color. Changing the mode preserves the accent.
The documentation site saves its selected mode and accent in browser storage, restores them before the first paint, and responds to device changes while System is active. When storage is unavailable, it uses System and Default. The interactive examples on this page do not write those preferences.
## Right-to-left interfaces
Wrap the relevant subtree in `DirectionProvider` and prefer logical spacing utilities:
```tsx
import { DirectionProvider } from "@workspace/ui/components/direction";
;
```
---
# Components
Browse all 67 SUI components, listed alphabetically.
Page: https://sui.draco.dev/docs/components
Every preview runs the components in this workspace. Choose a component to find its import, composition, examples, and API.
- [Accordion](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Faccordion): A vertically stacked set of interactive headings that each reveal a section of content.
- [Alert](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Falert): Displays a callout for user attention.
- [Alert Dialog](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Falert-dialog): A modal dialog that interrupts the user with important content and expects a response.
- [Aspect Ratio](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Faspect-ratio): Displays content within a desired ratio.
- [Attachment](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fattachment): Displays a file or image attachment with media, metadata, upload state, and actions.
- [Autocomplete](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fautocomplete): Suggests matching options while keeping the input free to accept custom text.
- [Avatar](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Favatar): An image element with a fallback for representing the user.
- [Badge](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fbadge): Displays a badge or a component that looks like a badge.
- [Breadcrumb](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fbreadcrumb): Displays the path to the current resource using a hierarchy of links.
- [Bubble](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fbubble): Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
- [Button](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fbutton): Displays a button or a component that looks like a button.
- [Button Group](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fbutton-group): A container that groups related buttons together with consistent styling.
- [Calendar](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcalendar): A calendar component that allows users to select a date or a range of dates.
- [Card](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcard): Displays a card with header, content, and footer.
- [Carousel](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcarousel): A carousel with motion and swipe built using Embla.
- [Chart](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fchart): Beautiful charts. Built using Recharts. Copy and paste into your apps.
- [Checkbox](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcheckbox): A control that allows the user to toggle between checked and not checked.
- [Clipboard Text](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fclipboard-text): A selectable text field with a separate copy action and async feedback.
- [Code Viewer](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcode-viewer): Syntax-highlighted code with folding, line highlights, copying, and streaming feedback.
- [Collapsible](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcollapsible): An interactive component which expands/collapses a panel.
- [ColorPicker](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcolor-picker): Choose colors using a saturation area, hue and opacity sliders, editable values and preset swatches.
- [Combobox](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcombobox): Autocomplete input with a list of suggestions.
- [Command](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcommand): Command menu for search and quick actions.
- [Context Menu](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fcontext-menu): Displays a menu of actions triggered by a right click.
- [Dialog](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fdialog): A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
- [Diff Viewer](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fdiff-viewer): A line-based code comparison with split and unified views.
- [Direction](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fdirection): A provider component that sets the text direction for your application.
- [Drawer](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fdrawer): A drawer component for React.
- [Dropdown Menu](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fdropdown-menu): Displays a menu to the user — such as a set of actions or functions — triggered by a button.
- [Editor](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Feditor): A client-loaded code editor with preview, toolbar actions, and fullscreen modes.
- [Empty](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fempty): Use the Empty component to display an empty state.
- [Field](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ffield): Combine labels, controls, and help text to compose accessible form fields and grouped inputs.
- [Glass](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fglass): SVG highlights and CSS frosted surfaces, progressively enhanced with WebGPU refraction.
- [Hover Card](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fhover-card): For sighted users to preview content available behind a link.
- [HTML Viewer](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fhtml-viewer): An isolated iframe preview for HTML content.
- [Image Viewer](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fimage-viewer): A dialog image viewer with navigation, zoom, rotation, and panning.
- [Inline Copy Text](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Finline-copy-text): Inline code that stays readable and copies its value when activated.
- [Input](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Finput): A text input component for forms and user data entry with built-in styling and accessibility features.
- [Input Group](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Finput-group): Add addons, buttons, and helper content to inputs.
- [Input OTP](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Finput-otp): Individual code inputs with character filtering and asynchronous verification feedback
- [Item](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fitem): A versatile component for displaying content with media, title, description, and actions.
- [Kbd](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fkbd): Used to display textual user input from keyboard.
- [Label](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Flabel): Renders an accessible label associated with controls.
- [Loader](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Floader): Eighteen loading animations with accessible labels, adjustable speed, and reduced motion support.
- [Locale Toggle](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Flocale-toggle): A controlled language button or selector without routing or persistence assumptions.
- [LongText](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Flong-text): Truncated text with a full-text disclosure only when the content overflows.
- [Markdown Viewer](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fmarkdown-viewer): Markdown rendering with tables, task lists, alerts, highlighted code, and sanitized HTML.
- [Marker](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fmarker): Displays an inline status, system note, bordered row, or labeled separator in a conversation.
- [Menubar](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fmenubar): A visually persistent menu common in desktop applications that provides quick access to a consistent set of commands.
- [Message](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fmessage): Displays a message in a conversation, with optional avatar, header, footer, and alignment.
- [Message Scroller](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fmessage-scroller): A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
- [Native Select](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fnative-select): A styled native HTML select element with consistent design system integration.
- [Navigation Menu](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fnavigation-menu): A collection of links for navigating websites.
- [NavigationProgress](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fnavigation-progress): A controlled navigation loading bar with delayed start and completion feedback.
- [Pagination](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fpagination): Compact data pagination with range information, page navigation, and page-size selection.
- [Popover](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fpopover): Displays rich content in a portal, triggered by a button.
- [Progress](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fprogress): Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.
- [QRCode](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fqr-code): A theme-aware QR code with loading feedback and optional reveal animation.
- [Questionnaire](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fquestionnaire): A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions.
- [Radio Group](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fradio-group): A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
- [Resizable](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fresizable): Accessible resizable panel groups and layouts with keyboard support.
- [Scroll Area](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fscroll-area): Augments native scroll functionality for custom, cross-browser styling.
- [Select](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fselect): Displays a list of options for the user to pick from—triggered by a button.
- [Sensitive Input](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fsensitive-input): A sensitive-value input with a fixed mask, click-to-reveal interaction, and copy feedback.
- [Separator](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fseparator): Visually or semantically separates content.
- [Sheet](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fsheet): Extends the Dialog component to display content that complements the main content of the screen.
- [Sidebar](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fsidebar): A composable, themeable and customizable sidebar component.
- [Skeleton](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fskeleton): Use to show a placeholder while content is loading.
- [Slider](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fslider): An input where the user selects a value from within a given range.
- [Switch](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Fswitch): A control that allows the user to toggle between checked and not checked.
- [TabBar](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftab-bar): Controlled application navigation with press-and-slide selection and optional glass material.
- [Table](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftable): A responsive table component.
- [Tabs](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftabs): A set of layered sections of content—known as tab panels—that are displayed one at a time.
- [Tag Input](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftag-input): A multi-value input with custom tags, suggestions, and keyboard-accessible chips.
- [Textarea](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftextarea): Displays a form textarea or a component that looks like a textarea.
- [Theme Toggle](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftheme-toggle): A controlled light/dark switch with optional view-transition effects.
- [Toast](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftoast): A succinct message that is displayed temporarily.
- [Toggle](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftoggle): A two-state button that can be either on or off.
- [Toggle Group](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftoggle-group): A set of two-state buttons that can be toggled on or off.
- [Toolbar](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftoolbar): Groups related commands and controls with arrow-key focus navigation.
- [Tooltip](https://sui.draco.dev/api/llms-markdown?locale=en-US&slug=components%2Ftooltip): A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
---
# Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Page: https://sui.draco.dev/docs/components/accordion
### Example: accordion-demo
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
export default function AccordionDemo() {
return (
What are your shipping options?
We offer standard (5-7 days), express (2-3 days), and overnight
shipping. Free shipping on international orders.
What is your return policy?
Returns accepted within 30 days. Items must be unused and in original
packaging. Refunds processed within 5-7 business days.
How can I contact customer support?
Reach us via email, live chat, or phone. We respond within 24 hours
during business days.
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/accordion
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion"
```
```tsx showLineNumbers
Is it accessible?
Yes. It adheres to the WAI-ARIA design pattern.
```
## Composition
Use the following composition to build an `Accordion`:
```text
Accordion
├── AccordionItem
│ ├── AccordionTrigger
│ └── AccordionContent
└── AccordionItem
├── AccordionTrigger
└── AccordionContent
```
## Basic
A basic accordion that shows one item at a time. The first item is open by default.
### Example: accordion-basic
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
const items = [
{
value: "item-1",
trigger: "How do I reset my password?",
content:
"Click on 'Forgot Password' on the login page, enter your email address, and we'll send you a link to reset your password. The link will expire in 24 hours.",
},
{
value: "item-2",
trigger: "Can I change my subscription plan?",
content:
"Yes, you can upgrade or downgrade your plan at any time from your account settings. Changes will be reflected in your next billing cycle.",
},
{
value: "item-3",
trigger: "What payment methods do you accept?",
content:
"We accept all major credit cards, PayPal, and bank transfers. All payments are processed securely through our payment partners.",
},
];
export function AccordionBasic() {
return (
{items.map((item) => (
{item.trigger}
{item.content}
))}
);
}
export default AccordionBasic;
```
## Multiple
Use the `multiple` prop to allow multiple items to be open at the same time.
### Example: accordion-multiple
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
const items = [
{
value: "notifications",
trigger: "Notification Settings",
content:
"Manage how you receive notifications. You can enable email alerts for updates or push notifications for mobile devices.",
},
{
value: "privacy",
trigger: "Privacy & Security",
content:
"Control your privacy settings and security preferences. Enable two-factor authentication, manage connected devices, review active sessions, and configure data sharing preferences. You can also download your data or delete your account.",
},
{
value: "billing",
trigger: "Billing & Subscription",
content:
"View your current plan, payment history, and upcoming invoices. Update your payment method, change your subscription tier, or cancel your subscription.",
},
];
export function AccordionMultiple() {
return (
{items.map((item) => (
{item.trigger}
{item.content}
))}
);
}
export default AccordionMultiple;
```
## Disabled
Use the `disabled` prop on `AccordionItem` to disable individual items.
### Example: accordion-disabled
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
export default function AccordionDisabled() {
return (
Can I access my account history?
Yes, you can view your complete account history including all
transactions, plan changes, and support tickets in the Account History
section of your dashboard.
Premium feature information
This section contains information about premium features. Upgrade your
plan to access this content.
How do I update my email address?
You can update your email address in your account settings.
You'll receive a verification email at your new address to
confirm the change.
);
}
```
## Borders
Add `border` to the `Accordion` and `border-b last:border-b-0` to the `AccordionItem` to add borders to the items.
### Example: accordion-borders
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
const items = [
{
value: "billing",
trigger: "How does billing work?",
content:
"We offer monthly and annual subscription plans. Billing is charged at the beginning of each cycle, and you can cancel anytime. All plans include automatic backups, 24/7 support, and unlimited team members.",
},
{
value: "security",
trigger: "Is my data secure?",
content:
"Yes. We use end-to-end encryption, SOC 2 Type II compliance, and regular third-party security audits. All data is encrypted at rest and in transit using industry-standard protocols.",
},
{
value: "integration",
trigger: "What integrations do you support?",
content:
"We integrate with 500+ popular tools including Slack, Zapier, Salesforce, HubSpot, and more. You can also build custom integrations using our REST API and webhooks.",
},
];
export default function AccordionBorders() {
return (
{items.map((item) => (
{item.trigger}
{item.content}
))}
);
}
```
## Card
Wrap the `Accordion` in a `Card` component.
### Example: accordion-card
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
const items = [
{
value: "plans",
trigger: "What subscription plans do you offer?",
content:
"We offer three subscription tiers: Starter ($9/month), Professional ($29/month), and Enterprise ($99/month). Each plan includes increasing storage limits, API access, priority support, and team collaboration features.",
},
{
value: "billing",
trigger: "How does billing work?",
content:
"Billing occurs automatically at the start of each billing cycle. We accept all major credit cards, PayPal, and ACH transfers for enterprise customers. You'll receive an invoice via email after each payment.",
},
{
value: "cancel",
trigger: "How do I cancel my subscription?",
content:
"You can cancel your subscription anytime from your account settings. There are no cancellation fees or penalties. Your access will continue until the end of your current billing period.",
},
];
export default function AccordionCard() {
return (
Subscription & Billing
Common questions about your account, plans, payments and
cancellations.
{items.map((item) => (
{item.trigger}
{item.content}
))}
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: accordion-rtl
```tsx
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
question1: "How do I reset my password?",
answer1:
"Click on 'Forgot Password' on the login page, enter your email address, and we'll send you a link to reset your password.",
question2: "Can I change my subscription plan?",
answer2:
"Yes, you can upgrade or downgrade your plan at any time from your account settings. Changes will be reflected in your next billing cycle.",
question3: "What payment methods do you accept?",
answer3:
"We accept all major credit cards, PayPal, and bank transfers. All payments are processed securely through our payment partners.",
},
},
ar: {
dir: "rtl",
values: {
question1: "كيف يمكنني إعادة تعيين كلمة المرور؟",
answer1:
"انقر على 'نسيت كلمة المرور' في صفحة تسجيل الدخول، أدخل عنوان بريدك الإلكتروني، وسنرسل لك رابطًا لإعادة تعيين كلمة المرور. سينتهي صلاحية الرابط خلال 24 ساعة.",
question2: "هل يمكنني تغيير خطة الاشتراك الخاصة بي؟",
answer2:
"نعم، يمكنك ترقية أو تخفيض خطتك في أي وقت من إعدادات حسابك. ستظهر التغييرات في دورة الفوترة التالية.",
question3: "ما هي طرق الدفع التي تقبلونها؟",
answer3:
"نقبل جميع بطاقات الائتمان الرئيسية و PayPal والتحويلات المصرفية. تتم معالجة جميع المدفوعات بأمان من خلال شركاء الدفع لدينا.",
},
},
he: {
dir: "rtl",
values: {
question1: "איך אני מאפס את הסיסמה שלי?",
answer1:
"לחץ על 'שכחתי סיסמה' בעמוד ההתחברות, הזן את כתובת האימייל שלך, ונשלח לך קישור לאיפוס הסיסמה. הקישור יפוג תוך 24 שעות.",
question2: "האם אני יכול לשנות את תוכנית המנוי שלי?",
answer2:
"כן, אתה יכול לשדרג או להוריד את התוכנית שלך בכל עת מההגדרות של החשבון שלך. השינויים יבואו לידי ביטוי במחזור החיוב הבא.",
question3: "אילו אמצעי תשלום אתם מקבלים?",
answer3: "אנו מקבלים כרטיסי אשראי, PayPal והעברות בנקאיות.",
},
},
};
const items = [
{
value: "item-1",
questionKey: "question1" as const,
answerKey: "answer1" as const,
},
{
value: "item-2",
questionKey: "question2" as const,
answerKey: "answer2" as const,
},
{
value: "item-3",
questionKey: "question3" as const,
answerKey: "answer3" as const,
},
] as const;
export function AccordionRtl() {
const { t } = useTranslation(translations, "ar");
return (
{items.map((item) => (
{t[item.questionKey]}
{t[item.answerKey]}
))}
);
}
export default AccordionRtl;
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/accordion#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/accordion)
- [API reference](https://base-ui.com/react/components/accordion#api-reference)
---
# Alert
Displays a callout for user attention.
Page: https://sui.draco.dev/docs/components/alert
### Example: alert-demo
```tsx
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { CheckCircle2Icon, InfoIcon } from "lucide-react";
export default function AlertDemo() {
return (
Payment successful
Your payment of $29.99 has been processed. A receipt has been sent to
your email address.
New feature available
We've added dark mode support. You can enable it in your account
settings.
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/alert
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert"
```
```tsx showLineNumbers
Heads up!
You can add components and dependencies to your app using the cli.
Enable
```
## Composition
Use the following composition to build an `Alert`:
```text
Alert
├── Icon
├── AlertTitle
├── AlertDescription
└── AlertAction
```
## Basic
A basic alert with an icon, title and description.
### Example: alert-basic
```tsx
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { CheckCircle2Icon } from "lucide-react";
export default function AlertBasic() {
return (
Account updated successfully
Your profile information has been saved. Changes will be reflected
immediately.
);
}
```
## Destructive
Use `variant="destructive"` to create a destructive alert.
### Example: alert-destructive
```tsx
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { AlertCircleIcon } from "lucide-react";
export default function AlertDestructive() {
return (
Payment failed
Your payment could not be processed. Please check your payment method
and try again.
);
}
```
## Action
Use `AlertAction` to add a button or other action element to the alert.
### Example: alert-action
```tsx
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { Button } from "@workspace/ui/components/button";
export default function AlertActionExample() {
return (
Dark mode is now available
Enable it under your profile settings to get started.
Enable
);
}
```
## Custom Colors
You can customize the alert colors by adding custom classes such as `bg-amber-50 dark:bg-amber-950` to the `Alert` component.
### Example: alert-colors
```tsx
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { AlertTriangleIcon } from "lucide-react";
export default function AlertColors() {
return (
Your subscription will expire in 3 days.
Renew now to avoid service interruption or upgrade to a paid plan to
continue using the service.
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: alert-rtl
```tsx
"use client";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { CheckCircle2Icon, InfoIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
paymentTitle: "Payment successful",
paymentDescription:
"Your payment of $29.99 has been processed. A receipt has been sent to your email address.",
featureTitle: "New feature available",
featureDescription:
"We've added dark mode support. You can enable it in your account settings.",
},
},
ar: {
dir: "rtl",
values: {
paymentTitle: "تم الدفع بنجاح",
paymentDescription:
"تمت معالجة دفعتك البالغة 29.99 دولارًا. تم إرسال إيصال إلى عنوان بريدك الإلكتروني.",
featureTitle: "ميزة جديدة متاحة",
featureDescription:
"لقد أضفنا دعم الوضع الداكن. يمكنك تفعيله في إعدادات حسابك.",
},
},
he: {
dir: "rtl",
values: {
paymentTitle: "התשלום בוצע בהצלחה",
paymentDescription:
"התשלום שלך בסך 29.99 דולר עובד. קבלה נשלחה לכתובת האימייל שלך.",
featureTitle: "תכונה חדשה זמינה",
featureDescription:
"הוספנו תמיכה במצב כהה. אתה יכול להפעיל אותו בהגדרות החשבון שלך.",
},
},
};
const alerts = [
{
icon: CheckCircle2Icon,
titleKey: "paymentTitle" as const,
descriptionKey: "paymentDescription" as const,
},
{
icon: InfoIcon,
titleKey: "featureTitle" as const,
descriptionKey: "featureDescription" as const,
},
] as const;
export function AlertRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{alerts.map((alert) => {
const Icon = alert.icon;
return (
{t[alert.titleKey]}
{t[alert.descriptionKey]}
);
})}
);
}
export default AlertRtl;
```
## API Reference
### Alert
The `Alert` component displays a callout for user attention.
| Prop | Type | Default |
| --------- | ---------------------------- | ----------- |
| `variant` | `"default" \| "destructive"` | `"default"` |
### AlertTitle
The `AlertTitle` component displays the title of the alert.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### AlertDescription
The `AlertDescription` component displays the description or content of the alert.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### AlertAction
The `AlertAction` component displays an action element (like a button) positioned absolutely in the top-right corner of the alert.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
---
# Alert Dialog
A modal dialog that interrupts the user with important content and expects a response.
Page: https://sui.draco.dev/docs/components/alert-dialog
### Example: alert-dialog-demo
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
export default function AlertDialogDemo() {
return (
}>
Show Dialog
Are you absolutely sure?
This action cannot be undone. This will permanently delete your
account from our servers.
Cancel
Continue
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/alert-dialog
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog"
```
```tsx showLineNumbers
}>
Show Dialog
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account
from our servers.
Cancel
Continue
```
## Composition
Use the following composition to build an `AlertDialog`:
```text
AlertDialog
├── AlertDialogTrigger
└── AlertDialogContent
├── AlertDialogHeader
│ ├── AlertDialogMedia
│ ├── AlertDialogTitle
│ └── AlertDialogDescription
└── AlertDialogFooter
├── AlertDialogCancel
└── AlertDialogAction
```
## Basic
A basic alert dialog with a title, description, and cancel and continue buttons.
### Example: alert-dialog-basic
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
export function AlertDialogBasic() {
return (
Show Dialog}
/>
Are you absolutely sure?
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
Cancel
Continue
);
}
export default AlertDialogBasic;
```
## Small
Use the `size="sm"` prop to make the alert dialog smaller.
### Example: alert-dialog-small
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
export function AlertDialogSmall() {
return (
Show Dialog}
/>
Allow accessory to connect?
Do you want to allow the USB accessory to connect to this device?
Don't allow
Allow
);
}
export default AlertDialogSmall;
```
## Media
Use the `AlertDialogMedia` component to add a media element such as an icon or image to the alert dialog.
### Example: alert-dialog-media
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import { CircleFadingPlusIcon } from "lucide-react";
export function AlertDialogWithMedia() {
return (
Share Project}
/>
Share this project?
Anyone with the link will be able to view and edit this project.
Cancel
Share
);
}
export default AlertDialogWithMedia;
```
## Small with Media
Use the `size="sm"` prop to make the alert dialog smaller and the `AlertDialogMedia` component to add a media element such as an icon or image to the alert dialog.
### Example: alert-dialog-small-media
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import { BluetoothIcon } from "lucide-react";
export function AlertDialogSmallWithMedia() {
return (
Show Dialog}
/>
Allow accessory to connect?
Do you want to allow the USB accessory to connect to this device?
Don't allow
Allow
);
}
export default AlertDialogSmallWithMedia;
```
## Destructive
Use the `AlertDialogAction` component to add a destructive action button to the alert dialog.
### Example: alert-dialog-destructive
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import { Trash2Icon } from "lucide-react";
export function AlertDialogDestructive() {
return (
Delete Chat}
/>
Delete chat?
This will permanently delete this chat conversation. View{" "}
Settings delete any memories saved during this
chat.
Cancel
Delete
);
}
export default AlertDialogDestructive;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: alert-dialog-rtl
```tsx
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import { BluetoothIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
showDialog: "Show Dialog",
showDialogSm: "Show Dialog (sm)",
title: "Are you absolutely sure?",
description:
"This action cannot be undone. This will permanently delete your account from our servers.",
cancel: "Cancel",
continue: "Continue",
smallTitle: "Allow accessory to connect?",
smallDescription:
"Do you want to allow the USB accessory to connect to this device?",
dontAllow: "Don't allow",
allow: "Allow",
},
},
ar: {
dir: "rtl",
values: {
showDialog: "إظهار الحوار",
showDialogSm: "إظهار الحوار (صغير)",
title: "هل أنت متأكد تمامًا؟",
description:
"لا يمكن التراجع عن هذا الإجراء. سيؤدي هذا إلى حذف حسابك نهائيًا من خوادمنا.",
cancel: "إلغاء",
continue: "متابعة",
smallTitle: "السماح للملحق بالاتصال؟",
smallDescription: "هل تريد السماح لملحق USB بالاتصال بهذا الجهاز؟",
dontAllow: "عدم السماح",
allow: "السماح",
},
},
he: {
dir: "rtl",
values: {
showDialog: "הצג דיאלוג",
showDialogSm: "הצג דיאלוג (קטן)",
title: "האם אתה בטוח לחלוטין?",
description:
"פעולה זו לא ניתנת לביטול. זה ימחק לצמיתות את החשבון שלך מהשרתים שלנו.",
cancel: "ביטול",
continue: "המשך",
smallTitle: "לאפשר להתקן להתחבר?",
smallDescription: "האם אתה רוצה לאפשר להתקן USB להתחבר למכשיר זה?",
dontAllow: "אל תאפשר",
allow: "אפשר",
},
},
};
export function AlertDialogRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
return (
}>
{t.showDialog}
{t.title}
{t.description}
{t.cancel}
{t.continue}
}>
{t.showDialogSm}
{t.smallTitle}
{t.smallDescription}
{t.dontAllow}
{t.allow}
);
}
export default AlertDialogRtl;
```
## API Reference
### size
Use the `size` prop on the `AlertDialogContent` component to control the size of the alert dialog. It accepts the following values:
| Prop | Type | Default |
| ------ | ------------------- | ----------- |
| `size` | `"default" \| "sm"` | `"default"` |
For more information about the other components and their props, see the [Base UI documentation](https://base-ui.com/react/components/alert-dialog#api-reference).
- [Documentation](https://base-ui.com/react/components/alert-dialog)
- [API reference](https://base-ui.com/react/components/alert-dialog#api-reference)
---
# Aspect Ratio
Displays content within a desired ratio.
Page: https://sui.draco.dev/docs/components/aspect-ratio
### Example: aspect-ratio-demo
```tsx
import { AspectRatio } from "@workspace/ui/components/aspect-ratio";
export default function AspectRatioDemo() {
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/aspect-ratio
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { AspectRatio } from "@workspace/ui/components/aspect-ratio"
```
```tsx showLineNumbers
```
## Square
A square aspect ratio component using the `ratio={1 / 1}` prop. This is useful for displaying images in a square format.
### Example: aspect-ratio-square
```tsx
import { AspectRatio } from "@workspace/ui/components/aspect-ratio";
export function AspectRatioSquare() {
return (
);
}
export default AspectRatioSquare;
```
## Portrait
A portrait aspect ratio component using the `ratio={9 / 16}` prop. This is useful for displaying images in a portrait format.
### Example: aspect-ratio-portrait
```tsx
import { AspectRatio } from "@workspace/ui/components/aspect-ratio";
export function AspectRatioPortrait() {
return (
);
}
export default AspectRatioPortrait;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: aspect-ratio-rtl
```tsx
"use client";
import { AspectRatio } from "@workspace/ui/components/aspect-ratio";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
caption: "Beautiful landscape",
},
},
ar: {
dir: "rtl",
values: {
caption: "منظر طبيعي جميل",
},
},
he: {
dir: "rtl",
values: {
caption: "נוף יפה",
},
},
};
export function AspectRatioRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.caption}
);
}
export default AspectRatioRtl;
```
## API Reference
### AspectRatio
The `AspectRatio` component displays content within a desired ratio.
| Prop | Type | Default | Required |
| ----------- | -------- | ------- | -------- |
| `ratio` | `number` | - | Yes |
| `className` | `string` | - | No |
For more information, see the [Base UI documentation](https://base-ui.com/react/components/aspect-ratio#api-reference).
---
# Attachment
Displays a file or image attachment with media, metadata, upload state, and actions.
Page: https://sui.draco.dev/docs/components/attachment
### Example: attachment-demo
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment";
import { Loader } from "@workspace/ui/components/loader";
import { FileCodeIcon, XIcon } from "lucide-react";
const images = [
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
alt: "Workspace",
},
{
name: "desk-reference.jpg",
meta: "JPG · 1.1 MB",
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
alt: "Desk",
},
{
name: "office-reference.jpg",
meta: "JPG · 940 KB",
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
alt: "Office",
},
];
export function AttachmentDemo() {
return (
{images.map((image) => (
{image.name}
{image.meta}
))}
sales-dashboard.pdf
Uploading · 64%
message-renderer.tsx
TypeScript · 12 KB
);
}
export default AttachmentDemo;
```
The `Attachment` component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state. Use it for files and images in chat composers, message threads, and upload lists.
## Installation
```bash
bunx --bun shadcn@latest add @sui/attachment
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment"
```
```tsx
sales-dashboard.pdf
PDF · 2.4 MB
```
## Composition
Use the following composition to build an attachment:
```text
Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTrigger
```
Use `AttachmentGroup` to lay out multiple attachments in a scrollable row:
```text
AttachmentGroup
├── Attachment
└── Attachment
```
## Features
- Icon and image media through `AttachmentMedia`
- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress
- Three sizes and horizontal or vertical orientation
- A full-card `AttachmentTrigger` that opens a link or dialog while the actions stay independently clickable
- Scrollable, snapping `AttachmentGroup` with an edge fade
- Customizable styling through the `className` prop on every part
## Image
Set `variant="image"` on `AttachmentMedia` and render an ` ` inside it. Use `orientation="vertical"` to stack the media above the content.
### Example: attachment-image
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@workspace/ui/components/attachment";
import { XIcon } from "lucide-react";
const images = [
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
alt: "Workspace",
},
{
name: "desk-reference.jpg",
meta: "JPG · 1.1 MB",
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
alt: "Desk",
},
{
name: "office-reference.jpg",
meta: "JPG · 940 KB",
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
alt: "Office",
},
];
export function AttachmentImage() {
return (
{images.map((image) => (
{image.name}
{image.meta}
}
/>
))}
);
}
export default AttachmentImage;
```
## States
Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment.
### Example: attachment-states
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment";
import { Loader } from "@workspace/ui/components/loader";
import {
CheckIcon,
ClockIcon,
FileTextIcon,
FileWarningIcon,
RefreshCwIcon,
XIcon,
} from "lucide-react";
export function AttachmentStates() {
return (
selected-file.pdf
Ready to upload
design-system.zip
Uploading · 64%
market-research.pdf
Processing document
financial-model.xlsx
Upload failed. Try again.
uploaded-report.pdf
Uploaded · 1.8 MB
);
}
export default AttachmentStates;
```
## Sizes
Use `size` to switch between `default`, `sm`, and `xs`.
### Example: attachment-sizes
```tsx
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment";
import { FileTextIcon } from "lucide-react";
export function AttachmentSizes() {
return (
Default attachment
PDF · 2.4 MB
Small attachment
PDF · 2.4 MB
Extra small attachment
);
}
export default AttachmentSizes;
```
## Group
Wrap attachments in `AttachmentGroup` to lay them out in a horizontally scrollable, snapping row with an edge fade.
### Example: attachment-group
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment";
import {
FileCodeIcon,
FileTextIcon,
type LucideIcon,
TableIcon,
XIcon,
} from "lucide-react";
import type { ReactNode } from "react";
type Item = {
name: string;
meta: string;
icon?: LucideIcon;
src?: string;
};
const items: Item[] = [
{ name: "briefing-notes.pdf", meta: "PDF · 1.4 MB", icon: FileTextIcon },
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
},
{ name: "customers.csv", meta: "CSV · 18 KB", icon: TableIcon },
{ name: "renderer.tsx", meta: "TSX · 12 KB", icon: FileCodeIcon },
];
export function AttachmentGroupDemo() {
return (
{items.map((item) => {
const Icon = item.icon;
let media: ReactNode = null;
if (item.src) {
media = (
);
} else if (Icon) {
media = (
);
}
return (
{media}
{item.name}
{item.meta}
);
})}
);
}
export default AttachmentGroupDemo;
```
## Trigger
Add an `AttachmentTrigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable.
### Example: attachment-trigger
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@workspace/ui/components/attachment";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { CopyIcon, FileSearchIcon, XIcon } from "lucide-react";
export function AttachmentTriggerDemo() {
return (
research-summary.pdf
Open preview dialog
}
/>
research-summary.pdf
The attachment trigger fills the card and opens the dialog, while
the actions stay independently clickable above it.
);
}
export default AttachmentTriggerDemo;
```
```tsx showLineNumbers
{/* media, content, actions */}
}
/>
{/* ... */}
```
## Accessibility
`AttachmentAction` renders a `Button`, and `AttachmentTrigger` renders a real `` (or your element via `render`). Follow the guidance below so both are operable and announced.
### Label icon-only actions
`AttachmentAction` is usually icon-only, so give each one an `aria-label` describing the action and its target.
```tsx showLineNumbers
```
### Label the trigger
`AttachmentTrigger` covers the card with no text of its own, so give it an `aria-label` for what activating it does.
```tsx showLineNumbers
}
/>
```
The trigger sits behind the actions in the stacking order, so an `AttachmentAction` and the `AttachmentTrigger` never trap each other — both remain separately focusable and clickable.
### Keyboard scrolling
An `AttachmentGroup` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`.
### Meaning beyond color
The `error` state uses a destructive color. Keep the failure reason in `AttachmentDescription` so the state is not conveyed by color alone.
## API Reference
### Attachment
The root attachment container.
| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- |
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. |
| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### AttachmentMedia
The media slot for an icon or image preview.
| Prop | Type | Default | Description |
| ----------- | ------------------- | -------- | ---------------------------------------------- |
| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an ` `. |
| `className` | `string` | - | Additional classes to apply to the media slot. |
### AttachmentContent
Wraps the title and description.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
### AttachmentTitle
The attachment name. Shimmers while the attachment is `uploading` or `processing`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the title. |
### AttachmentDescription
Secondary metadata such as the file type, size, or upload status.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the description. |
### AttachmentActions
A container for one or more actions, aligned to the end of the attachment.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the actions. |
### AttachmentAction
An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props.
| Prop | Type | Default | Description |
| ---------- | ------------------------------------- | ----------- | ---------------------------------------- |
| `size` | `Button["size"]` | `"icon-xs"` | The button size. |
| `...props` | `React.ComponentProps` | - | Props spread to the underlying `Button`. |
### AttachmentTrigger
A full-card overlay that activates the attachment. Renders a `` by default.
| Prop | Type | Default | Description |
| ---------- | -------------------------------- | ------- | ---------------------------------------------- |
| `render` | `ReactElement \| function` | - | Render as a different element, such as a link. |
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the trigger element. |
### AttachmentGroup
Lays out attachments in a horizontally scrollable, snapping row.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group. |
---
# Autocomplete
Suggests matching options while keeping the input free to accept custom text.
Page: https://sui.draco.dev/docs/components/autocomplete
Autocomplete combines a text input with a filtered list of suggestions. Its value is the input text, so users can accept a suggestion or enter their own answer. For a selection that must stay within a list of options, use [Combobox](/docs/components/combobox).
### Example: autocomplete-demo
```tsx
import {
Autocomplete,
AutocompleteClear,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteList,
AutocompleteTrigger,
} from "@workspace/ui/components/autocomplete";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { InputGroupAddon } from "@workspace/ui/components/input-group";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function AutocompleteDemo({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const [query, setQuery] = useState("");
const cities = chinese
? ["北京", "上海", "杭州", "成都", "深圳"]
: ["Beijing", "Shanghai", "Hangzhou", "Chengdu", "Shenzhen"];
return (
{chinese ? "目的地" : "Destination"}
{chinese
? "没有匹配的城市,仍可使用你输入的名称"
: "No matching cities. You can still use your own text."}
{(city: string) => (
{city}
)}
{chinese
? "城市建议不会限制自由输入。使用方向键浏览,Enter 采用建议,Escape 关闭"
: "Suggestions do not restrict your input. Use arrow keys to browse, Enter to accept, and Escape to close."}
{chinese ? "当前输入" : "Current input"}: {query || "—"}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/autocomplete
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteList,
} from "@workspace/ui/components/autocomplete"
import { Field, FieldLabel } from "@workspace/ui/components/field"
const languages = ["TypeScript", "JavaScript", "Rust"]
Project language
No suggestions found.
{(language: string) => (
{language}
)}
```
## Suggestions and clearing
Use `AutocompleteInputGroup` to anchor the popup to the complete input. Add `AutocompleteTrigger` and `AutocompleteClear` inside an `InputGroupAddon` when the field needs explicit suggestion and clear buttons. Give icon controls accessible labels.
Pass `value` and `onValueChange` to `Autocomplete` for a controlled text input. `openOnInputClick` also shows suggestions when the input is clicked. `autoHighlight` highlights the first match after typing; `mode="both"` enables inline completion alongside filtering.
## Grouped suggestions
Provide groups with an `items` array, render each group inside `AutocompleteList`, and use `AutocompleteCollection` to render its items. For object items, `itemToStringValue` defines the text placed in the input.
### Example: autocomplete-grouped
```tsx
import {
Autocomplete,
AutocompleteCollection,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteList,
} from "@workspace/ui/components/autocomplete";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { useId } from "react";
import type { ExampleProps } from "../types";
interface Destination {
id: string;
label: string;
}
interface DestinationGroup {
value: string;
items: Destination[];
}
export default function AutocompleteGrouped({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const groups: DestinationGroup[] = [
{
value: chinese ? "亚洲" : "Asia",
items: [
{ id: "tokyo", label: chinese ? "东京" : "Tokyo" },
{ id: "singapore", label: chinese ? "新加坡" : "Singapore" },
],
},
{
value: chinese ? "欧洲" : "Europe",
items: [
{ id: "paris", label: chinese ? "巴黎" : "Paris" },
{ id: "london", label: chinese ? "伦敦" : "London" },
],
},
];
return (
{chinese ? "按地区浏览目的地" : "Browse destinations by region"}
item.label}
openOnInputClick
autoHighlight
>
{chinese ? "没有找到城市" : "No cities found."}
{(group: DestinationGroup) => (
{group.value}
{(destination: Destination) => (
{destination.label}
)}
)}
);
}
```
## Disabled controls and items
Set `disabled` on `Autocomplete` to disable the input and its controls. Set `disabled` on a particular `AutocompleteItem` to leave it visible without allowing interaction. Use the switch below to enable the field; the Go suggestion remains unavailable.
### Example: autocomplete-disabled
```tsx
import {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteInputGroup,
AutocompleteItem,
AutocompleteList,
AutocompleteTrigger,
} from "@workspace/ui/components/autocomplete";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import { InputGroupAddon } from "@workspace/ui/components/input-group";
import { Switch } from "@workspace/ui/components/switch";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function AutocompleteDisabled({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const unavailableLabel = chinese ? "(暂不可用)" : " (unavailable)";
const id = useId();
const switchId = useId();
const [disabled, setDisabled] = useState(true);
const languages = ["TypeScript", "JavaScript", "Rust", "Go"];
return (
{chinese ? "禁用建议输入框" : "Disable autocomplete"}
{chinese ? "项目语言" : "Project language"}
{chinese ? "没有匹配的语言" : "No matching languages."}
{(language: string) => (
{language}
{language === "Go" ? unavailableLabel : ""}
)}
{chinese
? "开启控件后可以输入和浏览建议。Go 选项保持禁用"
: "Enable the control to type and browse suggestions. The Go option stays disabled."}
);
}
```
## Accessibility and keyboard
Provide a visible `FieldLabel` or an `aria-label` for the input. When linking a label explicitly, use React `useId()` so examples and repeated fields keep independent IDs.
Type to filter suggestions. Arrow keys move the highlighted suggestion, Enter accepts it, and Escape closes the popup. A disabled suggestion cannot be accepted. Suggestions do not make custom text invalid; add application validation when the field needs it.
## API reference
| Part | Purpose |
| --- | --- |
| `Autocomplete` | Owns items, filtering, input `value`, `onValueChange`, `mode`, opening, and disabled state. |
| `AutocompleteInputGroup`, `AutocompleteInput` | Compose the styled input and its anchor. The input keeps standard input props and a React 19 `ref`. |
| `AutocompleteTrigger`, `AutocompleteClear` | Open the suggestion popup or clear the input. Both support `render` composition. |
| `AutocompleteContent` | Composes the portal and positioning. Defaults to bottom/start with a `sideOffset` of `6`; accepts `side`, `align`, offsets, and `anchor`. |
| `AutocompleteList`, `AutocompleteItem` | Render filtered suggestions with keyboard highlighting and per-item `disabled`. |
| `AutocompleteGroup`, `AutocompleteGroupLabel`, `AutocompleteCollection` | Render labeled groups and their filtered items. |
| `AutocompleteEmpty`, `AutocompleteStatus`, `AutocompleteSeparator` | Show empty results, accessible status, and visual separators. |
| `AutocompleteValue` | Render the current input text with a child render function. |
| `useAutocompleteFilter`, `useAutocompleteFilteredItems` | Reuse filtering helpers for custom matching or externally filtered lists. |
See the [Base UI Autocomplete API](https://base-ui.com/react/components/autocomplete#api-reference) for the primitive props and event details.
---
# Avatar
An image element with a fallback for representing the user.
Page: https://sui.draco.dev/docs/components/avatar
### Example: avatar-demo
```tsx
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@workspace/ui/components/avatar";
export default function AvatarDemo() {
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/avatar
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Avatar, AvatarFallback, AvatarImage } from "@workspace/ui/components/avatar"
```
```tsx showLineNumbers
CN
```
## Composition
Use the following composition to build an `Avatar`:
```text
Avatar
├── AvatarImage
├── AvatarFallback
└── AvatarBadge
```
Use the following composition to build an `AvatarGroup`:
```text
AvatarGroup
├── Avatar
│ ├── AvatarImage
│ ├── AvatarFallback
│ └── AvatarBadge
├── Avatar
│ ├── AvatarImage
│ ├── AvatarFallback
│ └── AvatarBadge
└── AvatarGroupCount
```
## Basic
A basic avatar component with an image and a fallback.
### Example: avatar-basic
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
export default function AvatarDemo() {
return (
CN
);
}
```
## Badge
Use the `AvatarBadge` component to add a badge to the avatar. The badge is positioned at the bottom right of the avatar.
### Example: avatar-badge
```tsx
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
export function AvatarWithBadge() {
return (
CN
);
}
export default AvatarWithBadge;
```
Use the `className` prop to add custom styles to the badge such as custom colors, sizes, etc.
```tsx showLineNumbers
CN
```
## Badge with Icon
You can also use an icon inside ``.
### Example: avatar-badge-icon
```tsx
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { PlusIcon } from "lucide-react";
export function AvatarBadgeIconExample() {
return (
PP
);
}
export default AvatarBadgeIconExample;
```
## Avatar Group
Use the `AvatarGroup` component to add a group of avatars.
### Example: avatar-group
```tsx
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@workspace/ui/components/avatar";
export function AvatarGroupExample() {
return (
CN
LR
ER
);
}
export default AvatarGroupExample;
```
## Avatar Group Count
Use `` to add a count to the group.
### Example: avatar-group-count
```tsx
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@workspace/ui/components/avatar";
export function AvatarGroupCountExample() {
return (
CN
LR
ER
+3
);
}
export default AvatarGroupCountExample;
```
## Avatar Group with Icon
You can also use an icon inside ``.
### Example: avatar-group-count-icon
```tsx
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { PlusIcon } from "lucide-react";
export function AvatarGroupCountIconExample() {
return (
CN
LR
ER
);
}
export default AvatarGroupCountIconExample;
```
## Sizes
Use the `size` prop to change the size of the avatar.
### Example: avatar-size
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
export function AvatarSizeExample() {
return (
);
}
export default AvatarSizeExample;
```
## Dropdown
You can use the `Avatar` component as a trigger for a dropdown menu.
### Example: avatar-dropdown
```tsx
"use client";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
export function AvatarDropdown() {
return (
}
>
CN
Profile
Billing
Settings
Log out
);
}
export default AvatarDropdown;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: avatar-rtl
```tsx
"use client";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
moreUsers: "+3",
},
},
ar: {
dir: "rtl",
values: {
moreUsers: "+٣",
},
},
he: {
dir: "rtl",
values: {
moreUsers: "+3",
},
},
};
export function AvatarRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
CN
ER
CN
LR
ER
{t.moreUsers}
);
}
export default AvatarRtl;
```
## API Reference
### Avatar
The `Avatar` component is the root component that wraps the avatar image and fallback.
| Prop | Type | Default |
| ----------- | --------------------------- | ----------- |
| `size` | `"default" \| "sm" \| "lg"` | `"default"` |
| `className` | `string` | - |
### AvatarImage
The `AvatarImage` component displays the avatar image. It accepts all Base UI Avatar Image props.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `src` | `string` | - |
| `alt` | `string` | - |
| `className` | `string` | - |
### AvatarFallback
The `AvatarFallback` component displays a fallback when the image fails to load. It accepts all Base UI Avatar Fallback props.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### AvatarBadge
The `AvatarBadge` component displays a badge indicator on the avatar, typically positioned at the bottom right.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### AvatarGroup
The `AvatarGroup` component displays a group of avatars with overlapping styling.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### AvatarGroupCount
The `AvatarGroupCount` component displays a count indicator in an avatar group, typically showing the number of additional avatars.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
For more information about Base UI Avatar props, see the [Base UI documentation](https://base-ui.com/react/components/avatar#api-reference).
- [Documentation](https://base-ui.com/react/components/avatar)
- [API reference](https://base-ui.com/react/components/avatar#api-reference)
---
# Badge
Displays a badge or a component that looks like a badge.
Page: https://sui.draco.dev/docs/components/badge
### Example: badge-demo
```tsx
import { Badge } from "@workspace/ui/components/badge";
export default function BadgeDemo() {
return (
Badge
Secondary
Destructive
Outline
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/badge
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Badge } from "@workspace/ui/components/badge"
```
```tsx
Badge
```
## Variants
Use the `variant` prop to change the variant of the badge.
### Example: badge-variants
```tsx
import { Badge } from "@workspace/ui/components/badge";
export function BadgeVariants() {
return (
Default
Secondary
Destructive
Outline
Ghost
);
}
export default BadgeVariants;
```
## With Icon
You can render an icon inside the badge. Use `data-icon="inline-start"` to render the icon on the left and `data-icon="inline-end"` to render the icon on the right.
### Example: badge-icon
```tsx
import { Badge } from "@workspace/ui/components/badge";
import { BadgeCheck, BookmarkIcon } from "lucide-react";
export function BadgeWithIconLeft() {
return (
Verified
Bookmark
);
}
export default BadgeWithIconLeft;
```
## With Loader
You can render a spinner inside the badge. Remember to add the `data-icon="inline-start"` or `data-icon="inline-end"` prop to the spinner.
### Example: badge-loading
```tsx
import { Badge } from "@workspace/ui/components/badge";
import { Loader } from "@workspace/ui/components/loader";
export function BadgeWithLoader() {
return (
Deleting
Generating
);
}
export default BadgeWithLoader;
```
## Link
Use the `render` prop to render a link as a badge.
### Example: badge-link
```tsx
import { Badge } from "@workspace/ui/components/badge";
import { ArrowUpRightIcon } from "lucide-react";
export function BadgeAsLink() {
return (
}>
Open Link
);
}
export default BadgeAsLink;
```
## Custom Colors
You can customize the colors of a badge by adding custom classes such as `bg-green-50 dark:bg-green-800` to the `Badge` component.
### Example: badge-colors
```tsx
import { Badge } from "@workspace/ui/components/badge";
export function BadgeCustomColors() {
return (
Blue
Green
Sky
Purple
Red
);
}
export default BadgeCustomColors;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: badge-rtl
```tsx
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { BadgeCheck, BookmarkIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
badge: "Badge",
secondary: "Secondary",
destructive: "Destructive",
outline: "Outline",
verified: "Verified",
bookmark: "Bookmark",
},
},
ar: {
dir: "rtl",
values: {
badge: "شارة",
secondary: "ثانوي",
destructive: "مدمر",
outline: "مخطط",
verified: "متحقق",
bookmark: "إشارة مرجعية",
},
},
he: {
dir: "rtl",
values: {
badge: "תג",
secondary: "משני",
destructive: "הרסני",
outline: "קווי מתאר",
verified: "מאומת",
bookmark: "סימנייה",
},
},
};
export function BadgeRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.badge}
{t.secondary}
{t.destructive}
{t.outline}
{t.verified}
{t.bookmark}
);
}
export default BadgeRtl;
```
## API Reference
### Badge
The `Badge` component displays a badge or a component that looks like a badge.
| Prop | Type | Default |
| ----------- | ----------------------------------------------------------------------------- | ----------- |
| `variant` | `"default" \| "secondary" \| "destructive" \| "outline" \| "ghost" \| "link"` | `"default"` |
| `className` | `string` | - |
---
# Breadcrumb
Displays the path to the current resource using a hierarchy of links.
Page: https://sui.draco.dev/docs/components/breadcrumb
### Example: breadcrumb-demo
```tsx
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
export function BreadcrumbDemo() {
return (
}>Home
}
>
Toggle menu
Documentation
Themes
GitHub
}>
Components
Breadcrumb
);
}
export default BreadcrumbDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/breadcrumb
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb"
```
```tsx showLineNumbers
}>Home
}>
Components
Breadcrumb
```
## Composition
Use the following composition to build a `Breadcrumb`:
```text
Breadcrumb
└── BreadcrumbList
├── BreadcrumbItem
│ └── BreadcrumbLink
├── BreadcrumbSeparator
├── BreadcrumbItem
│ └── BreadcrumbLink
├── BreadcrumbSeparator
└── BreadcrumbItem
└── BreadcrumbPage
```
## Basic
A basic breadcrumb with a home link and a components link.
### Example: breadcrumb-basic
```tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
export function BreadcrumbBasic() {
return (
Home
Components
Breadcrumb
);
}
export default BreadcrumbBasic;
```
## Custom separator
Use a custom component as `children` for ` ` to create a custom separator.
### Example: breadcrumb-separator
```tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
import { DotIcon } from "lucide-react";
export function BreadcrumbSeparatorDemo() {
return (
}>Home
}>
Components
Breadcrumb
);
}
export default BreadcrumbSeparatorDemo;
```
## Dropdown
You can compose ` ` with a ` ` to create a dropdown in the breadcrumb.
### Example: breadcrumb-dropdown
```tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ChevronDownIcon, DotIcon } from "lucide-react";
export function BreadcrumbDropdown() {
return (
}>Home
}
>
Components
Documentation
Themes
GitHub
Breadcrumb
);
}
export default BreadcrumbDropdown;
```
## Collapsed
We provide a ` ` component to show a collapsed state when the breadcrumb is too long.
### Example: breadcrumb-ellipsis
```tsx
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
export function BreadcrumbEllipsisDemo() {
return (
}>Home
}>
Components
Breadcrumb
);
}
export default BreadcrumbEllipsisDemo;
```
## Link component
To use a custom link component from your routing library, you can use the `render` prop on ` `.
### Example: breadcrumb-link
```tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
export function BreadcrumbLinkDemo() {
return (
}>
Home
}>
Components
Breadcrumb
);
}
export default BreadcrumbLinkDemo;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: breadcrumb-rtl
```tsx
"use client";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ChevronDownIcon, DotIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
home: "Home",
components: "Components",
documentation: "Documentation",
themes: "Themes",
github: "GitHub",
breadcrumb: "Breadcrumb",
},
},
ar: {
dir: "rtl",
values: {
home: "الرئيسية",
components: "المكونات",
documentation: "التوثيق",
themes: "السمات",
github: "جيت هاب",
breadcrumb: "مسار التنقل",
},
},
he: {
dir: "rtl",
values: {
home: "בית",
components: "רכיבים",
documentation: "תיעוד",
themes: "ערכות נושא",
github: "גיטהאב",
breadcrumb: "פירורי לחם",
},
},
};
export function BreadcrumbRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
return (
}>{t.home}
}
>
{t.components}
{t.documentation}
{t.themes}
{t.github}
{t.breadcrumb}
);
}
export default BreadcrumbRtl;
```
## API Reference
### Breadcrumb
The `Breadcrumb` component is the root navigation element that wraps all breadcrumb components.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### BreadcrumbList
The `BreadcrumbList` component displays the ordered list of breadcrumb items.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### BreadcrumbItem
The `BreadcrumbItem` component wraps individual breadcrumb items.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### BreadcrumbLink
The `BreadcrumbLink` component displays a clickable link in the breadcrumb.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### BreadcrumbPage
The `BreadcrumbPage` component displays the current page in the breadcrumb (non-clickable).
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### BreadcrumbSeparator
The `BreadcrumbSeparator` component displays a separator between breadcrumb items. You can pass custom children to override the default separator icon.
| Prop | Type | Default |
| ----------- | ----------------- | ------- |
| `children` | `React.ReactNode` | - |
| `className` | `string` | - |
### BreadcrumbEllipsis
The `BreadcrumbEllipsis` component displays an ellipsis indicator for collapsed breadcrumb items.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
---
# Bubble
Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
Page: https://sui.draco.dev/docs/components/bubble
### Example: bubble-demo
```tsx
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@workspace/ui/components/bubble";
export function BubbleDemo() {
return (
Hey there! what's up?
Hey! Want to see chat bubbles?
I can group messages, switch sides, and keep the whole thread easy
to scan.
👍
Sure. Hit me with your best demo.
Yes. You are reading a demo that is demoing itself. Very meta. Very
on-brand.
👍
🔥
👀
+2
);
}
export default BubbleDemo;
```
The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions.
For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message).
## Installation
```bash
bunx --bun shadcn@latest add @sui/bubble
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Bubble, BubbleContent, BubbleReactions } from "@workspace/ui/components/bubble"
```
```tsx showLineNumbers
I checked the registry output and removed the stale route.
👍
```
## Composition
Use the following composition to build a bubble:
```text
Bubble
├── BubbleContent
└── BubbleReactions
```
Use `BubbleGroup` to group consecutive bubbles from the same sender:
```text
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
```
## Features
- Seven visual variants, from a strong primary bubble to unframed ghost content
- Start and end alignment for sender and receiver bubbles
- Reactions that anchor to the bubble edge with configurable side and alignment
- Bubbles size to their content, up to 80% of the container width
- Polymorphic content via `render` for link and button bubbles
- Customizable styling through the `className` prop on every part
## Variants
Use `variant` to change the visual treatment of the bubble.
### Example: bubble-variants
```tsx
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@workspace/ui/components/bubble";
export function BubbleVariantsDemo() {
return (
This is the default primary bubble.
This is the secondary variant.
This one is muted. It uses a lower emphasis color for the chat bubble.
👍
This one is tinted. The tint is a softer color derived from the
primary color.
We can also use an outlined variant.
Or a destructive variant with a reaction.
🔥
{`Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed.
This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render \`code\` in it.
Ghost bubbles are full width and can take the full width of the container.
`}
);
}
export default BubbleVariantsDemo;
```
| Variant | Description |
| ------------- | ------------------------------------------------------ |
| `default` | A strong primary bubble, usually for the current user. |
| `secondary` | The standard neutral bubble for conversation content. |
| `muted` | A lower-emphasis bubble for quiet supporting content. |
| `tinted` | A subtle primary-tinted bubble. |
| `outline` | A bordered bubble for secondary or rich content. |
| `ghost` | Unframed content for assistant text or rich content. |
| `destructive` | A destructive bubble for error or failed actions. |
A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row.
## Alignment
Use `align` on `Bubble` to align the bubble to the start or end of the conversation.
### Example: bubble-alignment
```tsx
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
export function BubbleAlignmentDemo() {
return (
This bubble is aligned to the start. This is the default alignment.
This bubble is aligned to the end. Use this for user messages.
);
}
export default BubbleAlignmentDemo;
```
| align | Description |
| ------- | -------------------------------------------------- |
| `start` | Align the bubble to the start of the conversation. |
| `end` | Align the bubble to the end of the conversation. |
**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `align` prop on the `Message` component to automatically align the bubble to the start or end of the conversation.
## Bubble Group
Use `BubbleGroup` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `Bubble` component itself, not the `BubbleGroup` component.
```text
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
```
### Example: bubble-group-demo
```tsx
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@workspace/ui/components/bubble";
export function BubbleGroupDemo() {
return (
Can you tell me what's the issue?
You tell me!
It worked yesterday. You broke it!
Find the bug and fix it.
👀
Want me to diff yesterday's you against today's you?
It's a bit embarrassing.
);
}
export default BubbleGroupDemo;
```
## Links and Buttons
You can turn a bubble into a link or button by using the `render` prop on `BubbleContent`.
### Example: bubble-link-button
```tsx
"use client";
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@workspace/ui/components/bubble";
import { toast } from "@workspace/ui/components/toast";
export function BubbleLinkButtonDemo() {
return (
How can I help you today?
toast.add({ title: "You clicked forgot password" })
}
/>
}
>
I forgot my password
toast.add({ title: "You clicked help with subscription" })
}
/>
}
>
I need help with my subscription
toast.add({
title: "You clicked something else. Talk to a human.",
})
}
/>
}
>
Something else. Talk to a human.
);
}
export default BubbleLinkButtonDemo;
```
```tsx showLineNumbers
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble"
export function BubbleLinkDemo() {
return (
}>Click here
)
}
```
## Reactions
Use `BubbleReactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason.
### Example: bubble-reactions
```tsx
"use client";
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import { toast } from "@workspace/ui/components/toast";
export function BubbleReactionsDemo() {
return (
I don't need tests, I know my code works.
👍
😮
Bold. Fine I'll add some tests. I'll let you know when
they're done.
👀
🚀
+2
Tests passed on the first try. All 142 of them. Looking good!
🎉
👏
Are you sure I can run this command?
toast.add({
title: "You clicked yes, running command...",
type: "success",
})
}
>
Yes, run it
);
}
export default BubbleReactionsDemo;
```
## Show More / Collapsible
Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `CollapsibleTrigger` component to trigger the collapsible content.
### Example: bubble-collapsible
```tsx
"use client";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Collapsible,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import { ChevronDownIcon } from "lucide-react";
import * as React from "react";
const text = `The accessibility review found two focus states that were visually too subtle in dark mode.
I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.
The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`;
const previewLength = 180;
export function BubbleCollapsible() {
const [open, setOpen] = React.useState(false);
const isLong = text.length > previewLength;
const preview = `${text.slice(0, previewLength)}...`;
return (
How can I help you today?
{open || !isLong ? text : preview}
{isLong ? (
}
>
{open ? "Show less" : "Show more"}
) : null}
);
}
export default BubbleCollapsible;
```
## Tooltip
Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read.
### Example: bubble-tooltip
```tsx
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { CheckIcon } from "lucide-react";
export function BubbleTooltipDemo() {
return (
Did you remove the stale route?
Yes, removed it from the registry.
}>
Read on Jan 5, 2026 at 4:32 PM
);
}
export default BubbleTooltipDemo;
```
## Popover
Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action.
### Example: bubble-popover
```tsx
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { InfoIcon } from "lucide-react";
export function BubblePopoverDemo() {
return (
Run the build script.
Failed to run the command.
}
>
Command failed with exit code 1
ENOENT: no such file or directory, open pnpm-lock.yaml
);
}
export default BubblePopoverDemo;
```
## Accessibility
`Bubble` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.
### Labeling Reactions
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria-label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria-hidden` is needed.
```tsx showLineNumbers
👍
🔥
+8
```
When reactions are interactive, render buttons instead and give icon-only buttons an `aria-label`.
```tsx showLineNumbers
```
### Interactive Bubbles
When a bubble is clickable, render it as a real `` or `` with the `render` prop so it is focusable and exposes the correct role. `BubbleContent` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.
```tsx showLineNumbers
}>
I forgot my password
```
### Meaning Beyond Color
Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment.
## API Reference
### Bubble
The root bubble wrapper.
| Prop | Type | Default | Description |
| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. |
| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### BubbleContent
The bubble content wrapper.
| Prop | Type | Default | Description |
| ----------- | -------------------------- | ------- | --------------------------------------------------------- |
| `render` | `ReactElement \| function` | - | Render the content as a different element such as a link. |
| `className` | `string` | - | Additional classes to apply to the content element. |
### BubbleReactions
Displays overlapped reactions for a bubble.
| Prop | Type | Default | Description |
| ----------- | ------------------- | ---------- | ------------------------------------------------ |
| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. |
| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. |
| `className` | `string` | - | Additional classes to apply to the reaction row. |
### BubbleGroup
Groups consecutive bubbles from the same sender.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ---------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group root. |
---
# Button
Displays a button or a component that looks like a button.
Page: https://sui.draco.dev/docs/components/button
### Example: button-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import { ArrowUpIcon } from "lucide-react";
export default function ButtonDemo() {
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/button
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Button } from "@workspace/ui/components/button"
```
```tsx
Button
```
## Cursor
Tailwind v4 [switched](https://tailwindcss.com/docs/upgrade-guide#buttons-use-the-default-cursor) from `cursor: pointer` to `cursor: default` for the button component.
If you want to keep the `cursor: pointer` behavior, add the following code to your CSS file:
You can also enable this during project setup with `npx shadcn@latest init --pointer`.
```css showLineNumbers title="globals.css"
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
```
## Size
Use the `size` prop to change the size of the button.
### Example: button-size
```tsx
import { Button } from "@workspace/ui/components/button";
import { ArrowUpRightIcon } from "lucide-react";
export default function ButtonSize() {
return (
);
}
```
## Default
### Example: button-default
```tsx
import { Button } from "@workspace/ui/components/button";
export default function ButtonDefault() {
return Button ;
}
```
## Outline
### Example: button-outline
```tsx
import { Button } from "@workspace/ui/components/button";
export default function ButtonOutline() {
return Outline ;
}
```
## Secondary
### Example: button-secondary
```tsx
import { Button } from "@workspace/ui/components/button";
export default function ButtonSecondary() {
return Secondary ;
}
```
## Ghost
### Example: button-ghost
```tsx
import { Button } from "@workspace/ui/components/button";
export default function ButtonGhost() {
return Ghost ;
}
```
## Destructive
### Example: button-destructive
```tsx
import { Button } from "@workspace/ui/components/button";
export default function ButtonDestructive() {
return Destructive ;
}
```
## Link
### Example: button-link
```tsx
import { Button } from "@workspace/ui/components/button";
export default function ButtonLink() {
return Link ;
}
```
## Icon
### Example: button-icon
```tsx
import { Button } from "@workspace/ui/components/button";
import { CircleFadingArrowUpIcon } from "lucide-react";
export default function ButtonIcon() {
return (
);
}
```
## With Icon
Remember to add the `data-icon="inline-start"` or `data-icon="inline-end"` attribute to the icon for the correct spacing.
### Example: button-with-icon
```tsx
import { Button } from "@workspace/ui/components/button";
import { GitBranchIcon, GitForkIcon } from "lucide-react";
export default function ButtonWithIcon() {
return (
New Branch
Fork
);
}
```
## Rounded
Use the `rounded-full` class to make the button rounded.
### Example: button-rounded
```tsx
import { Button } from "@workspace/ui/components/button";
import { ArrowUpIcon } from "lucide-react";
export default function ButtonRounded() {
return (
);
}
```
## Loader
Render a ` ` component inside the button to show a loading state. Remember to add the `data-icon="inline-start"` or `data-icon="inline-end"` attribute to the spinner for the correct spacing.
### Example: button-loading
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Loader } from "@workspace/ui/components/loader";
import { useEffect, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function ButtonLoading({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
generating: "正在生成",
generate: "生成",
downloading: "正在下载",
download: "下载",
done: "演示完成,可以再次点击",
idle: "点击按钮模拟加载状态",
}
: {
generating: "Generating",
generate: "Generate",
downloading: "Downloading",
download: "Download",
done: "Demo complete. Try it again.",
idle: "Click a button to simulate loading.",
};
const [status, setStatus] = useState<"idle" | "pending" | "done">("idle");
const timer = useRef | undefined>(undefined);
const pending = status === "pending";
useEffect(() => () => clearTimeout(timer.current), []);
function startDemo() {
clearTimeout(timer.current);
setStatus("pending");
// Replace this delay with your application's async operation.
timer.current = setTimeout(() => setStatus("done"), 1200);
}
return (
{pending && }
{pending ? stateLabels.generating : stateLabels.generate}
{pending ? stateLabels.downloading : stateLabels.download}
{pending && }
{status === "done" ? stateLabels.done : stateLabels.idle}
);
}
```
## Button Group
To create a button group, use the `ButtonGroup` component. See the [Button Group](/docs/components/button-group) documentation for more details.
### Example: button-group-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
export default function ButtonGroupDemo() {
const [label, setLabel] = React.useState("personal");
return (
Archive
Report
Snooze
}
>
Mark as Read
Archive
Snooze
Add to Calendar
Add to List
Label As...
Personal
Work
Other
Trash
);
}
```
## As Link
You can use the `buttonVariants` helper to make a link look like a button.
**Do not use ` } nativeButton={false} />` for links.** The Base UI `Button` component always applies `role="button"`, which overrides the semantic link role on `` elements. Use `buttonVariants` with a plain ` ` tag instead.
### Example: button-render
```tsx
import { buttonVariants } from "@workspace/ui/components/button";
export default function ButtonRender() {
return (
Login
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: button-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Loader } from "@workspace/ui/components/loader";
import { ArrowRightIcon, PlusIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
button: "Button",
submit: "Submit",
delete: "Delete",
loading: "Loading",
},
},
ar: {
dir: "rtl",
values: {
button: "زر",
submit: "إرسال",
delete: "حذف",
loading: "جاري التحميل",
},
},
he: {
dir: "rtl",
values: {
button: "כפתור",
submit: "שלח",
delete: "מחק",
loading: "טוען",
},
},
};
export function ButtonRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.button}
{t.delete}
{t.submit}{" "}
{t.loading}
);
}
export default ButtonRtl;
```
## API Reference
### Button
The `Button` component is a wrapper around the `button` element that adds a variety of styles and functionality.
| Prop | Type | Default |
| --------- | ------------------------------------------------------------------------------------ | ----------- |
| `variant` | `"default" \| "outline" \| "ghost" \| "destructive" \| "secondary" \| "link"` | `"default"` |
| `size` | `"default" \| "xs" \| "sm" \| "lg" \| "icon" \| "icon-xs" \| "icon-sm" \| "icon-lg"` | `"default"` |
---
# Button Group
A container that groups related buttons together with consistent styling.
Page: https://sui.draco.dev/docs/components/button-group
### Example: button-group-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
export default function ButtonGroupDemo() {
const [label, setLabel] = React.useState("personal");
return (
Archive
Report
Snooze
}
>
Mark as Read
Archive
Snooze
Add to Calendar
Add to List
Label As...
Personal
Work
Other
Trash
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/button-group
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@workspace/ui/components/button-group"
```
```tsx
Button 1
Button 2
```
## Composition
Use the following composition to build a `ButtonGroup`:
```text
ButtonGroup
├── Button or Input
├── ButtonGroupSeparator
└── ButtonGroupText
```
## Accessibility
- The `ButtonGroup` component has the `role` attribute set to `group`.
- Use Tab
to navigate between the buttons in the group.
- Use `aria-label` or `aria-labelledby` to label the button group.
```tsx showLineNumbers
Button 1
Button 2
```
## ButtonGroup vs ToggleGroup
- Use the `ButtonGroup` component when you want to group buttons that perform an action.
- Use the `ToggleGroup` component when you want to group buttons that toggle a state.
## Orientation
Set the `orientation` prop to change the button group layout.
### Example: button-group-orientation
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import { MinusIcon, PlusIcon } from "lucide-react";
export default function ButtonGroupOrientation() {
return (
);
}
```
## Size
Control the size of buttons using the `size` prop on individual buttons.
### Example: button-group-size
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import { PlusIcon } from "lucide-react";
export default function ButtonGroupSize() {
return (
Small
Button
Group
Default
Button
Group
Large
Button
Group
);
}
```
## Nested
Nest `` components to create button groups with spacing.
### Example: button-group-nested
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { AudioLinesIcon, PlusIcon } from "lucide-react";
export function ButtonGroupNested() {
return (
}>
Voice Mode
);
}
export default ButtonGroupNested;
```
## Separator
The `ButtonGroupSeparator` component visually divides buttons within a group.
Buttons with variant `outline` do not need a separator since they have a border. For other variants, a separator is recommended to improve the visual hierarchy.
### Example: button-group-separator
```tsx
import { Button } from "@workspace/ui/components/button";
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@workspace/ui/components/button-group";
export default function ButtonGroupSeparatorDemo() {
return (
Copy
Paste
);
}
```
## Split
Create a split button group by adding two buttons separated by a `ButtonGroupSeparator`.
### Example: button-group-split
```tsx
import { Button } from "@workspace/ui/components/button";
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@workspace/ui/components/button-group";
import { PlusIcon } from "lucide-react";
export default function ButtonGroupSplit() {
return (
Button
);
}
```
## Input
Wrap an `Input` component with buttons.
### Example: button-group-input
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import { Input } from "@workspace/ui/components/input";
import { SearchIcon } from "lucide-react";
export default function ButtonGroupInput() {
return (
);
}
```
## Input Group
Wrap an `InputGroup` component to create complex input layouts.
### Example: button-group-input-group
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { AudioLinesIcon, PlusIcon } from "lucide-react";
import * as React from "react";
export default function ButtonGroupInputGroup() {
const [voiceEnabled, setVoiceEnabled] = React.useState(false);
return (
setVoiceEnabled(!voiceEnabled)}
size="icon-xs"
data-active={voiceEnabled}
className="data-[active=true]:bg-orange-100 data-[active=true]:text-orange-700 dark:data-[active=true]:bg-orange-800 dark:data-[active=true]:text-orange-100"
aria-pressed={voiceEnabled}
/>
}
>
Voice Mode
);
}
```
## Dropdown Menu
Create a split button group with a `DropdownMenu` component.
### Example: button-group-dropdown
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
AlertTriangleIcon,
CheckIcon,
ChevronDownIcon,
CopyIcon,
ShareIcon,
TrashIcon,
UserRoundXIcon,
VolumeOffIcon,
} from "lucide-react";
export default function ButtonGroupDropdown() {
return (
Follow
}
>
Mute Conversation
Mark as Read
Report Conversation
Block User
Share Conversation
Copy Conversation
Delete Conversation
);
}
```
## Select
Pair with a `Select` component.
### Example: button-group-select
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@workspace/ui/components/select";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
const CURRENCIES = [
{ label: "US Dollar", value: "$" },
{ label: "Euro", value: "€" },
{ label: "British Pound", value: "£" },
];
export default function ButtonGroupSelect() {
const [currency, setCurrency] = React.useState("$");
return (
setCurrency(value as string)}
>
{currency}
{CURRENCIES.map((item) => (
{item.value}{" "}
{item.label}
))}
);
}
```
## Popover
Use with a `Popover` component.
### Example: button-group-popover
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { Textarea } from "@workspace/ui/components/textarea";
import { BotIcon, ChevronDownIcon } from "lucide-react";
import { useId as usePreviewId } from "react";
export default function ButtonGroupPopover() {
const previewId = usePreviewId();
return (
Copilot
}
>
Start a new task with Copilot
Describe your task in natural language.
Task Description
Copilot will open a pull request for review.
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: button-group-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
ArchiveIcon,
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
archive: "Archive",
report: "Report",
snooze: "Snooze",
markAsRead: "Mark as Read",
addToCalendar: "Add to Calendar",
addToList: "Add to List",
labelAs: "Label As...",
personal: "Personal",
work: "Work",
other: "Other",
trash: "Trash",
},
},
ar: {
dir: "rtl",
values: {
archive: "أرشفة",
report: "تقرير",
snooze: "تأجيل",
markAsRead: "وضع علامة كمقروء",
addToCalendar: "إضافة إلى التقويم",
addToList: "إضافة إلى القائمة",
labelAs: "تصنيف كـ...",
personal: "شخصي",
work: "عمل",
other: "آخر",
trash: "سلة المهملات",
},
},
he: {
dir: "rtl",
values: {
archive: "ארכיון",
report: "דוח",
snooze: "דחה",
markAsRead: "סמן כנקרא",
addToCalendar: "הוסף ליומן",
addToList: "הוסף לרשימה",
labelAs: "תייג כ...",
personal: "אישי",
work: "עבודה",
other: "אחר",
trash: "פח",
},
},
};
export function ButtonGroupRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
const [label, setLabel] = React.useState("personal");
return (
{t.archive}
{t.report}
{t.snooze}
}
>
{t.markAsRead}
{t.archive}
{t.snooze}
{t.addToCalendar}
{t.addToList}
{t.labelAs}
{t.personal}
{t.work}
{t.other}
{t.trash}
);
}
export default ButtonGroupRtl;
```
## API Reference
### ButtonGroup
The `ButtonGroup` component is a container that groups related buttons together with consistent styling.
| Prop | Type | Default |
| ------------- | ---------------------------- | -------------- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |
```tsx
Button 1
Button 2
```
Nest multiple button groups to create complex layouts with spacing. See the [nested](#nested) example for more details.
```tsx
```
### ButtonGroupSeparator
The `ButtonGroupSeparator` component visually divides buttons within a group.
| Prop | Type | Default |
| ------------- | ---------------------------- | ------------ |
| `orientation` | `"horizontal" \| "vertical"` | `"vertical"` |
```tsx
Button 1
Button 2
```
### ButtonGroupText
Use this component to display text within a button group.
| Prop | Type | Default |
| -------- | -------------------- | ------- |
| `render` | `React.ReactElement` | |
```tsx
Text
Button
```
Use the `render` prop to render a custom component as the text, for example a label.
```tsx showLineNumbers
import { ButtonGroupText } from "@workspace/ui/components/button-group"
import { Label } from "@workspace/ui/components/label"
export function ButtonGroupTextDemo() {
return (
}>Text
)
}
```
---
# Calendar
A calendar component that allows users to select a date or a range of dates.
Page: https://sui.draco.dev/docs/components/calendar
### Example: calendar-demo
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
import * as React from "react";
export default function CalendarDemo() {
const [date, setDate] = React.useState(
new Date(2025, 5, 12),
);
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/calendar
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Calendar } from "@workspace/ui/components/calendar"
```
```tsx showLineNumbers
const [date, setDate] = React.useState(new Date())
return (
)
```
See the [React DayPicker](https://react-day-picker.js.org) documentation for more information.
## About
The `Calendar` component is built on top of [React DayPicker](https://react-day-picker.js.org).
## Date Picker
You can use the `` component to build a date picker. See the [Date Picker](https://ui.shadcn.com/docs/components/base/date-picker) page for more information.
## Persian / Hijri / Jalali Calendar
To use the Persian calendar, edit `packages/ui/src/components/calendar.tsx` and replace `react-day-picker` with `@daypicker/persian`.
```diff
- import { DayPicker } from "react-day-picker"
+ import { DayPicker } from "@daypicker/persian"
```
The upstream example is named Hijri, but implements the Persian/Jalali calendar. With React DayPicker v10, use the `@daypicker/persian` addon.
### Example: calendar-hijri
```tsx
"use client";
// DayPicker v10 publishes the original Persian calendar in @daypicker/persian.
import { DayPicker } from "@daypicker/persian";
import { Button, buttonVariants } from "@workspace/ui/components/button";
import { cn } from "cn";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import * as React from "react";
import { type DayButton, getDefaultClassNames } from "react-day-picker";
export default function CalendarHijri() {
const [date, setDate] = React.useState(
new Date(2025, 5, 12),
);
return (
);
}
// ----------------------------------------------------------------------------
// The code below is for this example only.
// For your own calendar, you would edit the calendar.tsx component directly.
// ----------------------------------------------------------------------------
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps & {
buttonVariant?: React.ComponentProps["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months,
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root,
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label,
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header,
),
week_number: cn(
"select-none text-[0.8rem] text-muted-foreground",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
);
}
if (orientation === "right") {
return (
);
}
return (
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
{children}
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
```
## Selected Date (With TimeZone)
The Calendar component accepts a `timeZone` prop to ensure dates are displayed and selected in the user's local timezone.
```tsx showLineNumbers
export function CalendarWithTimezone() {
const [date, setDate] = React.useState(undefined)
const [timeZone, setTimeZone] = React.useState(undefined)
React.useEffect(() => {
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
}, [])
return (
)
}
```
**Note:** If you notice a selected date offset (for example, selecting the 20th highlights the 19th), make sure the `timeZone` prop is set to the user's local timezone.
**Why client-side?** The timezone is detected using `Intl.DateTimeFormat().resolvedOptions().timeZone` inside a `useEffect` to ensure compatibility with server-side rendering. Detecting the timezone during render would cause hydration mismatches, as the server and client may be in different timezones.
## Basic
A basic calendar component. We used `className="rounded-lg border"` to style the calendar.
### Example: calendar-basic
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
export default function CalendarBasic() {
return ;
}
```
## Range Calendar
Use the `mode="range"` prop to enable range selection.
### Example: calendar-range
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
import { addDays } from "date-fns";
import * as React from "react";
import type { DateRange } from "react-day-picker";
export function CalendarRange() {
const [dateRange, setDateRange] = React.useState({
from: new Date(2025, 0, 12),
to: addDays(new Date(2025, 0, 12), 30),
});
return (
);
}
export default CalendarRange;
```
## Month and Year Selector
Use `captionLayout="dropdown"` to show month and year dropdowns.
### Example: calendar-caption
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
export function CalendarCaption() {
return (
);
}
export default CalendarCaption;
```
## Presets
### Example: calendar-presets
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Calendar } from "@workspace/ui/components/calendar";
import { Card, CardContent, CardFooter } from "@workspace/ui/components/card";
import { addDays } from "date-fns";
import * as React from "react";
export function CalendarWithPresets() {
const [date, setDate] = React.useState(
new Date(2025, 1, 12),
);
const [currentMonth, setCurrentMonth] = React.useState(
new Date(
new Date(2025, 5, 12).getFullYear(),
new Date(2025, 5, 12).getMonth(),
1,
),
);
return (
{[
{ label: "Today", value: 0 },
{ label: "Tomorrow", value: 1 },
{ label: "In 3 days", value: 3 },
{ label: "In a week", value: 7 },
{ label: "In 2 weeks", value: 14 },
].map((preset) => (
{
const newDate = addDays(new Date(2025, 5, 12), preset.value);
setDate(newDate);
setCurrentMonth(
new Date(newDate.getFullYear(), newDate.getMonth(), 1),
);
}}
>
{preset.label}
))}
);
}
export default CalendarWithPresets;
```
## Date and Time Picker
### Example: calendar-time
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
import { Card, CardContent, CardFooter } from "@workspace/ui/components/card";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { Clock2Icon } from "lucide-react";
import * as React from "react";
import { useId as usePreviewId } from "react";
export function CalendarWithTime() {
const previewId = usePreviewId();
const [date, setDate] = React.useState(
new Date(
new Date(2025, 5, 12).getFullYear(),
new Date(2025, 5, 12).getMonth(),
12,
),
);
return (
Start Time
End Time
);
}
export default CalendarWithTime;
```
## Booked dates
### Example: calendar-booked-dates
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
import { Card, CardContent } from "@workspace/ui/components/card";
import * as React from "react";
export function CalendarBookedDates() {
const [date, setDate] = React.useState(
new Date(2025, 0, 6),
);
const bookedDates = Array.from(
{ length: 15 },
(_, i) => new Date(2025, 0, 12 + i),
);
return (
button]:line-through opacity-100",
}}
/>
);
}
export default CalendarBookedDates;
```
## Custom Cell Size
### Example: calendar-custom-days
```tsx
"use client";
import { Calendar, CalendarDayButton } from "@workspace/ui/components/calendar";
import { Card, CardContent } from "@workspace/ui/components/card";
import { addDays } from "date-fns";
import * as React from "react";
import type { DateRange } from "react-day-picker";
export function CalendarCustomDays() {
const [range, setRange] = React.useState({
from: new Date(2025, 11, 8),
to: addDays(new Date(2025, 11, 8), 10),
});
return (
{
return date.toLocaleString("default", { month: "long" });
},
}}
components={{
DayButton: ({ children, modifiers, day, ...props }) => {
const isWeekend =
day.date.getDay() === 0 || day.date.getDay() === 6;
return (
{children}
{!modifiers.outside && (
{isWeekend ? "$120" : "$100"}
)}
);
},
}}
/>
);
}
export default CalendarCustomDays;
```
You can customize the size of calendar cells using the `--cell-size` CSS variable. You can also make it responsive by using breakpoint-specific values:
```tsx showLineNumbers
```
Or use fixed values:
```tsx showLineNumbers
```
## Week Numbers
Use `showWeekNumber` to show week numbers.
### Example: calendar-week-numbers
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
import { Card, CardContent } from "@workspace/ui/components/card";
import * as React from "react";
export function CalendarWeekNumbers() {
const [date, setDate] = React.useState(
new Date(2025, 0, 12),
);
return (
);
}
export default CalendarWeekNumbers;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
See also the [Hijri Guide](#persian--hijri--jalali-calendar) for enabling the Persian / Hijri / Jalali calendar.
### Example: calendar-rtl
```tsx
"use client";
import { Calendar } from "@workspace/ui/components/calendar";
import * as React from "react";
import { arSA, he } from "react-day-picker/locale";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {},
},
ar: {
dir: "rtl",
values: {},
},
he: {
dir: "rtl",
values: {},
},
};
const locales = {
ar: arSA,
he: he,
} as const;
export function CalendarRtl() {
const { dir, language } = useTranslation(translations, "ar");
const [date, setDate] = React.useState(
new Date(2025, 5, 12),
);
return (
);
}
export default CalendarRtl;
```
When using RTL, import the locale from `react-day-picker/locale` and pass both the `locale` and `dir` props to the Calendar component:
```tsx showLineNumbers
import { arSA } from "react-day-picker/locale"
;
```
## API Reference
See the [React DayPicker](https://react-day-picker.js.org) documentation for more information on the `Calendar` component.
## Changelog
### RTL Support
If you're upgrading from a previous version of the `Calendar` component, you'll need to apply the following updates to add locale support:
### Import the `Locale` type.
Add `Locale` to your imports from `react-day-picker`:
```diff
import {
DayPicker,
getDefaultClassNames,
type DayButton,
+ type Locale,
} from "react-day-picker"
```
### Add `locale` prop to the Calendar component.
Add the `locale` prop to the component's props:
```diff
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
+ locale,
formatters,
components,
...props
}: React.ComponentProps & {
buttonVariant?: React.ComponentProps["variant"]
}) {
```
### Pass `locale` to DayPicker.
Pass the `locale` prop to the `DayPicker` component:
```diff
- date.toLocaleString("default", { month: "short" }),
+ date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
```
### Update CalendarDayButton to accept locale.
Update the `CalendarDayButton` component signature and pass `locale`:
```diff
function CalendarDayButton({
className,
day,
modifiers,
+ locale,
...props
- }: React.ComponentProps) {
+ }: React.ComponentProps & { locale?: Partial }) {
```
### Update date formatting in CalendarDayButton.
Use `locale?.code` in the date formatting:
```diff
```
### Pass locale to DayButton component.
Update the `DayButton` component usage to pass the `locale` prop:
```diff
components={{
...
- DayButton: CalendarDayButton,
+ DayButton: ({ ...props }) => (
+
+ ),
...
}}
```
### Update RTL-aware CSS classes.
Replace directional classes with logical properties for better RTL support:
```diff
// In the day classNames:
- [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)
+ [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)
- [&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)
- [&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)
// In range_start classNames:
- rounded-l-(--cell-radius) ... after:right-0
+ rounded-s-(--cell-radius) ... after:end-0
// In range_end classNames:
- rounded-r-(--cell-radius) ... after:left-0
+ rounded-e-(--cell-radius) ... after:start-0
// In CalendarDayButton className:
- data-[range-end=true]:rounded-r-(--cell-radius)
+ data-[range-end=true]:rounded-e-(--cell-radius)
- data-[range-start=true]:rounded-l-(--cell-radius)
+ data-[range-start=true]:rounded-s-(--cell-radius)
```
After applying these changes, you can use the `locale` prop to provide locale-specific formatting:
```tsx
import { enUS } from "react-day-picker/locale"
;
```
- [Documentation](https://react-day-picker.js.org)
---
# Card
Displays a card with header, content, and footer.
Page: https://sui.draco.dev/docs/components/card
### Example: card-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
export default function CardDemo() {
const previewId = usePreviewId();
return (
Login to your account
Enter your email below to login to your account
Sign Up
Login
Login with Google
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/card
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card"
```
```tsx showLineNumbers
Card Title
Card Description
Card Action
Card Content
Card Footer
```
## Composition
Use the following composition to build a `Card`:
```text
Card
├── CardHeader
│ ├── CardTitle
│ ├── CardDescription
│ └── CardAction
├── CardContent
└── CardFooter
```
## Size
Use the `size="sm"` prop to set the size of the card to small. The small size variant uses smaller spacing.
### Example: card-small
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { ChevronRightIcon } from "lucide-react";
export function CardSmall() {
const featureName = "Scheduled reports";
return (
{featureName}
Weekly snapshots. No more manual exports.
Choose a schedule (daily, or weekly).
Send to channels or specific teammates.
Include charts, tables, and key metrics.
Set up scheduled reports
See what's new
);
}
export default CardSmall;
```
## Spacing
In addition to the `size` prop, you can use the `--card-spacing` CSS variable to control the spacing between sections and the inset of card parts.
### Example: card-spacing
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import * as React from "react";
import { useId as usePreviewId } from "react";
const spacingOptions = [
{
className: "[--card-spacing:--spacing(4)]",
label: "16px",
value: "4",
},
{
className: "[--card-spacing:--spacing(5)]",
label: "20px",
value: "5",
},
{
className: "[--card-spacing:--spacing(6)]",
label: "24px",
value: "6",
},
{
className: "[--card-spacing:--spacing(8)]",
label: "32px",
value: "8",
},
];
export function CardSpacing() {
const previewId = usePreviewId();
const [spacing, setSpacing] = React.useState("4");
const selectedSpacing = spacingOptions.find(
(option) => option.value === spacing,
);
return (
{
if (value[0]) {
setSpacing(value[0]);
}
}}
variant="outline"
size="sm"
className="justify-center"
>
{spacingOptions.map((option) => (
{option.label}
))}
Login to your account
Enter your email below to login to your account
Sign Up
Login
Login with Google
);
}
export default CardSpacing;
```
Use negative margins with `-mx-(--card-spacing)` to make content go edge to edge while keeping it aligned with the card inset. When the edge-to-edge content sits above a footer, use `-mb-(--card-spacing)` on `CardContent` to remove the section gap.
### Example: card-edge-to-edge
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
export function CardEdgeToEdge() {
return (
Terms of Service
Review the terms before accepting the agreement.
These terms govern your use of the workspace, including access to
shared documents, project files, and collaboration tools.
You are responsible for the content you upload and for ensuring that
your team has the appropriate permissions to view or edit it.
We may update features or limits as the service evolves. When those
changes materially affect your workflow, we will notify your
workspace administrators.
By continuing, you agree to keep your account credentials secure and
to follow your organization's acceptable use policies.
Decline
Accept
);
}
export default CardEdgeToEdge;
```
## Image
Add an image before the card header to create a card with an image.
### Example: card-image
```tsx
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
export function CardImage() {
return (
Featured
Design systems meetup
A practical talk on component APIs, accessibility, and shipping
faster.
View Event
);
}
export default CardImage;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: card-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
title: "Login to your account",
description: "Enter your email below to login to your account",
signUp: "Sign Up",
email: "Email",
emailPlaceholder: "m@example.com",
password: "Password",
forgotPassword: "Forgot your password?",
login: "Login",
loginWithGoogle: "Login with Google",
},
},
ar: {
dir: "rtl",
values: {
title: "تسجيل الدخول إلى حسابك",
description: "أدخل بريدك الإلكتروني أدناه لتسجيل الدخول إلى حسابك",
signUp: "إنشاء حساب",
email: "البريد الإلكتروني",
emailPlaceholder: "m@example.com",
password: "كلمة المرور",
forgotPassword: "نسيت كلمة المرور؟",
login: "تسجيل الدخول",
loginWithGoogle: "تسجيل الدخول باستخدام Google",
},
},
he: {
dir: "rtl",
values: {
title: "התחבר לחשבון שלך",
description: "הזן את האימייל שלך למטה כדי להתחבר לחשבון שלך",
signUp: "הירשם",
email: "אימייל",
emailPlaceholder: "m@example.com",
password: "סיסמה",
forgotPassword: "שכחת את הסיסמה?",
login: "התחבר",
loginWithGoogle: "התחבר עם Google",
},
},
};
export function CardRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.title}
{t.description}
{t.signUp}
{t.login}
{t.loginWithGoogle}
);
}
export default CardRtl;
```
## API Reference
### Card
The `Card` component is the root container for card content.
| Prop | Type | Default |
| ----------- | ------------------- | ----------- |
| `size` | `"default" \| "sm"` | `"default"` |
| `className` | `string` | - |
### CardHeader
The `CardHeader` component is used for a title, description, and optional action.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### CardTitle
The `CardTitle` component is used for the card title.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### CardDescription
The `CardDescription` component is used for helper text under the title.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### CardAction
The `CardAction` component places content in the top-right of the header (for example, a button or a badge).
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### CardContent
The `CardContent` component is used for the main card body.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
### CardFooter
The `CardFooter` component is used for actions and secondary content at the bottom of the card.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | - |
## Changelog
### Spacing Variable
If you're upgrading from a previous version of the `Card` component, you'll need to apply the following updates to use the `--card-spacing` variable:
### Update the Card root spacing classes.
Replace the hard-coded gap and vertical padding with `--card-spacing`, and set the default and small size values on the root:
```diff
className={cn(
- "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
```
### Update CardHeader spacing classes.
Replace the horizontal padding and border spacing with the shared variable:
```diff
className={cn(
- "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
```
### Update CardContent and CardFooter spacing classes.
Use `--card-spacing` for the content inset and footer padding:
```diff
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
```
```diff
className={cn(
- "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
```
After applying these changes, you can customize card spacing by setting `--card-spacing` on the `Card` with an arbitrary property class:
```tsx
function Example() {
return ...
}
```
---
# Carousel
A carousel with motion and swipe built using Embla.
Page: https://sui.draco.dev/docs/components/carousel
### Example: carousel-demo
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
export default function CarouselDemo() {
return (
{Array.from({ length: 5 }).map((_, index) => (
{index + 1}
))}
);
}
```
## About
The carousel component is built using the [Embla Carousel](https://www.embla-carousel.com/) library.
## Installation
```bash
bunx --bun shadcn@latest add @sui/carousel
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel"
```
```tsx showLineNumbers
...
...
...
```
## Composition
Use the following composition to build a `Carousel`:
```text
Carousel
├── CarouselContent
│ ├── CarouselItem
│ └── CarouselItem
├── CarouselPrevious
└── CarouselNext
```
## Sizes
To set the size of the items, you can use the `basis` utility class on the ` `.
### Example: carousel-size
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
export default function CarouselSize() {
return (
{Array.from({ length: 5 }).map((_, index) => (
{index + 1}
))}
);
}
```
```tsx showLineNumbers {4-6}
// 33% of the carousel width.
...
...
...
```
```tsx showLineNumbers {4-6}
// 50% on small screens and 33% on larger screens.
...
...
...
```
## Spacing
To set the spacing between the items, we use a `pl-[VALUE]` utility on the ` ` and a negative `-ml-[VALUE]` on the ` `.
### Example: carousel-spacing
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
export default function CarouselSpacing() {
return (
{Array.from({ length: 5 }).map((_, index) => (
{index + 1}
))}
);
}
```
```tsx showLineNumbers /-ml-4/ /pl-4/
...
...
...
```
```tsx showLineNumbers /-ml-2/ /pl-2/ /md:-ml-4/ /md:pl-4/
...
...
...
```
## Orientation
Use the `orientation` prop to set the orientation of the carousel.
### Example: carousel-orientation
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
export default function CarouselOrientation() {
return (
{Array.from({ length: 5 }).map((_, index) => (
{index + 1}
))}
);
}
```
```tsx showLineNumbers /vertical | horizontal/
...
...
...
```
## Options
You can pass options to the carousel using the `opts` prop. See the [Embla Carousel docs](https://www.embla-carousel.com/api/options/) for more information.
```tsx showLineNumbers {2-5}
...
...
...
```
## API
Use a state and the `setApi` prop to get an instance of the carousel API.
### Example: carousel-api
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
"use client";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
import * as React from "react";
export default function CarouselDApiDemo() {
const [api, setApi] = React.useState();
const [current, setCurrent] = React.useState(0);
const [count, setCount] = React.useState(0);
React.useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
{Array.from({ length: 5 }).map((_, index) => (
{index + 1}
))}
Slide {current} of {count}
);
}
```
```tsx showLineNumbers {1,4,22}
import { type CarouselApi } from "@workspace/ui/components/carousel"
export function Example() {
const [api, setApi] = React.useState()
const [current, setCurrent] = React.useState(0)
const [count, setCount] = React.useState(0)
React.useEffect(() => {
if (!api) {
return
}
setCount(api.scrollSnapList().length)
setCurrent(api.selectedScrollSnap() + 1)
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1)
})
}, [api])
return (
...
...
...
)
}
```
## Events
You can listen to events using the api instance from `setApi`.
```tsx showLineNumbers {1,4-14,16}
import { type CarouselApi } from "@workspace/ui/components/carousel"
export function Example() {
const [api, setApi] = React.useState()
React.useEffect(() => {
if (!api) {
return
}
api.on("select", () => {
// Do something on select.
})
}, [api])
return (
...
...
...
)
}
```
See the [Embla Carousel docs](https://www.embla-carousel.com/api/events/) for more information on using events.
## Plugins
You can use the `plugins` prop to add plugins to the carousel.
```ts showLineNumbers {1,6-10}
import Autoplay from "embla-carousel-autoplay"
export function Example() {
return (
// ...
)
}
```
### Example: carousel-plugin
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
"use client";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
import Autoplay from "embla-carousel-autoplay";
import * as React from "react";
export default function CarouselPlugin() {
const plugin = React.useRef(
Autoplay({ delay: 2000, stopOnInteraction: true }),
);
return (
{Array.from({ length: 5 }).map((_, index) => (
{index + 1}
))}
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: carousel-rtl
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
"use client";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@workspace/ui/components/carousel";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {},
},
ar: {
dir: "rtl",
values: {},
},
he: {
dir: "rtl",
values: {},
},
};
function toArabicNumerals(num: number): string {
const arabicNumerals = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"];
return num
.toString()
.split("")
.map((digit) => arabicNumerals[parseInt(digit, 10)])
.join("");
}
export function CarouselRtl() {
const { dir, language } = useTranslation(translations, "ar");
const formatNumber = (num: number): string => {
if (language === "ar") {
return toArabicNumerals(num);
}
return num.toString();
};
return (
{Array.from({ length: 5 }).map((_, index) => (
{formatNumber(index + 1)}
))}
);
}
export default CarouselRtl;
```
When localizing the carousel for RTL languages, you need to set the `direction` option in the `opts` prop to match the text direction. This ensures the carousel scrolls in the correct direction.
```tsx showLineNumbers {2-5}
...
...
...
```
The `direction` option accepts `"ltr"` or `"rtl"` and should match the `dir` prop value. You may also want to rotate the navigation buttons using the `rtl:rotate-180` class to ensure they point in the correct direction.
## API Reference
See the [Embla Carousel docs](https://www.embla-carousel.com/api/) for more information on props and plugins.
- [Documentation](https://www.embla-carousel.com/get-started/react)
- [API reference](https://www.embla-carousel.com/api)
---
# Chart
Beautiful charts. Built using Recharts. Copy and paste into your apps.
Page: https://sui.draco.dev/docs/components/chart
**Updated:** The `chart` component now uses Recharts v3. If you're upgrading existing chart code, see [Updating to Recharts v3](#updating-to-recharts-v3).
### Example: chart-demo
```tsx
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import * as React from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
export const description = "An interactive bar chart";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
];
const chartConfig = {
views: {
label: "Page Views",
},
desktop: {
label: "Desktop",
color: "var(--chart-2)",
},
mobile: {
label: "Mobile",
color: "var(--chart-1)",
},
} satisfies ChartConfig;
export function ChartDemo() {
const [activeChart, setActiveChart] =
React.useState("desktop");
const total = React.useMemo(
() => ({
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
}),
[],
);
return (
Bar Chart - Interactive
Showing total visitors for the last 3 months
{["desktop", "mobile"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
setActiveChart(chart)}
>
{chartConfig[chart].label}
{total[key as keyof typeof total].toLocaleString()}
);
})}
{
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
{
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}}
/>
}
/>
);
}
export default ChartDemo;
```
Introducing **Charts**. A collection of chart components that you can copy and paste into your apps.
Charts are designed to look great out of the box. They work well with the other components and are fully customizable to fit your project.
[Browse the Charts Library](https://ui.shadcn.com/charts).
## Component
We use [Recharts](https://recharts.org/) under the hood.
We designed the `chart` component with composition in mind. **You build your charts using Recharts components and only bring in custom components, such as `ChartTooltip`, when and where you need it**.
```tsx showLineNumbers /ChartContainer/ /ChartTooltipContent/
import { Bar, BarChart } from "recharts"
import { ChartContainer, ChartTooltipContent } from "@workspace/ui/components/chart"
export function MyChart() {
return (
} />
)
}
```
We do not wrap Recharts. This means you're not locked into an abstraction. When a new Recharts version is released, you can follow the official upgrade path to upgrade your charts.
**The components are yours**.
## Updating to Recharts v3
If you're updating older chart code to Recharts v3:
- Use `var(--chart-1)` instead of `hsl(var(--chart-1))` when you reference chart tokens from your CSS variables.
- Use `ChartTooltip.defaultIndex` for initial tooltip state only. Keep persistent active shapes in your own chart state.
- Remove `layout` from `` when the parent `` already defines it.
- Keep a height, `min-h-*`, or `aspect-*` on `ChartContainer` so `ResponsiveContainer` can measure on first render.
## Installation
```bash
bunx --bun shadcn@latest add @sui/chart
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Your First Chart
Let's build your first chart. We'll build a bar chart, add a grid, axis, tooltip and legend.
### Start by defining your data
The following data represents the number of desktop and mobile users for each month.
**Note:** Your data can be in any shape. You are not limited to the shape of the data below. Use the `dataKey` prop to map your data to the chart.
```tsx title="components/example-chart.tsx" showLineNumbers
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
```
### Define your chart config
The chart config holds configuration for the chart. This is where you place human-readable strings, such as labels, icons and color tokens for theming.
```tsx title="components/example-chart.tsx" showLineNumbers
import { type ChartConfig } from "@workspace/ui/components/chart"
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "#60a5fa",
},
} satisfies ChartConfig
```
### Build your chart
You can now build your chart using Recharts components.
**Important:** Remember to set a `min-h-[VALUE]` on the `ChartContainer` component. This is required for the chart to be responsive.
### Example: chart-example
```tsx
"use client";
import {
type ChartConfig,
ChartContainer,
} from "@workspace/ui/components/chart";
import { Bar, BarChart } from "recharts";
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "#60a5fa",
},
} satisfies ChartConfig;
export function ChartExample() {
return (
);
}
export default ChartExample;
```
### Add a Grid
Let's add a grid to the chart.
### Import the `CartesianGrid` component.
```tsx /CartesianGrid/
import { Bar, BarChart, CartesianGrid } from "recharts"
```
### Add the `CartesianGrid` component to your chart.
```tsx showLineNumbers {3}
```
### Example: chart-example-grid
```tsx
"use client";
import {
type ChartConfig,
ChartContainer,
} from "@workspace/ui/components/chart";
import { Bar, BarChart, CartesianGrid } from "recharts";
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "#60a5fa",
},
} satisfies ChartConfig;
export function ChartBarDemoGrid() {
return (
);
}
export default ChartBarDemoGrid;
```
### Add an Axis
To add an x-axis to the chart, we'll use the `XAxis` component.
### Import the `XAxis` component.
```tsx /XAxis/
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
```
### Add the `XAxis` component to your chart.
```tsx showLineNumbers {4-10}
value.slice(0, 3)}
/>
```
### Example: chart-example-axis
```tsx
"use client";
import {
type ChartConfig,
ChartContainer,
} from "@workspace/ui/components/chart";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "#60a5fa",
},
} satisfies ChartConfig;
export function ChartBarDemoAxis() {
return (
value.slice(0, 3)}
/>
);
}
export default ChartBarDemoAxis;
```
### Add Tooltip
So far we've only used components from Recharts. They look great out of the box thanks to some customization in the `chart` component.
To add a tooltip, we'll use the custom `ChartTooltip` and `ChartTooltipContent` components from `chart`.
### Import the `ChartTooltip` and `ChartTooltipContent` components.
```tsx
import { ChartTooltip, ChartTooltipContent } from "@workspace/ui/components/chart"
```
### Add the components to your chart.
```tsx showLineNumbers {11}
value.slice(0, 3)}
/>
} />
```
### Example: chart-example-tooltip
```tsx
"use client";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "#60a5fa",
},
} satisfies ChartConfig;
export function ChartBarDemoTooltip() {
return (
value.slice(0, 3)}
/>
} />
);
}
export default ChartBarDemoTooltip;
```
Hover to see the tooltips. Easy, right? Two components, and we've got a beautiful tooltip.
### Add Legend
We'll do the same for the legend. We'll use the `ChartLegend` and `ChartLegendContent` components from `chart`.
### Import the `ChartLegend` and `ChartLegendContent` components.
```tsx
import { ChartLegend, ChartLegendContent } from "@workspace/ui/components/chart"
```
### Add the components to your chart.
```tsx showLineNumbers {12}
value.slice(0, 3)}
/>
} />
} />
```
### Example: chart-example-legend
```tsx
"use client";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "#60a5fa",
},
} satisfies ChartConfig;
export function ChartBarDemoLegend() {
return (
value.slice(0, 3)}
/>
} />
} />
);
}
export default ChartBarDemoLegend;
```
Done. You've built your first chart! What's next?
- [Themes and Colors](/docs/components/chart#theming)
- [Tooltip](/docs/components/chart#tooltip)
- [Legend](/docs/components/chart#legend)
## Chart Config
The chart config is where you define the labels, icons and colors for a chart.
It is intentionally decoupled from chart data.
This allows you to share config and color tokens between charts. It can also work independently for cases where your data or color tokens live remotely or in a different format.
```tsx showLineNumbers /ChartConfig/
import { Monitor } from "lucide-react"
import { type ChartConfig } from "@workspace/ui/components/chart"
const chartConfig = {
desktop: {
label: "Desktop",
icon: Monitor,
// A color like 'hsl(220, 98%, 61%)' or 'var(--color-name)'
color: "#2563eb",
// OR a theme object with 'light' and 'dark' keys
theme: {
light: "#2563eb",
dark: "#dc2626",
},
},
} satisfies ChartConfig
```
## Theming
Charts have built-in support for theming. You can use css variables (recommended) or color values in any color format, such as hex, hsl or oklch.
### CSS Variables
### Define your colors in your css file
```css title="app/globals.css" showLineNumbers
@layer base {
:root {
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
}
.dark {
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
}
}
```
### Add the color to your `chartConfig`
```tsx title="components/example-chart.tsx" showLineNumbers
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--chart-1)",
},
mobile: {
label: "Mobile",
color: "var(--chart-2)",
},
} satisfies ChartConfig
```
### hex, hsl or oklch
You can also define your colors directly in the chart config. Use the color format you prefer.
```tsx title="components/example-chart.tsx" showLineNumbers
const chartConfig = {
desktop: {
label: "Desktop",
color: "#2563eb",
},
mobile: {
label: "Mobile",
color: "hsl(220, 98%, 61%)",
},
tablet: {
label: "Tablet",
color: "oklch(0.5 0.2 240)",
},
laptop: {
label: "Laptop",
color: "var(--chart-2)",
},
} satisfies ChartConfig
```
### Using Colors
To use the theme colors in your chart, reference the colors using the format `var(--color-KEY)`.
#### Components
```tsx
```
#### Chart Data
```tsx title="components/example-chart.tsx" showLineNumbers
const chartData = [
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
]
```
#### Tailwind
```tsx title="components/example-chart.tsx"
```
## Tooltip
A chart tooltip contains a label, name, indicator and value. You can use a combination of these to customize your tooltip.
### Example: chart-tooltip
```tsx
"use client";
import { cn } from "cn";
import type * as React from "react";
import { useId as usePreviewId } from "react";
export function ChartTooltipDemo() {
const previewId = usePreviewId();
return (
);
}
function TooltipDemo({
indicator = "dot",
label,
payload,
hideLabel,
hideIndicator,
className,
}: {
label: string;
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
payload: {
name: string;
value: number;
fill: string;
}[];
nameKey?: string;
labelKey?: string;
} & React.ComponentProps<"div">) {
const tooltipLabel = hideLabel ? null : (
{label}
);
if (!payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
{!nestLabel ? tooltipLabel : null}
{payload.map((item) => {
const indicatorColor = item.fill;
return (
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{!hideIndicator && (
)}
{nestLabel ? tooltipLabel : null}
{item.name}
{item.value.toLocaleString()}
);
})}
);
}
export default ChartTooltipDemo;
```
You can turn on/off any of these using the `hideLabel`, `hideIndicator` props and customize the indicator style using the `indicator` prop.
Use `labelKey` and `nameKey` to use a custom key for the tooltip label and name.
Chart comes with the `` and `` components. You can use these two components to add custom tooltips to your chart.
```tsx title="components/example-chart.tsx"
import { ChartTooltip, ChartTooltipContent } from "@workspace/ui/components/chart"
```
```tsx title="components/example-chart.tsx"
} />
```
### Props
Use the following props to customize the tooltip.
| Prop | Type | Description |
| :-------------- | :----------------------- | :------------------------------------------- |
| `labelKey` | string | The config or data key to use for the label. |
| `nameKey` | string | The config or data key to use for the name. |
| `indicator` | `dot` `line` or `dashed` | The indicator style for the tooltip. |
| `hideLabel` | boolean | Whether to hide the label. |
| `hideIndicator` | boolean | Whether to hide the indicator. |
### Colors
Colors are automatically referenced from the chart config.
### Custom
To use a custom key for tooltip label and names, use the `labelKey` and `nameKey` props.
```tsx showLineNumbers /browser/
const chartData = [
{ browser: "chrome", visitors: 187, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
]
const chartConfig = {
visitors: {
label: "Total Visitors",
},
chrome: {
label: "Chrome",
color: "var(--chart-1)",
},
safari: {
label: "Safari",
color: "var(--chart-2)",
},
} satisfies ChartConfig
```
```tsx title="components/example-chart.tsx"
}
/>
```
This will use `Total Visitors` for label and `Chrome` and `Safari` for the tooltip names.
## Legend
You can use the custom `` and `` components to add a legend to your chart.
```tsx title="components/example-chart.tsx"
import { ChartLegend, ChartLegendContent } from "@workspace/ui/components/chart"
```
```tsx title="components/example-chart.tsx"
} />
```
### Colors
Colors are automatically referenced from the chart config.
### Custom
To use a custom key for legend names, use the `nameKey` prop.
```tsx showLineNumbers /browser/
const chartData = [
{ browser: "chrome", visitors: 187, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
]
const chartConfig = {
chrome: {
label: "Chrome",
color: "var(--chart-1)",
},
safari: {
label: "Safari",
color: "var(--chart-2)",
},
} satisfies ChartConfig
```
```tsx title="components/example-chart.tsx"
} />
```
This will use `Chrome` and `Safari` for the legend names.
## Accessibility
You can turn on the `accessibilityLayer` prop to add an accessible layer to your chart.
This prop adds keyboard access and screen reader support to your charts.
```tsx title="components/example-chart.tsx"
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: chart-rtl
```tsx
"use client";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
january: "January",
february: "February",
march: "March",
april: "April",
may: "May",
june: "June",
desktop: "Desktop",
mobile: "Mobile",
},
},
ar: {
dir: "rtl",
values: {
january: "يناير",
february: "فبراير",
march: "مارس",
april: "أبريل",
may: "مايو",
june: "يونيو",
desktop: "سطح المكتب",
mobile: "الجوال",
},
},
he: {
dir: "rtl",
values: {
january: "ינואר",
february: "פברואר",
march: "מרץ",
april: "אפריל",
may: "מאי",
june: "יוני",
desktop: "מחשב",
mobile: "נייד",
},
},
};
const chartData = [
{ month: "january", desktop: 186, mobile: 80 },
{ month: "february", desktop: 305, mobile: 200 },
{ month: "march", desktop: 237, mobile: 120 },
{ month: "april", desktop: 73, mobile: 190 },
{ month: "may", desktop: 209, mobile: 130 },
{ month: "june", desktop: 214, mobile: 140 },
];
export function ChartRtl() {
const { t, dir } = useTranslation(translations, "ar");
const chartConfig = {
desktop: {
label: t.desktop,
color: "var(--chart-2)",
},
mobile: {
label: t.mobile,
color: "var(--chart-1)",
},
} satisfies ChartConfig;
return (
(t[value as keyof typeof t] as string).slice(0, 3)
}
reversed={dir === "rtl"}
/>
t[value as keyof typeof t] as string}
/>
}
labelClassName="w-32"
/>
} />
);
}
export default ChartRtl;
```
---
# Checkbox
A control that allows the user to toggle between checked and not checked.
Page: https://sui.draco.dev/docs/components/checkbox
### Example: checkbox-demo
```tsx
"use client";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@workspace/ui/components/field";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
export default function CheckboxDemo() {
const previewId = usePreviewId();
return (
Accept terms and conditions
Accept terms and conditions
By clicking this checkbox, you agree to the terms.
Enable notifications
Enable notifications
You can enable or disable notifications at any time.
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/checkbox
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox"
```
```tsx
```
## Checked State
Use `defaultChecked` for uncontrolled checkboxes, or `checked` and
`onCheckedChange` to control the state.
```tsx showLineNumbers
import * as React from "react"
export function Example() {
const [checked, setChecked] = React.useState(false)
return
}
```
## Invalid State
Set `aria-invalid` on the checkbox and `data-invalid` on the field wrapper to
show the invalid styles.
### Example: checkbox-invalid
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export function CheckboxInvalid() {
const previewId = usePreviewId();
return (
Accept terms and conditions
);
}
export default CheckboxInvalid;
```
## Basic
Pair the checkbox with `Field` and `FieldLabel` for proper layout and labeling.
### Example: checkbox-basic
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export function CheckboxBasic() {
const previewId = usePreviewId();
return (
Accept terms and conditions
);
}
export default CheckboxBasic;
```
## Description
Use `FieldContent` and `FieldDescription` for helper text.
### Example: checkbox-description
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export function CheckboxDescription() {
const previewId = usePreviewId();
return (
Accept terms and conditions
By clicking this checkbox, you agree to the terms and conditions.
);
}
export default CheckboxDescription;
```
## Disabled
Use the `disabled` prop to prevent interaction and add the `data-disabled` attribute to the `` component for disabled styles.
### Example: checkbox-disabled
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export function CheckboxDisabled() {
const previewId = usePreviewId();
return (
Enable notifications
);
}
export default CheckboxDisabled;
```
## Group
Use multiple fields to create a checkbox list.
### Example: checkbox-group
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export function CheckboxGroup() {
const previewId = usePreviewId();
return (
Show these items on the desktop:
Select the items you want to show on the desktop.
Hard disks
External disks
CDs, DVDs, and iPods
Connected servers
);
}
export default CheckboxGroup;
```
## Table
### Example: checkbox-table
```tsx
"use client";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import * as React from "react";
import { useId as usePreviewId } from "react";
const tableData = [
{
id: "1",
name: "Sarah Chen",
email: "sarah.chen@example.com",
role: "Admin",
},
{
id: "2",
name: "Marcus Rodriguez",
email: "marcus.rodriguez@example.com",
role: "User",
},
{
id: "3",
name: "Priya Patel",
email: "priya.patel@example.com",
role: "User",
},
{
id: "4",
name: "David Kim",
email: "david.kim@example.com",
role: "Editor",
},
];
export function CheckboxInTable() {
const previewId = usePreviewId();
const [selectedRows, setSelectedRows] = React.useState>(
new Set(["1"]),
);
const selectAll = selectedRows.size === tableData.length;
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedRows(new Set(tableData.map((row) => row.id)));
} else {
setSelectedRows(new Set());
}
};
const handleSelectRow = (id: string, checked: boolean) => {
const newSelected = new Set(selectedRows);
if (checked) {
newSelected.add(id);
} else {
newSelected.delete(id);
}
setSelectedRows(newSelected);
};
return (
Name
Email
Role
{tableData.map((row) => (
handleSelectRow(row.id, checked === true)
}
/>
{row.name}
{row.email}
{row.role}
))}
);
}
export default CheckboxInTable;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: checkbox-rtl
```tsx
"use client";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@workspace/ui/components/field";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
acceptTerms: "Accept terms and conditions",
acceptTermsDescription:
"By clicking this checkbox, you agree to the terms.",
enableNotifications: "Enable notifications",
enableNotificationsDescription:
"You can enable or disable notifications at any time.",
},
},
ar: {
dir: "rtl",
values: {
acceptTerms: "قبول الشروط والأحكام",
acceptTermsDescription: "بالنقر على هذا المربع، فإنك توافق على الشروط.",
enableNotifications: "تفعيل الإشعارات",
enableNotificationsDescription:
"يمكنك تفعيل أو إلغاء تفعيل الإشعارات في أي وقت.",
},
},
he: {
dir: "rtl",
values: {
acceptTerms: "קבל תנאים והגבלות",
acceptTermsDescription:
"על ידי לחיצה על תיבת הסימון הזו, אתה מסכים לתנאים.",
enableNotifications: "הפעל התראות",
enableNotificationsDescription:
"אתה יכול להפעיל או להשבית התראות בכל עת.",
},
},
};
export function CheckboxRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.acceptTerms}
{t.acceptTerms}
{t.acceptTermsDescription}
{t.enableNotifications}
{t.enableNotifications}
{t.enableNotificationsDescription}
);
}
export default CheckboxRtl;
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/checkbox#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/checkbox)
- [API reference](https://base-ui.com/react/components/checkbox#api-reference)
---
# Clipboard Text
A selectable text field with a separate copy action and async feedback.
Page: https://sui.draco.dev/docs/components/clipboard-text
### Example: clipboard-text-demo
```tsx
import { ClipboardText } from "@workspace/ui/components/clipboard-text";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/clipboard-text
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
```tsx
import { ClipboardText } from "@workspace/ui/components/clipboard-text";
```
## Usage
```tsx
```
The displayed text remains selectable. A long value truncates visually; its full value is available on hover and the copy action always copies the complete value.
## Display and copy different values
Use `textToCopy` for a full address while showing a shorter label. `size` accepts `sm`, `default`, and `lg`.
### Example: clipboard-text-display-value
```tsx
import { ClipboardText } from "@workspace/ui/components/clipboard-text";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh
? "显示简短路径,复制完整地址;长文本可选择或悬停查看"
: "Show a short path while copying the full URL. Long text can be selected or inspected on hover."}
);
}
```
## Feedback and disabled state
`onCopy` receives the copied string only after the clipboard write succeeds. Handle `onCopyError` to provide a visible fallback. `labels` localizes the action and screen reader feedback. A pending write disables the copy button; a disabled field cannot start a write.
### Example: clipboard-text-feedback
```tsx
import { ClipboardText } from "@workspace/ui/components/clipboard-text";
import { Switch } from "@workspace/ui/components/switch";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [enabled, setEnabled] = useState(true);
const [notice, setNotice] = useState("");
const id = useId();
return (
{zh ? "允许复制" : "Enable copying"}
setNotice(zh ? "工作区 ID 已复制" : "Workspace ID copied.")
}
onCopyError={() =>
setNotice(
zh
? "浏览器无法访问剪贴板,请选择文本后手动复制"
: "Clipboard access failed. Select the text and copy it manually.",
)
}
labels={{
copy: zh ? "复制工作区 ID" : "Copy workspace ID",
copied: zh ? "已复制" : "Copied",
failed: zh
? "无法复制,请手动复制"
: "Copy unavailable. Copy manually.",
}}
/>
{notice}
);
}
```
The component uses the browser Clipboard API. If it is unavailable or permission is denied, it announces failure and calls `onCopyError`; users can select the text and copy it manually. It does not request clipboard access while rendering.
## API
| Prop | Type | Default |
| --- | --- | --- |
| `text` | `string` | Required |
| `textToCopy` | `string` | `text` |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |
| `disabled` | `boolean` | `false` |
| `resetDelay` | `number`, milliseconds | `1500` |
| `onCopy` | `(value: string) => void` | — |
| `onCopyError` | `(error: Error) => void` | — |
| `labels` | `{ copy?, pending?, copied?, failed? }` | English labels |
| `render` | React element or render function | `InputGroup` |
Standard root `div` props and refs are supported. Customize the root with `className` or Base UI's `render` composition. The copy control uses SUI [Input Group](/docs/components/input-group) and [Tooltip](/docs/components/tooltip); async state is exposed through `data-copy-status="idle|pending|copied|error"`. See the [Base UI composition API](https://base-ui.com/react/handbook/composition) for render and ref behavior.
---
# Code Viewer
Syntax-highlighted code with folding, line highlights, copying, and streaming feedback.
Page: https://sui.draco.dev/docs/components/code-viewer
### Example: code-viewer-demo
```tsx
"use client";
import { CodeViewer } from "@workspace/ui/components/code-viewer";
import type { ExampleProps } from "../types";
const code =
'const colors = ["bamboo", "mauve", "mist"];\n\nexport function getTheme(name: string) {\n if (colors.includes(name)) {\n return { name, active: true };\n }\n return { name: "default", active: false };\n}';
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/code-viewer
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { CodeViewer } from "@workspace/ui/components/code-viewer";
;
```
## Lines, folding, and layout
Line numbers are shown by default. `highlightLines` uses one-based line numbers. Indented blocks following `{`, `[`, or `(` can be folded with the gutter controls; changing `code` resets folding. This is an indentation-based view, not a syntax-aware code editor.
Set `wrap` for long lines or change `maxHeight` to limit the scrolling viewport. Use `showLineNumbers={false}` for compact snippets. The viewport is reachable with Tab and supports keyboard scrolling with a visible theme-colored focus ring.
## Streaming content
Pass `status="streaming"` while appending code, then switch to `"complete"`. The viewer displays status feedback and follows content near the bottom. Scrolling more than 32px away pauses following; returning to the bottom resumes it. Starting a new streaming session restores following by default. The example replays a local sequence and cleans up its timer on unmount.
### Example: code-viewer-streaming
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { CodeViewer } from "@workspace/ui/components/code-viewer";
import { useEffect, useState } from "react";
import type { ExampleProps } from "../types";
const lines = [
'const colors = ["bamboo", "mauve", "mist"];',
"",
"export function getTheme(name: string) {",
" if (colors.includes(name)) {",
" return { name, active: true };",
" }",
' return { name: "default", active: false };',
"}",
"",
"export const settings = {",
' theme: "bamboo",',
" palettes: [",
' "bamboo",',
' "mauve",',
' "mist",',
' "sand",',
' "pine",',
' "rose",',
" ],",
" layout: {",
' density: "comfortable",',
' width: "content",',
" },",
"};",
];
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [count, setCount] = useState(lines.length);
const streaming = count < lines.length;
useEffect(() => {
if (!streaming) return;
const timer = setTimeout(() => setCount(count + 1), 240);
return () => clearTimeout(timer);
}, [streaming, count]);
return (
setCount(1)}
>
{chinese ? "重新播放流式输入" : "Replay streaming input"}
);
}
```
## Copy and theme behavior
Copy writes the original complete code, including folded lines, and provides success or failure feedback. Set `copyable={false}` to remove the action. Highlighting loads asynchronously with a bounded skeleton; streaming updates keep incoming text visible while tokens are prepared. If loading is pending, the complete source remains available to copy and to assistive technology; if highlighting fails, readable plain code remains available. An empty string displays the empty-state label.
The viewer follows the surrounding light or dark theme unless `theme` is supplied. Translate `labels` for copy, loading, empty, folding, writing, ready, copied, and copy failure.
## Plain style and highlighting configuration
`variant="plain"` removes the outer frame and title bar while retaining the code and copy action. Override `--code-highlight-bg` to customize highlighted-line backgrounds.
An optional `ShikiProvider` shares highlighting configuration across its viewers and editors. `themes` provides light and dark themes, and `languages` preloads requested languages. Highlighting works without a provider; recognized languages and aliases load on demand, while unknown languages fall back to plain text.
### Example: code-viewer-plain
```tsx
"use client";
import {
CodeViewer,
ShikiProvider,
} from "@workspace/ui/components/code-viewer";
import type { ExampleProps } from "../types";
const themes = { light: "github-light", dark: "github-dark" } as const;
const languages = ["python", "sql"] as const;
const code =
'def greeting(name: str):\n return f"Hello, {name}"\n\nprint(greeting("SUI"))';
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
);
}
```
```tsx
import { CodeViewer, ShikiProvider } from "@workspace/ui/components/code-viewer";
;
```
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `variant` | `"default" \| "plain"` | `"default"`. |
| `code` | `string` | Required source text. |
| `lang` | `string` | `"typescript"`. |
| `theme` | `"light" \| "dark"` | Follows surrounding theme. |
| `title` | `ReactNode` | Optional header title. |
| `status` | `"streaming" \| "complete"` | `"complete"`. |
| `showLineNumbers` | `boolean` | `true`. |
| `highlightLines` | `number[]` | `[]`; one-based. |
| `maxHeight` | `number` | `280` pixels. |
| `wrap` | `boolean` | `false`. |
| `copyable` | `boolean` | `true`. |
| `labels` | `Partial` | English labels. |
| `glass` | `boolean` | `false`. |
The module exports `CodeViewerProps` and `CodeViewerLabels`. Highlighting uses [Shiki](https://shiki.style/guide/). To edit instead of view, use [Editor](/docs/components/editor).
`ShikiProviderProps` is also exported from this module; theme names use Shiki bundled themes.
### ShikiProvider
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `themes` | `{ light: BundledTheme; dark: BundledTheme }` | `one-light` and `one-dark-pro`. |
| `languages` | `readonly string[]` | Optional languages to preload. |
| `onError` | `(error: unknown) => void` | Reports preload failure without changing the plain-text fallback. |
Keep static `themes` and `languages` outside the component to avoid restarting preloads.
---
# Collapsible
An interactive component which expands/collapses a panel.
Page: https://sui.draco.dev/docs/components/collapsible
### Example: collapsible-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
export default function CollapsibleDemo() {
const [isOpen, setIsOpen] = React.useState(false);
return (
Order #4189
}
>
Toggle details
Status
Shipped
Shipping address
100 Market St, San Francisco
Items
2x Studio Headphones
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/collapsible
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible"
```
```tsx showLineNumbers
Can I use this in my project?
Yes. Free to use for personal and commercial projects. No attribution
required.
```
## Composition
Use the following composition to build a `Collapsible`:
```text
Collapsible
├── CollapsibleTrigger
└── CollapsibleContent
```
## Controlled State
Use the `open` and `onOpenChange` props to control the state.
```tsx showLineNumbers
import * as React from "react"
export function Example() {
const [open, setOpen] = React.useState(false)
return (
Toggle
Content
)
}
```
## Basic
### Example: collapsible-basic
```tsx
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import { ChevronDownIcon } from "lucide-react";
export function CollapsibleBasic() {
return (
}
>
Product details
This panel can be expanded or collapsed to reveal additional
content.
Learn More
);
}
export default CollapsibleBasic;
```
## Settings Panel
Use a trigger button to reveal additional settings.
### Example: collapsible-settings
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { MaximizeIcon, MinimizeIcon } from "lucide-react";
import * as React from "react";
import { useId as usePreviewId } from "react";
export function CollapsibleSettings() {
const previewId = usePreviewId();
const [isOpen, setIsOpen] = React.useState(false);
return (
Radius
Set the corner radius of the element.
Radius X
Radius Y
Radius X
Radius Y
}>
{isOpen ? : }
);
}
export default CollapsibleSettings;
```
## File Tree
Use nested collapsibles to build a file tree.
### Example: collapsible-file-tree
```tsx
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent, CardHeader } from "@workspace/ui/components/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react";
type FileTreeItem = { name: string } | { name: string; items: FileTreeItem[] };
export function CollapsibleFileTree() {
const fileTree: FileTreeItem[] = [
{
name: "components",
items: [
{
name: "ui",
items: [
{ name: "button.tsx" },
{ name: "card.tsx" },
{ name: "dialog.tsx" },
{ name: "input.tsx" },
{ name: "select.tsx" },
{ name: "table.tsx" },
],
},
{ name: "login-form.tsx" },
{ name: "register-form.tsx" },
],
},
{
name: "lib",
items: [{ name: "utils.ts" }, { name: "cn.ts" }, { name: "api.ts" }],
},
{
name: "hooks",
items: [
{ name: "use-media-query.ts" },
{ name: "use-debounce.ts" },
{ name: "use-local-storage.ts" },
],
},
{
name: "types",
items: [{ name: "index.d.ts" }, { name: "api.d.ts" }],
},
{
name: "public",
items: [
{ name: "favicon.ico" },
{ name: "logo.svg" },
{ name: "images" },
],
},
{ name: "app.tsx" },
{ name: "layout.tsx" },
{ name: "globals.css" },
{ name: "package.json" },
{ name: "tsconfig.json" },
{ name: "README.md" },
{ name: ".gitignore" },
];
const renderItem = (fileItem: FileTreeItem) => {
if ("items" in fileItem) {
return (
}
>
{fileItem.name}
{fileItem.items.map((child) => renderItem(child))}
);
}
return (
{fileItem.name}
);
};
return (
Explorer
Outline
{fileTree.map((item) => renderItem(item))}
);
}
export default CollapsibleFileTree;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: collapsible-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
orderNumber: "Order #4189",
status: "Status",
shipped: "Shipped",
shippingAddress: "Shipping address",
address: "100 Market St, San Francisco",
items: "Items",
itemsDescription: "2x Studio Headphones",
},
},
ar: {
dir: "rtl",
values: {
orderNumber: "الطلب #4189",
status: "الحالة",
shipped: "تم الشحن",
shippingAddress: "عنوان الشحن",
address: "100 Market St, San Francisco",
items: "العناصر",
itemsDescription: "2x سماعات الاستوديو",
},
},
he: {
dir: "rtl",
values: {
orderNumber: "הזמנה #4189",
status: "סטטוס",
shipped: "נשלח",
shippingAddress: "כתובת משלוח",
address: "100 Market St, San Francisco",
items: "פריטים",
itemsDescription: "2x אוזניות סטודיו",
},
},
};
export function CollapsibleRtl() {
const { dir, t } = useTranslation(translations, "ar");
const [isOpen, setIsOpen] = React.useState(false);
return (
{t.orderNumber}
}
>
Toggle details
{t.status}
{t.shipped}
{t.shippingAddress}
{t.address}
{t.items}
{t.itemsDescription}
);
}
export default CollapsibleRtl;
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/collapsible#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/collapsible)
- [API reference](https://base-ui.com/react/components/collapsible#api-reference)
---
# ColorPicker
Choose colors using a saturation area, hue and opacity sliders, editable values and preset swatches.
Page: https://sui.draco.dev/docs/components/color-picker
### Example: color-picker-demo
```tsx
"use client";
import { ColorPicker } from "@workspace/ui/components/color-picker";
import { useState } from "react";
import type { ExampleProps } from "../types";
export const chineseLabels = {
trigger: "选择颜色",
saturation: "饱和度和亮度",
hue: "色相",
alpha: "不透明度",
hex: "HEX 颜色",
format: "颜色格式",
color: "颜色值",
invalid: "请输入有效的颜色值",
eyeDropper: "拾取屏幕颜色",
eyeDropperFailed: "无法拾取屏幕颜色",
swatches: "预设颜色",
};
export default function Example({ locale }: ExampleProps) {
const [value, setValue] = useState("#007AFF");
return (
{value}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/color-picker
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { ColorPicker } from "@workspace/ui/components/color-picker";
import { useState } from "react";
export function AccentColor() {
const [color, setColor] = useState("#007AFF");
return ;
}
```
`value` and `onValueChange` make the picker controlled. Use `defaultValue` for local state. Values accept HEX with three or six digits, with or without `#`. With `alpha`, four and eight digits also encode opacity. Changes return normalized uppercase six-digit HEX, or eight digits with alpha enabled. Invalid input remains editable and shows feedback without changing the selected color.
## Editing formats
The format selector offers HEX, RGB, HSL, HSB and OKLCH. RGB accepts `rgb(0 122 255)`; HSL and HSB use percentages, for example `hsl(211 100% 50%)`. With opacity enabled, use `/ 0.5` or `/ 50%`. Changing the editing format preserves the color; output values remain HEX.
OKLCH accepts values such as `oklch(62.8% 0.2577 29.23 / 50%)`. Lightness accepts percentages or 0–1; chroma accepts numbers or percentages (100% equals 0.4); hue accepts unitless degrees, `deg`, `rad`, `grad`, or `turn`. Output remains sRGB HEX. Colors outside sRGB are mapped by reducing chroma while preserving lightness and hue, then quantized to eight-bit channels; the original wide-gamut value is not retained. Inputs require explicit numbers, without `none`, `calc()`, or relative color syntax. Lightness and opacity must be in range, and chroma must be nonnegative
## Opacity and inline controls
`inline` renders the controls directly. The checkerboard distinguishes transparency from white and black. Opacity changes return an eight-digit HEX value. The color picker does not change your application theme or persist a preference.
### Example: color-picker-alpha
```tsx
"use client";
import { ColorPicker } from "@workspace/ui/components/color-picker";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseLabels } from "./color-picker-demo";
export default function Example({ locale }: ExampleProps) {
const [value, setValue] = useState("#007AFF80");
return (
{value}
);
}
```
## Swatches and glass
Provide `swatches` as HEX values. Invalid and duplicate normalized colors are excluded. `glass` applies to the popup surface; the saturation field and sliders keep their color encoding, and individual swatches do not create nested glass surfaces.
### Example: color-picker-swatches
```tsx
"use client";
import { ColorPicker } from "@workspace/ui/components/color-picker";
import type { ExampleProps } from "../types";
import { chineseLabels } from "./color-picker-demo";
const swatches = [
"#007AFF",
"#70866A",
"#8D739C",
"#648493",
"#AE916B",
"#3E806E",
"#BC6C73",
];
export default function Example({ locale }: ExampleProps) {
return (
);
}
```
## Keyboard and screen color
Focus the saturation area: Left/Right change saturation, Up/Down change brightness, and Shift increases the step. Home/End set saturation to its minimum/maximum. Hue and opacity sliders use the shared Slider keyboard interactions. Tab moves between controls; Escape closes the popup and returns focus to its trigger. Every button uses `type="button"` and does not submit its surrounding form.
The optional screen eyedropper appears only in secure contexts with browser support. Cancelling keeps the current value, and an active request is cancelled when the picker unmounts. Set `eyeDropper={false}` to hide it. Translate `labels` for your interface. Trigger props such as `id`, `aria-describedby`, `ref`, `size` and `variant` are forwarded to the native trigger button. `inline` has no trigger and therefore does not use these trigger props.
## Composition
The same module exports `ColorPickerArea`, `ColorPickerSlider` and `ColorPickerSwatches`. Area and Slider receive `color: HSVColor` and `onColorChange(color)`; Slider adds `channel="hue" | "alpha"`. Swatches receive `value`, `swatches` and `onValueChange(value)`. Share one HSV state between these controls. Pure conversions are available from `@workspace/ui/lib/color/model`: `parseHexColor`, `colorToHex`, `parseColor`, `formatColor` and `colorToCSS`.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `value` | `string` | Controlled HEX color |
| `defaultValue` | `string` | `"#0088FF"` |
| `onValueChange` | `(value: string) => void` | Called when the normalized color changes |
| `alpha` | `boolean` | `false`; enables opacity and eight-digit output |
| `swatches` | `readonly string[]` | `[]` |
| `disabled` | `boolean` | Disables all interactions |
| `inline` | `boolean` | `false`; renders controls without popup |
| `glass` | `boolean` | `false`; popup material |
| `eyeDropper` | `boolean` | `true`; depends on browser support |
| `labels` | `Partial` | Accessible labels and feedback |
| `contentClassName` | `string` | Popup classes |
The module also exports prop types for the picker and its controls. Popup and sliders use the [Base UI Popover](https://base-ui.com/react/components/popover) and [Slider](https://base-ui.com/react/components/slider) APIs.
---
# Combobox
Autocomplete input with a list of suggestions.
Page: https://sui.draco.dev/docs/components/combobox
### Example: combobox-demo
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export default function ComboboxBasic() {
return (
No items found.
{(item) => (
{item}
)}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/combobox
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox"
```
```tsx showLineNumbers
const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"]
export function ExampleCombobox() {
return (
No items found.
{(item) => (
{item}
)}
)
}
```
## Composition
### Simple
A single-line input and a flat list (see [Basic](#basic)).
```text
Combobox
├── ComboboxInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
├── ComboboxItem
└── ComboboxItem
```
### With chips
Multi-select with `multiple`, chips, and a chips input (see [Multiple](#multiple)).
```text
Combobox
├── ComboboxChips
│ ├── ComboboxValue
│ │ └── ComboboxChip
│ └── ComboboxChipsInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
├── ComboboxItem
└── ComboboxItem
```
### With groups and collection
Nested items per group using `ComboboxCollection` inside each `ComboboxGroup`, with a separator between groups (see [Groups](#groups)).
```text
Combobox
├── ComboboxInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
├── ComboboxGroup
│ ├── ComboboxLabel
│ └── ComboboxCollection
│ ├── ComboboxItem
│ └── ComboboxItem
├── ComboboxSeparator
└── ComboboxGroup
├── ComboboxLabel
└── ComboboxCollection
├── ComboboxItem
└── ComboboxItem
```
## Custom Items
Use `itemToStringValue` when your items are objects.
```tsx showLineNumbers
import * as React from "react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox"
type Framework = {
label: string
value: string
}
const frameworks: Framework[] = [
{ label: "Next.js", value: "next" },
{ label: "SvelteKit", value: "sveltekit" },
{ label: "Nuxt", value: "nuxt" },
]
export function ExampleComboboxCustomItems() {
return (
framework.label}
>
No items found.
{(framework) => (
{framework.label}
)}
)
}
```
## Multiple Selection
Use `multiple` with chips for multi-select behavior.
```tsx showLineNumbers
import * as React from "react"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@workspace/ui/components/combobox"
const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"]
export function ExampleComboboxMultiple() {
const [value, setValue] = React.useState([])
return (
{value.map((item) => (
{item}
))}
No items found.
{(item) => (
{item}
)}
)
}
```
## Basic
A simple combobox with a list of frameworks.
### Example: combobox-basic
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export default function ComboboxBasic() {
return (
No items found.
{(item) => (
{item}
)}
);
}
```
## Multiple
A combobox with multiple selection using `multiple` and `ComboboxChips`.
### Example: combobox-multiple
```tsx
"use client";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@workspace/ui/components/combobox";
import * as React from "react";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export function ComboboxMultiple() {
const anchor = useComboboxAnchor();
return (
{(values) => (
{values.map((value: string) => (
{value}
))}
)}
No items found.
{(item) => (
{item}
)}
);
}
export default ComboboxMultiple;
```
## Clear Button
Use the `showClear` prop to show a clear button.
### Example: combobox-clear
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export function ComboboxWithClear() {
return (
No items found.
{(item) => (
{item}
)}
);
}
export default ComboboxWithClear;
```
## Groups
Use `ComboboxGroup` and `ComboboxSeparator` to group items.
### Example: combobox-groups
```tsx
"use client";
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
} from "@workspace/ui/components/combobox";
const timezones = [
{
value: "Americas",
items: [
"(GMT-5) New York",
"(GMT-8) Los Angeles",
"(GMT-6) Chicago",
"(GMT-5) Toronto",
"(GMT-8) Vancouver",
"(GMT-3) São Paulo",
],
},
{
value: "Europe",
items: [
"(GMT+0) London",
"(GMT+1) Paris",
"(GMT+1) Berlin",
"(GMT+1) Rome",
"(GMT+1) Madrid",
"(GMT+1) Amsterdam",
],
},
{
value: "Asia/Pacific",
items: [
"(GMT+9) Tokyo",
"(GMT+8) Shanghai",
"(GMT+8) Singapore",
"(GMT+4) Dubai",
"(GMT+11) Sydney",
"(GMT+9) Seoul",
],
},
] as const;
export function ComboboxWithGroupsAndSeparator() {
return (
No timezones found.
{(group, index) => (
{group.value}
{(item) => (
{item}
)}
{index < timezones.length - 1 && }
)}
);
}
export default ComboboxWithGroupsAndSeparator;
```
## Custom Items
You can render a custom component inside `ComboboxItem`.
### Example: combobox-custom
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from "@workspace/ui/components/item";
const countries = [
{ code: "", value: "", continent: "", label: "Select country" },
{
code: "ar",
value: "argentina",
label: "Argentina",
continent: "South America",
},
{ code: "au", value: "australia", label: "Australia", continent: "Oceania" },
{ code: "br", value: "brazil", label: "Brazil", continent: "South America" },
{ code: "ca", value: "canada", label: "Canada", continent: "North America" },
{ code: "cn", value: "china", label: "China", continent: "Asia" },
{
code: "co",
value: "colombia",
label: "Colombia",
continent: "South America",
},
{ code: "eg", value: "egypt", label: "Egypt", continent: "Africa" },
{ code: "fr", value: "france", label: "France", continent: "Europe" },
{ code: "de", value: "germany", label: "Germany", continent: "Europe" },
{ code: "it", value: "italy", label: "Italy", continent: "Europe" },
{ code: "jp", value: "japan", label: "Japan", continent: "Asia" },
{ code: "ke", value: "kenya", label: "Kenya", continent: "Africa" },
{ code: "mx", value: "mexico", label: "Mexico", continent: "North America" },
{
code: "nz",
value: "new-zealand",
label: "New Zealand",
continent: "Oceania",
},
{ code: "ng", value: "nigeria", label: "Nigeria", continent: "Africa" },
{
code: "za",
value: "south-africa",
label: "South Africa",
continent: "Africa",
},
{ code: "kr", value: "south-korea", label: "South Korea", continent: "Asia" },
{
code: "gb",
value: "united-kingdom",
label: "United Kingdom",
continent: "Europe",
},
{
code: "us",
value: "united-states",
label: "United States",
continent: "North America",
},
];
export function ComboboxWithCustomItems() {
return (
country.code !== "")}
itemToStringValue={(country: (typeof countries)[number]) => country.label}
>
No countries found.
{(country) => (
-
{country.label}
{country.continent} ({country.code})
)}
);
}
export default ComboboxWithCustomItems;
```
## Invalid
Use the `aria-invalid` prop to make the combobox invalid.
### Example: combobox-invalid
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export function ComboboxInvalid() {
return (
No items found.
{(item) => (
{item}
)}
);
}
export default ComboboxInvalid;
```
## Disabled
Use the `disabled` prop to disable the combobox.
### Example: combobox-disabled
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export function ComboboxDisabled() {
return (
No items found.
{(item) => (
{item}
)}
);
}
export default ComboboxDisabled;
```
## Auto Highlight
Use the `autoHighlight` prop to automatically highlight the first item on filter.
### Example: combobox-auto-highlight
```tsx
"use client";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@workspace/ui/components/combobox";
const frameworks = [
"Next.js",
"SvelteKit",
"Nuxt.js",
"Remix",
"Astro",
] as const;
export function ComboboxAutoHighlight() {
return (
No items found.
{(item) => (
{item}
)}
);
}
export default ComboboxAutoHighlight;
```
## Popup
You can trigger the combobox from a button or any other component by using the `render` prop. Move the `ComboboxInput` inside the `ComboboxContent`.
### Example: combobox-popup
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@workspace/ui/components/combobox";
const countries = [
{ code: "", value: "", continent: "", label: "Select country" },
{
code: "ar",
value: "argentina",
label: "Argentina",
continent: "South America",
},
{ code: "au", value: "australia", label: "Australia", continent: "Oceania" },
{ code: "br", value: "brazil", label: "Brazil", continent: "South America" },
{ code: "ca", value: "canada", label: "Canada", continent: "North America" },
{ code: "cn", value: "china", label: "China", continent: "Asia" },
{
code: "co",
value: "colombia",
label: "Colombia",
continent: "South America",
},
{ code: "eg", value: "egypt", label: "Egypt", continent: "Africa" },
{ code: "fr", value: "france", label: "France", continent: "Europe" },
{ code: "de", value: "germany", label: "Germany", continent: "Europe" },
{ code: "it", value: "italy", label: "Italy", continent: "Europe" },
{ code: "jp", value: "japan", label: "Japan", continent: "Asia" },
{ code: "ke", value: "kenya", label: "Kenya", continent: "Africa" },
{ code: "mx", value: "mexico", label: "Mexico", continent: "North America" },
{
code: "nz",
value: "new-zealand",
label: "New Zealand",
continent: "Oceania",
},
{ code: "ng", value: "nigeria", label: "Nigeria", continent: "Africa" },
{
code: "za",
value: "south-africa",
label: "South Africa",
continent: "Africa",
},
{ code: "kr", value: "south-korea", label: "South Korea", continent: "Asia" },
{
code: "gb",
value: "united-kingdom",
label: "United Kingdom",
continent: "Europe",
},
{
code: "us",
value: "united-states",
label: "United States",
continent: "North America",
},
];
export function ComboboxPopup() {
return (
}
>
No items found.
{(item) => (
{item.label}
)}
);
}
export default ComboboxPopup;
```
## Input Group
You can add an addon to the combobox by using the `InputGroupAddon` component inside the `ComboboxInput`.
### Example: combobox-input-group
```tsx
"use client";
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
} from "@workspace/ui/components/combobox";
import { InputGroupAddon } from "@workspace/ui/components/input-group";
import { GlobeIcon } from "lucide-react";
const timezones = [
{
value: "Americas",
items: [
"(GMT-5) New York",
"(GMT-8) Los Angeles",
"(GMT-6) Chicago",
"(GMT-5) Toronto",
"(GMT-8) Vancouver",
"(GMT-3) São Paulo",
],
},
{
value: "Europe",
items: [
"(GMT+0) London",
"(GMT+1) Paris",
"(GMT+1) Berlin",
"(GMT+1) Rome",
"(GMT+1) Madrid",
"(GMT+1) Amsterdam",
],
},
{
value: "Asia/Pacific",
items: [
"(GMT+9) Tokyo",
"(GMT+8) Shanghai",
"(GMT+8) Singapore",
"(GMT+4) Dubai",
"(GMT+11) Sydney",
"(GMT+9) Seoul",
],
},
] as const;
export function ComboxboxInputGroup() {
return (
No timezones found.
{(group) => (
{group.value}
{(item) => (
{item}
)}
)}
);
}
export default ComboxboxInputGroup;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: combobox-rtl
```tsx
"use client";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@workspace/ui/components/combobox";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const categories = [
"technology",
"design",
"business",
"marketing",
"education",
"health",
] as const;
const translations: Translations = {
en: {
dir: "ltr",
values: {
label: "Categories",
placeholder: "Add categories",
empty: "No categories found.",
technology: "Technology",
design: "Design",
business: "Business",
marketing: "Marketing",
education: "Education",
health: "Health",
},
},
ar: {
dir: "rtl",
values: {
label: "الفئات",
placeholder: "أضف فئات",
empty: "لم يتم العثور على فئات.",
technology: "التكنولوجيا",
design: "التصميم",
business: "الأعمال",
marketing: "التسويق",
education: "التعليم",
health: "الصحة",
},
},
he: {
dir: "rtl",
values: {
label: "קטגוריות",
placeholder: "הוסף קטגוריות",
empty: "לא נמצאו קטגוריות.",
technology: "טכנולוגיה",
design: "עיצוב",
business: "עסקים",
marketing: "שיווק",
education: "חינוך",
health: "בריאות",
},
},
};
export function ComboboxRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
const anchor = useComboboxAnchor();
const categoryLabels: Record = {
technology: t.technology,
design: t.design,
business: t.business,
marketing: t.marketing,
education: t.education,
health: t.health,
};
return (
{t.label}
categoryLabels[item] || item
}
>
{(values) => (
{values.map((value: string) => (
{categoryLabels[value] || value}
))}
)}
{t.empty}
{(item) => (
{categoryLabels[item] || item}
)}
);
}
export default ComboboxRtl;
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/combobox#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/combobox)
- [API reference](https://base-ui.com/react/components/combobox#api-reference)
---
# Command
Command menu for search and quick actions.
Page: https://sui.draco.dev/docs/components/command
### Example: command-demo
```tsx
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@workspace/ui/components/command";
import {
Calculator,
Calendar,
CreditCard,
Settings,
Smile,
User,
} from "lucide-react";
export function CommandDemo() {
return (
No results found.
Calendar
Search Emoji
Calculator
Profile
⌘P
Billing
⌘B
Settings
⌘S
);
}
export default CommandDemo;
```
## About
The ` ` component uses the [`cmdk`](https://github.com/dip/cmdk) component by [Dip](https://www.dip.org/).
## Installation
```bash
bunx --bun shadcn@latest add @sui/command
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@workspace/ui/components/command"
```
```tsx showLineNumbers
No results found.
Calendar
Search Emoji
Calculator
Profile
Billing
Settings
```
## Composition
Use the following composition to build a `Command`:
```text
Command
├── CommandInput
└── CommandList
├── CommandEmpty
├── CommandGroup
│ ├── CommandItem
│ └── CommandItem
├── CommandSeparator
└── CommandGroup
├── CommandItem
└── CommandItem
```
## Basic
A simple command menu in a dialog.
### Example: command-basic
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@workspace/ui/components/command";
import * as React from "react";
export function CommandBasic() {
const [open, setOpen] = React.useState(false);
return (
setOpen(true)} variant="outline" className="w-fit">
Open Menu
No results found.
Calendar
Search Emoji
Calculator
);
}
export default CommandBasic;
```
## Shortcuts
### Example: command-shortcuts
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@workspace/ui/components/command";
import { CreditCardIcon, SettingsIcon, UserIcon } from "lucide-react";
import * as React from "react";
export function CommandWithShortcuts() {
const [open, setOpen] = React.useState(false);
return (
setOpen(true)} variant="outline" className="w-fit">
Open Menu
No results found.
Profile
⌘P
Billing
⌘B
Settings
⌘S
);
}
export default CommandWithShortcuts;
```
## Groups
A command menu with groups, icons and separators.
### Example: command-groups
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@workspace/ui/components/command";
import {
CalculatorIcon,
CalendarIcon,
CreditCardIcon,
SettingsIcon,
SmileIcon,
UserIcon,
} from "lucide-react";
import * as React from "react";
export function CommandWithGroups() {
const [open, setOpen] = React.useState(false);
return (
setOpen(true)} variant="outline" className="w-fit">
Open Menu
No results found.
Calendar
Search Emoji
Calculator
Profile
⌘P
Billing
⌘B
Settings
⌘S
);
}
export default CommandWithGroups;
```
## Scrollable
Scrollable command menu with multiple items.
### Example: command-scrollable
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@workspace/ui/components/command";
import {
BellIcon,
CalculatorIcon,
CalendarIcon,
ClipboardPasteIcon,
CodeIcon,
CopyIcon,
CreditCardIcon,
FileTextIcon,
FolderIcon,
FolderPlusIcon,
HelpCircleIcon,
HomeIcon,
ImageIcon,
InboxIcon,
LayoutGridIcon,
ListIcon,
PlusIcon,
ScissorsIcon,
SettingsIcon,
TrashIcon,
UserIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import * as React from "react";
export function CommandManyItems() {
const [open, setOpen] = React.useState(false);
return (
setOpen(true)} variant="outline" className="w-fit">
Open Menu
No results found.
Home
⌘H
Inbox
⌘I
Documents
⌘D
Folders
⌘F
New File
⌘N
New Folder
⇧⌘N
Copy
⌘C
Cut
⌘X
Paste
⌘V
Delete
⌫
Grid View
List View
Zoom In
⌘+
Zoom Out
⌘-
Profile
⌘P
Billing
⌘B
Settings
⌘S
Notifications
Help & Support
Calculator
Calendar
Image Editor
Code Editor
);
}
export default CommandManyItems;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: command-rtl
```tsx
"use client";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@workspace/ui/components/command";
import {
Calculator,
Calendar,
CreditCard,
Settings,
Smile,
User,
} from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
placeholder: "Type a command or search...",
empty: "No results found.",
suggestions: "Suggestions",
calendar: "Calendar",
searchEmoji: "Search Emoji",
calculator: "Calculator",
settings: "Settings",
profile: "Profile",
billing: "Billing",
},
},
ar: {
dir: "rtl",
values: {
placeholder: "اكتب أمرًا أو ابحث...",
empty: "لم يتم العثور على نتائج.",
suggestions: "اقتراحات",
calendar: "التقويم",
searchEmoji: "البحث عن الرموز التعبيرية",
calculator: "الآلة الحاسبة",
settings: "الإعدادات",
profile: "الملف الشخصي",
billing: "الفوترة",
},
},
he: {
dir: "rtl",
values: {
placeholder: "הקלד פקודה או חפש...",
empty: "לא נמצאו תוצאות.",
suggestions: "הצעות",
calendar: "לוח שנה",
searchEmoji: "חפש אמוג'י",
calculator: "מחשבון",
settings: "הגדרות",
profile: "פרופיל",
billing: "חיוב",
},
},
};
export function CommandRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.empty}
{t.calendar}
{t.searchEmoji}
{t.calculator}
{t.profile}
⌘P
{t.billing}
⌘B
{t.settings}
⌘S
);
}
export default CommandRtl;
```
## API Reference
See the [cmdk](https://github.com/dip/cmdk) documentation for more information.
- [Documentation](https://github.com/dip/cmdk)
---
# Context Menu
Displays a menu of actions triggered by a right click.
Page: https://sui.draco.dev/docs/components/context-menu
### Example: context-menu-demo
```tsx
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuDemo() {
return (
Right click here
Long press here
Back
⌘[
Forward
⌘]
Reload
⌘R
More Tools
Save Page...
Create Shortcut...
Name Window...
Developer Tools
Delete
Show Bookmarks
Show Full URLs
People
Pedro Duarte
Colm Tuite
);
}
export default ContextMenuDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/context-menu
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu"
```
```tsx showLineNumbers
Right click here
Profile
Billing
Team
Subscription
```
## Composition
Use the following composition to build a `ContextMenu`:
```text
ContextMenu
├── ContextMenuTrigger
└── ContextMenuContent
├── ContextMenuGroup
│ ├── ContextMenuLabel
│ ├── ContextMenuItem
│ └── ContextMenuItem
├── ContextMenuSeparator
├── ContextMenuGroup
│ ├── ContextMenuLabel
│ ├── ContextMenuCheckboxItem
│ └── ContextMenuCheckboxItem
├── ContextMenuSeparator
├── ContextMenuGroup
│ ├── ContextMenuLabel
│ └── ContextMenuRadioGroup
│ ├── ContextMenuRadioItem
│ └── ContextMenuRadioItem
└── ContextMenuSub
├── ContextMenuSubTrigger
└── ContextMenuSubContent
└── ContextMenuGroup
├── ContextMenuItem
└── ContextMenuItem
```
## Basic
A simple context menu with a few actions.
### Example: context-menu-basic
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuBasic() {
return (
Right click here
Long press here
Back
Forward
Reload
);
}
export default ContextMenuBasic;
```
## Submenu
Use `ContextMenuSub` to nest secondary actions.
### Example: context-menu-submenu
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuSubmenu() {
return (
Right click here
Long press here
Copy
⌘C
Cut
⌘X
More Tools
Save Page...
Create Shortcut...
Name Window...
Developer Tools
Delete
);
}
export default ContextMenuSubmenu;
```
## Shortcuts
Add `ContextMenuShortcut` to show keyboard hints.
### Example: context-menu-shortcuts
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuShortcuts() {
return (
Right click here
Long press here
Back
⌘[
Forward
⌘]
Reload
⌘R
Save
⌘S
Save As...
⇧⌘S
);
}
export default ContextMenuShortcuts;
```
## Groups
Group related actions and separate them with dividers.
### Example: context-menu-groups
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuGroups() {
return (
Right click here
Long press here
File
New File
⌘N
Open File
⌘O
Save
⌘S
Edit
Undo
⌘Z
Redo
⇧⌘Z
Cut
⌘X
Copy
⌘C
Paste
⌘V
Delete
⌫
);
}
export default ContextMenuGroups;
```
## Icons
Combine icons with labels for quick scanning.
### Example: context-menu-icons
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
import {
ClipboardPasteIcon,
CopyIcon,
ScissorsIcon,
TrashIcon,
} from "lucide-react";
export function ContextMenuIcons() {
return (
Right click here
Long press here
Copy
Cut
Paste
Delete
);
}
export default ContextMenuIcons;
```
## Checkboxes
Use `ContextMenuCheckboxItem` for toggles.
### Example: context-menu-checkboxes
```tsx
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuCheckboxes() {
return (
Right click here
Long press here
Show Bookmarks Bar
Show Full URLs
Show Developer Tools
);
}
export default ContextMenuCheckboxes;
```
## Radio
Use `ContextMenuRadioItem` for exclusive choices.
### Example: context-menu-radio
```tsx
"use client";
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
import * as React from "react";
export function ContextMenuRadio() {
const [user, setUser] = React.useState("pedro");
const [theme, setTheme] = React.useState("light");
return (
Right click here
Long press here
People
Pedro Duarte
Colm Tuite
Theme
Light
Dark
System
);
}
export default ContextMenuRadio;
```
## Destructive
Use `variant="destructive"` to style the menu item as destructive.
### Example: context-menu-destructive
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
import { PencilIcon, ShareIcon, TrashIcon } from "lucide-react";
export function ContextMenuDestructive() {
return (
Right click here
Long press here
Edit
Share
Delete
);
}
export default ContextMenuDestructive;
```
## Sides
Control submenu placement with side and align props.
### Example: context-menu-sides
```tsx
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
export function ContextMenuSides() {
return (
Right click (top)
Long press (top)
Back
Forward
Reload
Right click (right)
Long press (right)
Back
Forward
Reload
Right click (bottom)
Long press (bottom)
Back
Forward
Reload
Right click (left)
Long press (left)
Back
Forward
Reload
);
}
export default ContextMenuSides;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: context-menu-rtl
```tsx
"use client";
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@workspace/ui/components/context-menu";
import { ArrowLeftIcon, ArrowRightIcon, RotateCwIcon } from "lucide-react";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
rightClick: "Right click here",
longPress: "Long press here",
navigation: "Navigation",
back: "Back",
forward: "Forward",
reload: "Reload",
moreTools: "More Tools",
savePage: "Save Page...",
createShortcut: "Create Shortcut...",
nameWindow: "Name Window...",
developerTools: "Developer Tools",
delete: "Delete",
showBookmarks: "Show Bookmarks",
showFullUrls: "Show Full URLs",
people: "People",
pedro: "Pedro Duarte",
colm: "Colm Tuite",
},
},
ar: {
dir: "rtl",
values: {
rightClick: "انقر بزر الماوس الأيمن هنا",
longPress: "اضغط مطولاً هنا",
navigation: "التنقل",
back: "رجوع",
forward: "تقدم",
reload: "إعادة تحميل",
moreTools: "المزيد من الأدوات",
savePage: "حفظ الصفحة...",
createShortcut: "إنشاء اختصار...",
nameWindow: "تسمية النافذة...",
developerTools: "أدوات المطور",
delete: "حذف",
showBookmarks: "إظهار الإشارات المرجعية",
showFullUrls: "إظهار عناوين URL الكاملة",
people: "الأشخاص",
pedro: "Pedro Duarte",
colm: "Colm Tuite",
},
},
he: {
dir: "rtl",
values: {
rightClick: "לחץ לחיצה ימנית כאן",
longPress: "לחץ לחיצה ארוכה כאן",
navigation: "ניווט",
back: "חזור",
forward: "קדימה",
reload: "רענן",
moreTools: "כלים נוספים",
savePage: "שמור עמוד...",
createShortcut: "צור קיצור דרך...",
nameWindow: "שם חלון...",
developerTools: "כלי מפתח",
delete: "מחק",
showBookmarks: "הצג סימניות",
showFullUrls: "הצג כתובות URL מלאות",
people: "אנשים",
pedro: "Pedro Duarte",
colm: "Colm Tuite",
},
},
};
export function ContextMenuRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
const [people, setPeople] = React.useState("pedro");
return (
{t.rightClick}
{t.longPress}
{t.navigation}
{t.back}
⌘[
{t.forward}
⌘]
{t.reload}
⌘R
{t.moreTools}
{t.savePage}
{t.createShortcut}
{t.nameWindow}
{t.developerTools}
{t.delete}
{t.showBookmarks}
{t.showFullUrls}
{t.people}
{t.pedro}
{t.colm}
);
}
export default ContextMenuRtl;
```
Use `side="inline-end"` to place the menu on the logical right side of the trigger.
```tsx showLineNumbers
Right click here
Profile
Billing
Team
Subscription
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/context-menu#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/context-menu)
- [API reference](https://base-ui.com/react/components/context-menu#api-reference)
---
# Dialog
A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
Page: https://sui.draco.dev/docs/components/dialog
### Example: dialog-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Field, FieldGroup } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
export function DialogDemo() {
const previewId = usePreviewId();
return (
);
}
export default DialogDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/dialog
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog"
```
```tsx showLineNumbers
Open
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
```
## Composition
Use the following composition to build a `Dialog`:
```text
Dialog
├── DialogTrigger
└── DialogContent
├── DialogHeader
│ ├── DialogTitle
│ └── DialogDescription
└── DialogFooter
```
## Custom Close Button
Replace the default close control with your own button.
### Example: dialog-close-button
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
export function DialogCloseButton() {
const previewId = usePreviewId();
return (
}>Share
Share link
Anyone who has this link will be able to view this.
}>Close
);
}
export default DialogCloseButton;
```
## No Close Button
Use `showCloseButton={false}` to hide the close button.
### Example: dialog-no-close-button
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
export function DialogNoCloseButton() {
return (
}>
No Close Button
No Close Button
This dialog doesn't have a close button in the top-right
corner.
);
}
export default DialogNoCloseButton;
```
## Sticky Footer
Keep actions visible while the content scrolls.
### Example: dialog-sticky-footer
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
export function DialogStickyFooter() {
return (
}>
Sticky Footer
Sticky Footer
This dialog has a sticky footer that stays visible while the content
scrolls.
{Array.from({ length: 10 }).map((_, index) => (
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
))}
}>Close
);
}
export default DialogStickyFooter;
```
## Scrollable Content
Long content can scroll while the header stays in view.
### Example: dialog-scrollable-content
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
export function DialogScrollableContent() {
return (
}>
Scrollable Content
Scrollable Content
This is a dialog with scrollable content.
{Array.from({ length: 10 }).map((_, index) => (
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
))}
);
}
export default DialogScrollableContent;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: dialog-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Field, FieldGroup } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
openDialog: "Open Dialog",
editProfile: "Edit profile",
description:
"Make changes to your profile here. Click save when you're done.",
name: "Name",
username: "Username",
cancel: "Cancel",
saveChanges: "Save changes",
},
},
ar: {
dir: "rtl",
values: {
openDialog: "فتح الحوار",
editProfile: "تعديل الملف الشخصي",
description:
"قم بإجراء تغييرات على ملفك الشخصي هنا. انقر فوق حفظ عند الانتهاء.",
name: "الاسم",
username: "اسم المستخدم",
cancel: "إلغاء",
saveChanges: "حفظ التغييرات",
},
},
he: {
dir: "rtl",
values: {
openDialog: "פתח דיאלוג",
editProfile: "ערוך פרופיל",
description: "בצע שינויים בפרופיל שלך כאן. לחץ על שמור כשתסיים.",
name: "שם",
username: "שם משתמש",
cancel: "בטל",
saveChanges: "שמור שינויים",
},
},
};
export function DialogRtl() {
const previewId = usePreviewId();
const { dir, t, language } = useTranslation(translations, "ar");
return (
);
}
export default DialogRtl;
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/dialog#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/dialog)
- [API reference](https://base-ui.com/react/components/dialog#api-reference)
---
# Diff Viewer
A line-based code comparison with split and unified views.
Page: https://sui.draco.dev/docs/components/diff-viewer
### Example: diff-viewer-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { DiffViewer } from "@workspace/ui/components/diff-viewer";
import { useState } from "react";
import type { ExampleProps } from "../types";
const oldCode =
'export const theme = {\n name: "default",\n contrast: 4.5,\n};';
const revisedCode =
'export const theme = {\n name: "bamboo",\n contrast: 4.5,\n focusContrast: 3,\n};';
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const stateLabels = chinese
? {
identical: "查看相同内容",
changes: "查看修改",
}
: {
identical: "Show identical content",
changes: "Show changes",
};
const [changed, setChanged] = useState(true);
return (
setChanged((current) => !current)}
>
{changed ? stateLabels.identical : stateLabels.changes}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/diff-viewer
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { DiffViewer } from "@workspace/ui/components/diff-viewer";
;
```
## Split and unified views
The viewer starts with old and new code in side-by-side panes. Its view controls switch to a unified list. Added and removed lines retain distinct colors and line numbers. `oldTitle`, `newTitle`, and `filename` customize the headings. The comparison operates on lines rather than individual character changes.
Old and new sources are highlighted independently so multiline strings and comments keep each version's own syntax context. Unified unchanged lines use the new source context; split columns retain their respective source context.
Split headings stay on one line and truncate long titles, with the full text available on hover, so unequal headings cannot shift code rows. The first example pairs a long and short heading for narrow-screen comparison.
Use `maxHeight` to limit the scrolling viewport. The viewport is reachable with Tab and supports keyboard scrolling with a visible theme-colored focus ring.
## Streaming revisions
Use `status="streaming"` while `newCode` is being assembled and `"complete"` when it finishes. It follows content near the bottom, pauses after you scroll away, and resumes when you return. The example appends a local JSON revision. Copying is disabled in this example with `copyable={false}`.
### Example: diff-viewer-streaming
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { DiffViewer } from "@workspace/ui/components/diff-viewer";
import { useEffect, useState } from "react";
import type { ExampleProps } from "../types";
const oldCode = '{\n "name": "default",\n "enabled": false\n}';
const newLines = [
"{",
' "name": "rose",',
' "enabled": true,',
...Array.from(
{ length: 12 },
(_, index) => ` "item${index + 1}": ${index + 1},`,
),
' "version": 2',
"}",
];
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [count, setCount] = useState(newLines.length);
const streaming = count < newLines.length;
useEffect(() => {
if (!streaming) return;
const timer = setTimeout(() => setCount(count + 1), 350);
return () => clearTimeout(timer);
}, [streaming, count]);
return (
setCount(1)}
>
{chinese ? "重新播放修改" : "Replay changes"}
);
}
```
## Copying and localization
The copy action copies `newCode`, not a patch or the old version. Highlighting is asynchronous and falls back to plain readable lines when it fails. The surrounding theme is used unless `theme` is provided. Translate `labels` for the two versions, view controls, status, and copy feedback.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `oldCode`, `newCode` | `string` | Required source versions. |
| `oldTitle`, `newTitle` | `string` | Optional version headings. |
| `filename` | `ReactNode` | Optional file heading. |
| `lang` | `string` | `"typescript"`. |
| `theme` | `"light" \| "dark"` | Follows surrounding theme. |
| `status` | `"streaming" \| "complete"` | `"complete"`. |
| `maxHeight` | `number` | `280` pixels. |
| `copyable` | `boolean` | `true`; copies new code. |
| `labels` | `Partial` | English labels. |
| `glass` | `boolean` | `false`. |
The module exports `DiffViewerProps` and `DiffViewerLabels`. The line comparison uses [jsdiff](https://github.com/kpdecker/jsdiff).
---
# Direction
A provider component that sets the text direction for your application.
Page: https://sui.draco.dev/docs/components/direction
The `DirectionProvider` component is used to set the text direction (`ltr` or `rtl`) for your application. This is essential for supporting right-to-left languages like Arabic, Hebrew, and Persian.
Here's a preview of the component in RTL mode. Use the language selector to switch the language. To see more examples, look for the RTL section on components pages.
### Example: card-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
title: "Login to your account",
description: "Enter your email below to login to your account",
signUp: "Sign Up",
email: "Email",
emailPlaceholder: "m@example.com",
password: "Password",
forgotPassword: "Forgot your password?",
login: "Login",
loginWithGoogle: "Login with Google",
},
},
ar: {
dir: "rtl",
values: {
title: "تسجيل الدخول إلى حسابك",
description: "أدخل بريدك الإلكتروني أدناه لتسجيل الدخول إلى حسابك",
signUp: "إنشاء حساب",
email: "البريد الإلكتروني",
emailPlaceholder: "m@example.com",
password: "كلمة المرور",
forgotPassword: "نسيت كلمة المرور؟",
login: "تسجيل الدخول",
loginWithGoogle: "تسجيل الدخول باستخدام Google",
},
},
he: {
dir: "rtl",
values: {
title: "התחבר לחשבון שלך",
description: "הזן את האימייל שלך למטה כדי להתחבר לחשבון שלך",
signUp: "הירשם",
email: "אימייל",
emailPlaceholder: "m@example.com",
password: "סיסמה",
forgotPassword: "שכחת את הסיסמה?",
login: "התחבר",
loginWithGoogle: "התחבר עם Google",
},
},
};
export function CardRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.title}
{t.description}
{t.signUp}
{t.login}
{t.loginWithGoogle}
);
}
export default CardRtl;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/direction
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { DirectionProvider } from "@workspace/ui/components/direction"
```
```tsx showLineNumbers
{/* Your app content */}
```
## useDirection
The `useDirection` hook is used to get the current direction of the application.
```tsx showLineNumbers
import { useDirection } from "@workspace/ui/components/direction"
function MyComponent() {
const direction = useDirection()
return Current direction: {direction}
}
```
- [Documentation](https://base-ui.com/react/utils/direction-provider)
- [API reference](https://base-ui.com/react/utils/direction-provider#api-reference)
---
# Drawer
A drawer component for React.
Page: https://sui.draco.dev/docs/components/drawer
### Example: drawer-demo
```tsx
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { toast } from "@workspace/ui/components/toast";
import { useIsMobile } from "@workspace/ui/hooks/use-mobile";
import * as React from "react";
const deliveryTimes = [
{
value: "asap",
id: "delivery-asap",
label: "Standard delivery",
description: "25–35 min · Driver assigned now",
badge: "Fastest",
},
{
value: "5-00",
id: "delivery-5-00",
label: "5:00 PM – 5:15 PM",
description: "Prep starts at 4:45 PM",
},
{
value: "5-30",
id: "delivery-5-30",
label: "5:30 PM – 5:45 PM",
description: "Good if you're heading home",
},
{
value: "6-00",
id: "delivery-6-00",
label: "6:00 PM – 6:15 PM",
description: "Most popular · High demand",
},
{
value: "6-30",
id: "delivery-6-30",
label: "6:30 PM – 6:45 PM",
description: "Last slot before kitchen closes",
},
];
export function DrawerDemo() {
const [open, setOpen] = React.useState(false);
const [deliveryTime, setDeliveryTime] = React.useState("asap");
const isMobile = useIsMobile();
function handleConfirm() {
const selected = deliveryTimes.find((time) => time.value === deliveryTime);
if (!selected) {
return;
}
setOpen(false);
toast.add({
title: "Delivery time confirmed",
...{
description: selected.label,
},
});
}
return (
}>
Open Drawer
Pick a delivery time
We'll prepare your order as soon as possible.
{deliveryTimes.map((time) => (
{time.label}
{time.badge ? (
{time.badge}
) : null}
{time.description}
))}
Confirm Delivery Time
}>
Cancel
);
}
export default DrawerDemo;
```
The drawer component now uses [Base
UI](https://base-ui.com/react/components/drawer) instead of Vaul. If you
installed the previous version, see the [migration
guide](#migrating-from-vaul).
## Installation
```bash
bunx --bun shadcn@latest add @sui/drawer
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer"
```
```tsx showLineNumbers
}>Open
Are you absolutely sure?
This action cannot be undone.
{/* Content here */}
Submit
}>Cancel
```
## Composition
Use the following composition to build a `Drawer`:
```text
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooter
```
`DrawerContent` composes the portal, overlay, viewport, and popup from Base UI. For lower-level control, `DrawerPortal`, `DrawerOverlay`, and `DrawerSwipeHandle` are also exported.
## Custom Sizes
A vertical drawer sizes itself to its content and is capped at `calc(100dvh - 6rem)` by default. A side drawer spans `75%` of the viewport width, or `24rem` on larger screens.
To customize the height of a vertical drawer, use the `h-*` and `max-h-*` utilities on `DrawerContent`.
```tsx
```
To customize the width of a side drawer, use the `w-*` and `max-w-*` utilities on `DrawerContent`.
```tsx
```
When the same component renders in multiple directions, scope an override to one axis using the `data-[swipe-axis=*]` variants.
```tsx
```
To make a region of the drawer scrollable, make the scroll container a flex item. Avoid `h-full`, which does not resolve inside a content-sized drawer.
```tsx
...
{/* Scrollable content */}
...
```
## Styling
The drawer exposes CSS variables for style-level customization. Set the sizing variables on `DrawerContent`. Set the overlay variable on `[data-slot=drawer-overlay]` in your CSS.
| Variable | Default | Description |
| ------------------------------ | ---------------------- | ----------------------------------------------------------------------- |
| `--drawer-inset` | `0px` | Floats the drawer from the viewport edges. |
| `--drawer-bleed-background` | `var(--color-popover)` | Fills the gap behind the drawer on swipe overshoot. |
| `--drawer-overlay-min-opacity` | `0` | Minimum overlay opacity. Defaults to `0.5` when snap points are active. |
The drawer also sets data attributes you can target with variants such as `data-[swipe-direction=down]:` on `DrawerContent`, or `group-data-[swipe-axis=y]/drawer-popup:` on its descendants.
| Attribute | Values | Set when |
| ------------------------- | ----------------------------- | ------------------------------------- |
| `data-swipe-direction` | `up`, `right`, `down`, `left` | Always. |
| `data-swipe-axis` | `x`, `y` | Always. |
| `data-snap-points` | Present | The drawer has snap points. |
| `data-expanded` | Present | The drawer is at the full snap point. |
| `data-swiping` | Present | A swipe is in progress. |
| `data-nested-drawer-open` | Present | A nested drawer is open on top. |
## Position
Use the `swipeDirection` prop to set the side of the drawer.
Available options are `up`, `right`, `down`, and `left`.
### Example: drawer-sides
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
export function DrawerWithSides() {
return (
}>
Open Left Drawer
Move Goal
Set your daily activity goal.
}>Close
);
}
export default DrawerWithSides;
```
## Swipe Handle
Use `showSwipeHandle` on `Drawer` to render a swipe handle.
### Example: drawer-swipe-handle
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
export function DrawerSwipeHandle() {
return (
}>
Open Drawer
Drawer
Drawer with a swipe handle.
}>Close
);
}
export default DrawerSwipeHandle;
```
## Nested
Open drawers from inside another drawer. Parent drawers stay mounted and stack behind the frontmost drawer.
### Example: drawer-nested
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
import { useIsMobile } from "@workspace/ui/hooks/use-mobile";
export function DrawerNested() {
const isMobile = useIsMobile();
const swipeDirection = isMobile ? "down" : "right";
return (
}>
Open Drawer
Drawer
Open another drawer from the same direction.
}>
Open Nested Drawer
Nested Drawer
The parent drawer stays mounted behind this one.
}>
Open Third Drawer
Third Drawer
Two drawers are stacked behind this one.
}>
Open Fourth Drawer
Fourth Drawer
This is the frontmost drawer in the stack.
}>
Close
}>
Close
}>
Close
}>Close
);
}
export default DrawerNested;
```
## Non Modal
Set `modal={false}` to allow interaction with the rest of the page while the drawer is open. Combine with `disablePointerDismissal` to prevent the drawer from closing on outside presses. Use `modal="trap-focus"` to keep focus inside the drawer while leaving scroll and pointer interaction unrestricted.
### Example: drawer-non-modal
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
export function DrawerNonModal() {
return (
}>
Non Modal
Non Modal Drawer
}>Close
);
}
export default DrawerNonModal;
```
## Snap Points
Use `snapPoints` to snap a drawer to preset heights. Numbers between `0` and `1` represent fractions of the viewport. Numbers greater than `1` are treated as pixel values. String values support `px` and `rem` units. Snap points apply to vertical drawers.
Track the active snap point with the controlled `snapPoint` and `onSnapPointChange` props. At the full snap point, the drawer gets a `data-expanded` attribute you can style with the `data-expanded:` variant.
### Example: drawer-snap-points
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
const SNAP_POINTS = ["31rem", 1];
export function DrawerSnapPoints() {
return (
}>
Open Snap Drawer
Snap points
Drag the drawer to snap between a compact peek and a near
full-height view.
}>Close
);
}
export default DrawerSnapPoints;
```
## Responsive
You can combine the `Dialog` and `Drawer` components to create a responsive dialog. This renders a `Dialog` component on desktop and a `Drawer` on mobile.
### Example: drawer-dialog
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { cn } from "cn";
import * as React from "react";
import { useId as usePreviewId } from "react";
import { useMediaQuery } from "./support";
export function DrawerDialogDemo() {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
if (isDesktop) {
return (
}>
Edit Profile
Edit profile
Make changes to your profile here. Click save when you're
done.
);
}
return (
}>
Edit Profile
Edit profile
Make changes to your profile here. Click save when you're done.
);
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
const previewId = usePreviewId();
return (
);
}
export default DrawerDialogDemo;
```
## Migrating from Vaul
The base drawer now uses [Base UI](https://base-ui.com/react/components/drawer)
instead of Vaul. If you installed the previous base drawer, update your usage
to the Base UI API.
### Update the dependency.
```diff
- npm install vaul
+ npm install @base-ui/react
```
### Replace `direction` with `swipeDirection`.
Use `down` instead of `bottom`, and `up` instead of `top`. `left` and `right`
stay the same.
```diff
-
+
```
### Replace `asChild` with `render`.
For `DrawerTrigger`, pass the trigger element to the `render` prop.
```diff
-
- Open
-
+ }>
+ Open
+
```
For `DrawerClose`, pass the close element to the `render` prop.
```diff
-
- Cancel
-
+ }>
+ Cancel
+
```
### Update snap point props.
If you use snap points, rename the controlled snap point props and the sequential
snap point prop.
```diff
```
### Update animation and focus props.
```diff
- setDone(open)}>
+ setDone(open)}>
```
```diff
- event.preventDefault()}>
+
```
### Review Vaul-only props.
Vaul props like `handleOnly`, `repositionInputs`, and
`shouldScaleBackground` do not have one-to-one replacements in the base drawer
API. Use Base UI props such as `disablePointerDismissal`, `modal`, `snapPoints`,
or controlled `open` state for the behavior you need.
```diff
-
+
```
```diff
-
+
```
### Update custom data attribute selectors.
Replace Vaul's `data-vaul-drawer-direction` selectors with Base UI's
`data-swipe-direction` selectors.
```diff
-
+
```
Base UI also exposes attributes like `data-swiping`, `data-starting-style`, and
`data-ending-style` for swipe and transition states. Descendants inside
`DrawerContent` can use `group-data-[swipe-axis=x]/drawer-popup` and
`group-data-[swipe-axis=y]/drawer-popup` for axis-specific styling.
## API Reference
See the [Base UI documentation](https://base-ui.com/react/components/drawer) for the full API reference.
- [Documentation](https://base-ui.com/react/components/drawer)
- [API reference](https://base-ui.com/react/components/drawer#api-reference)
---
# Dropdown Menu
Displays a menu to the user — such as a set of actions or functions — triggered by a button.
Page: https://sui.draco.dev/docs/components/dropdown-menu
### Example: dropdown-menu-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
export function DropdownMenuDemo() {
return (
}>
Open
My Account
Profile
⇧⌘P
Billing
⌘B
Settings
⌘S
Team
Invite users
Email
Message
More...
New Team
⌘+T
GitHub
Support
API
Log out
⇧⌘Q
);
}
export default DropdownMenuDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/dropdown-menu
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Button } from "@workspace/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
```
```tsx showLineNumbers
}>
Open
My Account
Profile
Billing
Team
Subscription
```
## Composition
Use the following composition to build a `DropdownMenu`:
```text
DropdownMenu
├── DropdownMenuTrigger
└── DropdownMenuContent
├── DropdownMenuGroup
│ ├── DropdownMenuLabel
│ ├── DropdownMenuItem
│ └── DropdownMenuItem
├── DropdownMenuSeparator
├── DropdownMenuGroup
│ ├── DropdownMenuLabel
│ ├── DropdownMenuCheckboxItem
│ └── DropdownMenuCheckboxItem
├── DropdownMenuSeparator
├── DropdownMenuGroup
│ ├── DropdownMenuLabel
│ └── DropdownMenuRadioGroup
│ ├── DropdownMenuRadioItem
│ └── DropdownMenuRadioItem
└── DropdownMenuSub
├── DropdownMenuSubTrigger
└── DropdownMenuSubContent
└── DropdownMenuGroup
├── DropdownMenuLabel
├── DropdownMenuItem
└── DropdownMenuItem
```
## Basic
A basic dropdown menu with labels and separators.
### Example: dropdown-menu-basic
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
export function DropdownMenuBasic() {
return (
}>
Open
My Account
Profile
Billing
Settings
GitHub
Support
API
);
}
export default DropdownMenuBasic;
```
## Submenu
Use `DropdownMenuSub` to nest secondary actions.
### Example: dropdown-menu-submenu
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
export function DropdownMenuSubmenu() {
return (
}>
Open
Team
Invite users
Email
Message
More options
Calendly
Slack
Webhook
Advanced...
New Team
⌘+T
);
}
export default DropdownMenuSubmenu;
```
## Shortcuts
Add `DropdownMenuShortcut` to show keyboard hints.
### Example: dropdown-menu-shortcuts
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
export function DropdownMenuShortcuts() {
return (
}>
Open
My Account
Profile
⇧⌘P
Billing
⌘B
Settings
⌘S
Log out
⇧⌘Q
);
}
export default DropdownMenuShortcuts;
```
## Icons
Combine icons with labels for quick scanning.
### Example: dropdown-menu-icons
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
CreditCardIcon,
LogOutIcon,
SettingsIcon,
UserIcon,
} from "lucide-react";
export function DropdownMenuIcons() {
return (
}>
Open
Profile
Billing
Settings
Log out
);
}
export default DropdownMenuIcons;
```
## Checkboxes
Use `DropdownMenuCheckboxItem` for toggles.
### Example: dropdown-menu-checkboxes
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import * as React from "react";
export function DropdownMenuCheckboxes() {
const [showStatusBar, setShowStatusBar] = React.useState(true);
const [showActivityBar, setShowActivityBar] = React.useState(false);
const [showPanel, setShowPanel] = React.useState(false);
return (
}>
Open
Appearance
Status Bar
Activity Bar
Panel
);
}
export default DropdownMenuCheckboxes;
```
## Checkboxes Icons
Add icons to checkbox items.
### Example: dropdown-menu-checkboxes-icons
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { BellIcon, MailIcon, MessageSquareIcon } from "lucide-react";
import * as React from "react";
export function DropdownMenuCheckboxesIcons() {
const [notifications, setNotifications] = React.useState({
email: true,
sms: false,
push: true,
});
return (
}>
Notifications
Notification Preferences
setNotifications({ ...notifications, email: checked === true })
}
>
Email notifications
setNotifications({ ...notifications, sms: checked === true })
}
>
SMS notifications
setNotifications({ ...notifications, push: checked === true })
}
>
Push notifications
);
}
export default DropdownMenuCheckboxesIcons;
```
## Radio Group
Use `DropdownMenuRadioGroup` for exclusive choices.
### Example: dropdown-menu-radio-group
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import * as React from "react";
export function DropdownMenuRadioGroupDemo() {
const [position, setPosition] = React.useState("bottom");
return (
}>
Open
Panel Position
Top
Bottom
Right
);
}
export default DropdownMenuRadioGroupDemo;
```
## Radio Icons
Show radio options with icons.
### Example: dropdown-menu-radio-icons
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Building2Icon, CreditCardIcon, WalletIcon } from "lucide-react";
import * as React from "react";
export function DropdownMenuRadioIcons() {
const [paymentMethod, setPaymentMethod] = React.useState("card");
return (
}>
Payment Method
Select Payment Method
Credit Card
PayPal
Bank Transfer
);
}
export default DropdownMenuRadioIcons;
```
## Destructive
Use `variant="destructive"` for irreversible actions.
### Example: dropdown-menu-destructive
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { PencilIcon, ShareIcon, TrashIcon } from "lucide-react";
export function DropdownMenuDestructive() {
return (
}>
Actions
Edit
Share
Delete
);
}
export default DropdownMenuDestructive;
```
## Avatar
An account switcher dropdown triggered by an avatar.
### Example: dropdown-menu-avatar
```tsx
"use client";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
BadgeCheckIcon,
BellIcon,
CreditCardIcon,
LogOutIcon,
} from "lucide-react";
export function DropdownMenuAvatar() {
return (
}
>
LR
Account
Billing
Notifications
Sign Out
);
}
export default DropdownMenuAvatar;
```
## Complex
A richer example combining groups, icons, and submenus.
### Example: dropdown-menu-complex
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
BellIcon,
CreditCardIcon,
DownloadIcon,
EyeIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
FolderIcon,
FolderOpenIcon,
FolderSearchIcon,
HelpCircleIcon,
KeyboardIcon,
LanguagesIcon,
LayoutIcon,
LogOutIcon,
MailIcon,
MonitorIcon,
MoonIcon,
MoreHorizontalIcon,
PaletteIcon,
SaveIcon,
SettingsIcon,
ShieldIcon,
SunIcon,
UserIcon,
} from "lucide-react";
import * as React from "react";
export function DropdownMenuComplex() {
const [notifications, setNotifications] = React.useState({
email: true,
sms: false,
push: true,
});
const [theme, setTheme] = React.useState("light");
return (
}>
Complex Menu
File
New File
⌘N
New Folder
⇧⌘N
Open Recent
Recent Projects
Project Alpha
Project Beta
More Projects
Project Gamma
Project Delta
Browse...
Save
⌘S
Export
⇧⌘E
View
setNotifications({ ...notifications, email: checked === true })
}
>
Show Sidebar
setNotifications({ ...notifications, sms: checked === true })
}
>
Show Status Bar
Theme
Appearance
Light
Dark
System
Account
Profile
⇧⌘P
Billing
Settings
Preferences
Keyboard Shortcuts
Language
Notifications
Notification Types
setNotifications({
...notifications,
push: checked === true,
})
}
>
Push Notifications
setNotifications({
...notifications,
email: checked === true,
})
}
>
Email Notifications
Privacy & Security
Help & Support
Documentation
Sign Out
⇧⌘Q
);
}
export default DropdownMenuComplex;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: dropdown-menu-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { CreditCardIcon, SettingsIcon, UserIcon } from "lucide-react";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
open: "Open",
account: "Account",
profile: "Profile",
billing: "Billing",
settings: "Settings",
logout: "Log out",
team: "Team",
inviteUsers: "Invite users",
email: "Email",
message: "Message",
more: "More",
calendar: "Calendar",
chat: "Chat",
webhook: "Webhook",
advanced: "Advanced...",
newTeam: "New Team",
view: "View",
statusBar: "Status Bar",
activityBar: "Activity Bar",
panel: "Panel",
position: "Position",
top: "Top",
bottom: "Bottom",
right: "Right",
left: "Left",
},
},
ar: {
dir: "rtl",
values: {
open: "افتح القائمة",
account: "الحساب",
profile: "الملف الشخصي",
billing: "الفوترة",
settings: "الإعدادات",
logout: "تسجيل الخروج",
team: "الفريق",
inviteUsers: "دعوة المستخدمين",
email: "البريد الإلكتروني",
message: "رسالة",
more: "المزيد",
calendar: "تقويم",
chat: "دردشة",
webhook: "خطاف ويب",
advanced: "متقدم...",
newTeam: "فريق جديد",
view: "عرض",
statusBar: "شريط الحالة",
activityBar: "شريط النشاط",
panel: "اللوحة",
position: "الموضع",
top: "أعلى",
bottom: "أسفل",
right: "يمين",
left: "يسار",
},
},
he: {
dir: "rtl",
values: {
open: "פתח תפריט",
account: "חשבון",
profile: "פרופיל",
billing: "חיוב",
settings: "הגדרות",
logout: "התנתק",
team: "הצוות",
inviteUsers: "הזמן משתמשים",
email: "אימייל",
message: "הודעה",
more: "עוד",
calendar: "יומן",
chat: "צ'אט",
webhook: "Webhook",
advanced: "מתקדם...",
newTeam: "צוות חדש",
view: "תצוגה",
statusBar: "שורת סטטוס",
activityBar: "שורת פעילות",
panel: "לוח",
position: "מיקום",
top: "למעלה",
bottom: "למטה",
right: "ימין",
left: "שמאל",
},
},
};
export function DropdownMenuRtl() {
const { dir, language, t } = useTranslation(translations, "ar");
const [showStatusBar, setShowStatusBar] = React.useState(true);
const [showActivityBar, setShowActivityBar] = React.useState(false);
const [showPanel, setShowPanel] = React.useState(false);
const [position, setPosition] = React.useState("bottom");
return (
}>
{t.open}
{t.account}
{t.profile}
{t.billing}
{t.settings}
{t.team}
{t.team}
{t.inviteUsers}
{t.email}
{t.message}
{t.more}
{t.calendar}
{t.chat}
{t.webhook}
{t.advanced}
{t.newTeam}
⌘+T
{t.view}
{t.statusBar}
{t.activityBar}
{t.panel}
{t.position}
{t.top}
{t.bottom}
{t.right}
{t.left}
{t.logout}
);
}
export default DropdownMenuRtl;
```
## API Reference
See the [Base UI documentation](https://base-ui.com/react/components/menu) for the full API reference.
- [Documentation](https://base-ui.com/react/components/menu)
- [API reference](https://base-ui.com/react/components/menu#api-reference)
---
# Editor
A client-loaded code editor with preview, toolbar actions, and fullscreen modes.
Page: https://sui.draco.dev/docs/components/editor
### Example: editor-demo
```tsx
"use client";
import { Editor } from "@workspace/ui/components/editor";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [language, setLanguage] = useState("markdown");
const [value, setValue] = useState(
chinese
? "# 项目说明\n\n编辑这里的 **Markdown**,然后切换预览。\n\n- [x] 双语文档\n- [ ] 下一次发布"
: "# Project notes\n\nEdit this **Markdown**, then switch to preview.\n\n- [x] Bilingual docs\n- [ ] Next release",
);
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/editor
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Editor } from "@workspace/ui/components/editor";
import { useState } from "react";
export function NotesEditor() {
const [value, setValue] = useState("# Project notes");
return ;
}
```
## Editing and value state
Use `value` and `onChange` for controlled editing. For internal state, omit `value` and supply `defaultValue`; `onChange` still reports edits. `disabled` makes the editor read-only. The editor is loaded in the client, with a loading placeholder during SSR and hydration. Switching language recreates the editor bridge and releases its previous model while retaining the current document value. Language services and workers load as needed. Workers are shared between mounted editors, stop after the last editor and all models are released, and are recreated when editing resumes. Editing does not execute the supplied code.
## Language selection
Supply `language` and `onLanguageChange` to show the shared Select language picker in the toolbar. Customize the candidates with `languages`, or use the common languages provided by default; the current value stays visible even when absent from that list. The caller controls the selected language, and selection preserves the document content. Set `toolbarLanguage={false}` to hide the picker. Without a callback it is hidden, and `disabled` disables selection. Language IDs are trimmed and lowercased; `ts`, `js`, `md`, and `text` normalize to `typescript`, `javascript`, `markdown`, and `plaintext`.
The first example switches between Markdown, HTML, TypeScript, TSX, and JSON. HTML and Markdown have built-in previews; the other languages retain editing mode.
## Preview modes
Markdown and HTML have built-in previews and start in `edit` mode. Toolbar actions switch between `edit`, `preview`, and `split`. The built-in HTML preview disables scripts with `sandbox="allow-same-origin"` and uses the isolated [Html Viewer](/docs/components/html-viewer), while Markdown uses [Markdown Viewer](/docs/components/markdown-viewer).
Provide `preview.component` for another renderer. It receives `content`, `language`, `scrollContainerRef`, and `onScroll`. Use `preview.defaultMode` for the initial mode, or `preview.mode` and `preview.onModeChange` for controlled mode selection. Custom previews default to split mode.
### Scroll synchronization
In split mode, scrolling either pane synchronizes the other by relative scroll progress, rather than source-line positions. Custom previews must attach `scrollContainerRef` and `onScroll` to the actual scrolling div. The built-in Markdown preview uses its outer scrolling container. The built-in HTML preview permits same-origin DOM access while keeping scripts disabled; the parent synchronizes its document scrolling container after each frame load and removes listeners on replacement or unmount.
The example supplies a long document, a custom scrolling preview, controlled preview mode, and browser fullscreen.
### Example: editor-preview
```tsx
"use client";
import {
Editor,
type EditorProps,
type EditorViewMode,
} from "@workspace/ui/components/editor";
import { MarkdownViewer } from "@workspace/ui/components/markdown-viewer";
import {
type ComponentProps,
createContext,
useContext,
useState,
} from "react";
import type { ExampleProps } from "../types";
type PreviewProps = ComponentProps<
NonNullable["component"]
>;
const PreviewLocale = createContext(false);
const sections = Array.from({ length: 24 }, (_, index) => index + 1);
function Preview({ content, scrollContainerRef, onScroll }: PreviewProps) {
const chinese = useContext(PreviewLocale);
return (
);
}
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [mode, setMode] = useState("split");
const [value, setValue] = useState(() =>
sections
.map((section) =>
chinese
? `## 第 ${section} 节\n\n在编辑区和预览区分别滚动,另一区域按可滚动距离的比例跟随。\n\n这一段用于产生真实的长内容。\n`
: `## Section ${section}\n\nScroll either pane to synchronize its relative position with the other pane.\n\nThis paragraph creates real long-form content.\n`,
)
.join("\n"),
);
return (
{chinese ? "受控模式" : "Controlled mode"}: {mode}
);
}
```
## Toolbar and read-only editing
`toolbarTitle` and `toolbar` accept a React node or a render function. The render function receives `EditorToolbarActionContext`, including the current value, language, theme, disabled state, Monaco instance, `format()`, `setMode()`, and `setFullscreen()`. `toolbar={false}` hides the entire toolbar.
`EditorToolbarButton` is available for custom icon actions. The example adds formatting and reset actions, and lets you enable or disable editing. Formatting depends on the formatter registered for the current language.
### Example: editor-toolbar
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Editor } from "@workspace/ui/components/editor";
import { useState } from "react";
import type { ExampleProps } from "../types";
const initialValue = 'export const theme = { name: "bamboo", enabled: true };';
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const stateLabels = chinese
? {
enable: "允许编辑",
readOnly: "设为只读",
}
: {
enable: "Enable editing",
readOnly: "Make read-only",
};
const [value, setValue] = useState(initialValue);
const [disabled, setDisabled] = useState(false);
return (
setDisabled((current) => !current)}
>
{disabled ? stateLabels.enable : stateLabels.readOnly}
(
{chinese ? "格式化" : "Format"}
setValue(initialValue)}
>
{chinese ? "恢复示例" : "Reset example"}
)}
labels={
chinese
? {
copied: "已复制",
copyFailed: "复制失败",
loading: "正在加载编辑器",
loadingPreview: "正在加载预览",
error: "无法加载编辑器",
preview: "预览",
hidePreview: "隐藏预览",
split: "并排",
copy: "复制",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
}
: undefined
}
/>
);
}
```
## Fullscreen and localization
Fullscreen defaults to a fixed viewport overlay. Use `fullscreen={{ mode: "screen" }}` for the browser Fullscreen API, or `fullscreen={false}` to hide the action. `value`, `defaultValue`, and `onChange` inside the fullscreen options support controlled or uncontrolled fullscreen state. Browser fullscreen requires API support and permission; a failed request returns to the non-fullscreen state. The fixed overlay supports Escape, contains keyboard focus, and restores focus on exit. Accessible same-origin iframe controls participate in the focus cycle in DOM order; Escape inside the built-in HTML preview also exits. Frame loading and preview-mode changes refresh keyboard bindings. A cross-origin or opaque custom iframe is treated as one focus entry: the parent cannot intercept keys inside its inaccessible document, so retain an outer exit control.
Translate the `labels` for preview, split view, copy, fullscreen, loading, and failure messages. The examples keep user content in local component state; the component does not save documents or preferences for you. Monaco theme settings are shared within a page; keep multiple Editors on a consistent theme rather than relying on isolated light and dark instances.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `value` / `defaultValue` | `string` | Controlled / initial internal content. |
| `onChange` | `(value: string) => void` | Reports content changes. |
| `language` | `string` | `"plaintext"`. |
| `onLanguageChange` | `(language: string) => void` | Requests selection; the caller updates `language`. |
| `languages` | `readonly EditorLanguageOption[]` | Common languages; each item has `value` and `label`. |
| `toolbarLanguage` | `boolean` | `true`; shows the picker when a callback is supplied. |
| `height` | `string \| number` | Controls editing area height; numbers are pixels. |
| `disabled` | `boolean` | `false`; read-only editing. |
| `toolbar`, `toolbarTitle` | `ReactNode \| render function` | Custom actions and heading; toolbar also accepts `false`. |
| `toolbarMode`, `toolbarCopy` | `boolean` | Both `true`. |
| `preview` | Preview options | Custom renderer, mode and mode-change callback. |
| `fullscreen` | `false \| object` | Fixed overlay by default. |
| `size` | Button size | `"icon-sm"` for toolbar actions. |
| `labels` | `Partial` | English feedback and action names. |
| `glass` | `boolean` | `false`; opt-in glass surface. |
`EditorProps`, `EditorLanguageOption`, `EditorLabels`, `EditorViewMode`, `EditorFullscreenMode`, and `EditorToolbarActionContext` are exported. The editor uses Monaco; see the [Monaco API](https://microsoft.github.io/monaco-editor/docs.html). For glass setup, see [Glass](/docs/components/glass).
---
# Empty
Use the Empty component to display an empty state.
Page: https://sui.draco.dev/docs/components/empty
### Example: empty-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import { ArrowUpRightIcon, FolderCodeIcon } from "lucide-react";
export default function EmptyDemo() {
return (
No Projects Yet
You haven't created any projects yet. Get started by creating
your first project.
Create Project
Import Project
}
className="text-muted-foreground"
size="sm"
nativeButton={false}
>
Learn More
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/empty
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty"
```
```tsx
No data
No data found
Add data
```
## Composition
Use the following composition to build an `Empty` state:
```text
Empty
├── EmptyHeader
│ ├── EmptyMedia
│ ├── EmptyTitle
│ └── EmptyDescription
└── EmptyContent
```
## Outline
Use the `border` utility class to create an outline empty state.
### Example: empty-outline
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import { CloudIcon } from "lucide-react";
export default function EmptyOutline() {
return (
Cloud Storage Empty
Upload files to your cloud storage to access them anywhere.
Upload Files
);
}
```
## Background
Use the `bg-*` and `bg-gradient-*` utilities to add a background to the empty state.
### Example: empty-background
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import { BellIcon, RefreshCcwIcon } from "lucide-react";
export function EmptyMuted() {
return (
No Notifications
You're all caught up. New notifications will appear here.
Refresh
);
}
export default EmptyMuted;
```
## Avatar
Use the `EmptyMedia` component to display an avatar in the empty state.
### Example: empty-avatar
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
export default function EmptyAvatar() {
return (
LR
User Offline
This user is currently offline. You can leave a message to notify them
or try again later.
Leave Message
);
}
```
## Avatar Group
Use the `EmptyMedia` component to display an avatar group in the empty state.
### Example: empty-avatar-group
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import { PlusIcon } from "lucide-react";
export default function EmptyAvatarGroup() {
return (
No Team Members
Invite your team to collaborate on this project.
Invite Members
);
}
```
## InputGroup
You can add an `InputGroup` component to the `EmptyContent` component.
### Example: empty-input-group
```tsx
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@workspace/ui/components/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { Kbd } from "@workspace/ui/components/kbd";
import { SearchIcon } from "lucide-react";
export default function EmptyInputGroup() {
return (
404 - Not Found
The page you're looking for doesn't exist. Try searching for
what you need below.
/
Need help? Contact support
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: empty-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import { ArrowUpRightIcon, FolderCodeIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
title: "No Projects Yet",
description:
"You haven't created any projects yet. Get started by creating your first project.",
createProject: "Create Project",
importProject: "Import Project",
learnMore: "Learn More",
},
},
ar: {
dir: "rtl",
values: {
title: "لا توجد مشاريع بعد",
description: "لم تقم بإنشاء أي مشاريع بعد. ابدأ بإنشاء مشروعك الأول.",
createProject: "إنشاء مشروع",
importProject: "استيراد مشروع",
learnMore: "تعرف على المزيد",
},
},
he: {
dir: "rtl",
values: {
title: "אין פרויקטים עדיין",
description:
"עדיין לא יצרת פרויקטים. התחל על ידי יצירת הפרויקט הראשון שלך.",
createProject: "צור פרויקט",
importProject: "ייבא פרויקט",
learnMore: "למד עוד",
},
},
};
export function EmptyRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.title}
{t.description}
{t.createProject}
{t.importProject}
}
className="text-muted-foreground"
size="sm"
nativeButton={false}
>
{t.learnMore}{" "}
);
}
export default EmptyRtl;
```
## API Reference
### Empty
The main component of the empty state. Wraps the `EmptyHeader` and `EmptyContent` components.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
```
### EmptyHeader
The `EmptyHeader` component wraps the empty media, title, and description.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
```
### EmptyMedia
Use the `EmptyMedia` component to display the media of the empty state such as an icon or an image. You can also use it to display other components such as an avatar.
| Prop | Type | Default |
| ----------- | --------------------- | --------- |
| `variant` | `"default" \| "icon"` | `default` |
| `className` | `string` | |
```tsx
```
```tsx
CN
```
### EmptyTitle
Use the `EmptyTitle` component to display the title of the empty state.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
No data
```
### EmptyDescription
Use the `EmptyDescription` component to display the description of the empty state.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
You do not have any notifications.
```
### EmptyContent
Use the `EmptyContent` component to display the content of the empty state such as a button, input or a link.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
Add Project
```
---
# Field
Combine labels, controls, and help text to compose accessible form fields and grouped inputs.
Page: https://sui.draco.dev/docs/components/field
### Example: field-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
const months = [
{ label: "MM", value: null },
{ label: "01", value: "01" },
{ label: "02", value: "02" },
{ label: "03", value: "03" },
{ label: "04", value: "04" },
{ label: "05", value: "05" },
{ label: "06", value: "06" },
{ label: "07", value: "07" },
{ label: "08", value: "08" },
{ label: "09", value: "09" },
{ label: "10", value: "10" },
{ label: "11", value: "11" },
{ label: "12", value: "12" },
];
const years = [
{ label: "YYYY", value: null },
{ label: "2024", value: "2024" },
{ label: "2025", value: "2025" },
{ label: "2026", value: "2026" },
{ label: "2027", value: "2027" },
{ label: "2028", value: "2028" },
{ label: "2029", value: "2029" },
];
export default function FieldDemo() {
const previewId = usePreviewId();
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/field
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@workspace/ui/components/field"
```
```tsx showLineNumbers
Profile
This appears on invoices and emails.
Full name
This appears on invoices and emails.
Username
Choose another username.
Subscribe to the newsletter
```
## Composition
### Field
A single control with label, helper text, and validation.
```text
Field
├── FieldLabel
├── Input / Textarea / Switch / Select
├── FieldDescription
└── FieldError
```
### FieldGroup
Related fields in one group. Use `FieldSeparator` between sections when needed.
```text
FieldGroup
├── Field
│ ├── FieldLabel
│ ├── Input / Textarea / Switch / Select
│ ├── FieldDescription
│ └── FieldError
├── FieldSeparator
└── Field
├── FieldLabel
└── Input / Textarea / Switch / Select
```
### FieldSet
Semantic grouping with a legend and description, usually containing a `FieldGroup`.
```text
FieldSet
├── FieldLegend
├── FieldDescription
└── FieldGroup
├── Field
│ ├── FieldLabel
│ ├── Input / Textarea / Switch / Select
│ ├── FieldDescription
│ └── FieldError
└── Field
├── FieldLabel
└── Input / Textarea / Switch / Select
```
## Anatomy
The `Field` family is designed for composing accessible forms. A typical field is structured as follows:
```tsx showLineNumbers
Label
{/* Input, Select, Switch, etc. */}
Optional helper text.
Validation message.
```
- `Field` is the core wrapper for a single field.
- `FieldContent` is a flex column that groups label and description. Not required if you have no description.
- Wrap related fields with `FieldGroup`, and use `FieldSet` with `FieldLegend` for semantic grouping.
## Form
See the [Form](https://ui.shadcn.com/docs/forms) documentation for building forms with the `Field` component and [React Hook Form](https://ui.shadcn.com/docs/forms/react-hook-form), [Tanstack Form](https://ui.shadcn.com/docs/forms/tanstack-form), or [Formisch](https://ui.shadcn.com/docs/forms/formisch).
## Input
### Example: field-input
```tsx
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSet,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export default function FieldInput() {
const previewId = usePreviewId();
return (
Username
Choose a unique username for your account.
Password
Must be at least 8 characters long.
);
}
```
## Textarea
### Example: field-textarea
```tsx
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSet,
} from "@workspace/ui/components/field";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
export default function FieldTextarea() {
const previewId = usePreviewId();
return (
Feedback
Share your thoughts about our service.
);
}
```
## Select
### Example: field-select
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
const items = [
{ label: "Choose department", value: null },
{ label: "Engineering", value: "engineering" },
{ label: "Design", value: "design" },
{ label: "Marketing", value: "marketing" },
{ label: "Sales", value: "sales" },
{ label: "Customer Support", value: "support" },
{ label: "Human Resources", value: "hr" },
{ label: "Finance", value: "finance" },
{ label: "Operations", value: "operations" },
];
export default function FieldSelect() {
return (
Department
{items.map((item) => (
{item.label}
))}
Select your department or area of work.
);
}
```
## Slider
### Example: field-slider
```tsx
"use client";
import {
Field,
FieldDescription,
FieldTitle,
} from "@workspace/ui/components/field";
import { Slider } from "@workspace/ui/components/slider";
import * as React from "react";
export default function FieldSlider() {
const [value, setValue] = React.useState([200, 800]);
return (
Price Range
Set your budget range ($
{value[0]} -{" "}
{value[1]} ).
setValue(value as [number, number])}
max={1000}
min={0}
step={10}
className="mt-2 w-full"
aria-label="Price Range"
/>
);
}
```
## Fieldset
### Example: field-fieldset
```tsx
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function FieldFieldset() {
const previewId = usePreviewId();
return (
Address Information
We need your address to deliver your order.
Street Address
City
Postal Code
);
}
export default FieldFieldset;
```
## Checkbox
### Example: field-checkbox
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export function FieldCheckbox() {
const previewId = usePreviewId();
return (
Show these items on the desktop
Select the items you want to show on the desktop.
Hard disks
External disks
CDs, DVDs, and iPods
Connected servers
Sync Desktop & Documents folders
Your Desktop & Documents folders are being synced with iCloud Drive.
You can access them from other devices.
);
}
export default FieldCheckbox;
```
## Radio
### Example: field-radio
```tsx
import {
Field,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function FieldRadio() {
const previewId = usePreviewId();
return (
Subscription Plan
Yearly and lifetime plans offer significant savings.
Monthly ($9.99/month)
Yearly ($99.99/year)
Lifetime ($299.99)
);
}
export default FieldRadio;
```
## Switch
### Example: field-switch
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export default function FieldSwitch() {
const previewId = usePreviewId();
return (
Multi-factor authentication
);
}
```
## Choice Card
Wrap `Field` components inside `FieldLabel` to create selectable field groups. This works with `RadioItem`, `Checkbox` and `Switch` components.
### Example: field-choice-card
```tsx
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export default function FieldChoiceCard() {
const previewId = usePreviewId();
return (
Compute Environment
Select the compute environment for your cluster.
Kubernetes
Run GPU workloads on a K8s cluster.
Virtual Machine
Access a cluster to run GPU workloads.
);
}
```
## Field Group
Stack `Field` components with `FieldGroup`. Add `FieldSeparator` to divide them.
### Example: field-group
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
FieldSet,
} from "@workspace/ui/components/field";
import { useId as usePreviewId } from "react";
export default function FieldGroupExample() {
const previewId = usePreviewId();
return (
Responses
Get notified when ChatGPT responds to requests that take time, like
research or image generation.
Push notifications
Tasks
Get notified when tasks you've created have updates.{" "}
Manage tasks
Push notifications
Email notifications
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: field-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const months = [
{ label: "MM", value: null },
{ label: "01", value: "01" },
{ label: "02", value: "02" },
{ label: "03", value: "03" },
{ label: "04", value: "04" },
{ label: "05", value: "05" },
{ label: "06", value: "06" },
{ label: "07", value: "07" },
{ label: "08", value: "08" },
{ label: "09", value: "09" },
{ label: "10", value: "10" },
{ label: "11", value: "11" },
{ label: "12", value: "12" },
];
const years = [
{ label: "YYYY", value: null },
{ label: "2024", value: "2024" },
{ label: "2025", value: "2025" },
{ label: "2026", value: "2026" },
{ label: "2027", value: "2027" },
{ label: "2028", value: "2028" },
{ label: "2029", value: "2029" },
];
const translations: Translations = {
en: {
dir: "ltr",
values: {
paymentMethod: "Payment Method",
secureTransactions: "All transactions are secure and encrypted",
nameOnCard: "Name on Card",
cardNumber: "Card Number",
cardNumberDescription: "Enter your 16-digit card number",
month: "Month",
year: "Year",
cvv: "CVV",
monthPlaceholder: "MM",
month01: "01",
month02: "02",
month03: "03",
month04: "04",
month05: "05",
month06: "06",
month07: "07",
month08: "08",
month09: "09",
month10: "10",
month11: "11",
month12: "12",
billingAddress: "Billing Address",
billingAddressDescription:
"The billing address associated with your payment method",
sameAsShipping: "Same as shipping address",
comments: "Comments",
commentsPlaceholder: "Add any additional comments",
submit: "Submit",
cancel: "Cancel",
},
},
ar: {
dir: "rtl",
values: {
paymentMethod: "طريقة الدفع",
secureTransactions: "جميع المعاملات آمنة ومشفرة",
nameOnCard: "الاسم على البطاقة",
cardNumber: "رقم البطاقة",
cardNumberDescription: "أدخل رقم البطاقة المكون من 16 رقمًا",
month: "الشهر",
year: "السنة",
cvv: "CVV",
monthPlaceholder: "ش.ش",
month01: "٠١",
month02: "٠٢",
month03: "٠٣",
month04: "٠٤",
month05: "٠٥",
month06: "٠٦",
month07: "٠٧",
month08: "٠٨",
month09: "٠٩",
month10: "١٠",
month11: "١١",
month12: "١٢",
billingAddress: "عنوان الفوترة",
billingAddressDescription: "عنوان الفوترة المرتبط بطريقة الدفع الخاصة بك",
sameAsShipping: "نفس عنوان الشحن",
comments: "تعليقات",
commentsPlaceholder: "أضف أي تعليقات إضافية",
submit: "إرسال",
cancel: "إلغاء",
},
},
he: {
dir: "rtl",
values: {
paymentMethod: "אמצעי תשלום",
secureTransactions: "כל העסקאות מאובטחות ומוצפנות",
nameOnCard: "שם על הכרטיס",
cardNumber: "מספר כרטיס",
cardNumberDescription: "הזן את מספר הכרטיס בן 16 הספרות שלך",
month: "חודש",
year: "שנה",
cvv: "CVV",
monthPlaceholder: "MM",
month01: "01",
month02: "02",
month03: "03",
month04: "04",
month05: "05",
month06: "06",
month07: "07",
month08: "08",
month09: "09",
month10: "10",
month11: "11",
month12: "12",
billingAddress: "כתובת חיוב",
billingAddressDescription: "כתובת החיוב המשויכת לאמצעי התשלום שלך",
sameAsShipping: "זהה לכתובת המשלוח",
comments: "הערות",
commentsPlaceholder: "הוסף הערות נוספות",
submit: "שלח",
cancel: "בטל",
},
},
};
export function FieldRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
const getMonthLabel = (value: string | null): string => {
if (value === null) return t.monthPlaceholder;
const monthKey = `month${value}` as keyof typeof t;
return t[monthKey] || value;
};
return (
);
}
export default FieldRtl;
```
## Responsive Layout
- **Vertical fields:** Default orientation stacks label, control, and helper text—ideal for mobile-first layouts.
- **Horizontal fields:** Set `orientation="horizontal"` on `Field` to align the label and control side-by-side. Pair with `FieldContent` to keep descriptions aligned.
- **Responsive fields:** Set `orientation="responsive"` for automatic column layouts inside container-aware parents. Apply `@container/field-group` classes on `FieldGroup` to switch orientations at specific breakpoints.
### Example: field-responsive
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function FieldResponsive() {
const previewId = usePreviewId();
return (
);
}
export default FieldResponsive;
```
## Validation and Errors
- Add `data-invalid` to `Field` to switch the entire block into an error state.
- Add `aria-invalid` on the input itself for assistive technologies.
- Render `FieldError` immediately after the control or inside `FieldContent` to keep error messages aligned with the field.
```tsx showLineNumbers /data-invalid/ /aria-invalid/
Email
Enter a valid email address.
```
## Accessibility
- `FieldSet` and `FieldLegend` keep related controls grouped for keyboard and assistive tech users.
- `Field` outputs `role="group"` so nested controls inherit labeling from `FieldLabel` and `FieldLegend` when combined.
- Apply `FieldSeparator` sparingly to ensure screen readers encounter clear section boundaries.
## API Reference
### FieldSet
Container that renders a semantic `fieldset` with spacing presets.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
Delivery
{/* Fields */}
```
### FieldLegend
Legend element for a `FieldSet`. Switch to the `label` variant to align with label sizing.
| Prop | Type | Default |
| ----------- | --------------------- | ---------- |
| `variant` | `"legend" \| "label"` | `"legend"` |
| `className` | `string` | |
```tsx
Notification Preferences
```
The `FieldLegend` has two variants: `legend` and `label`. The `label` variant applies label sizing and alignment. Handy if you have nested `FieldSet`.
### FieldGroup
Layout wrapper that stacks `Field` components and enables container queries for responsive orientations.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
{/* ... */}
{/* ... */}
```
### Field
The core wrapper for a single field. Provides orientation control, invalid state styling, and spacing.
| Prop | Type | Default |
| -------------- | -------------------------------------------- | ------------ |
| `orientation` | `"vertical" \| "horizontal" \| "responsive"` | `"vertical"` |
| `className` | `string` | |
| `data-invalid` | `boolean` | |
```tsx
Remember me
```
### FieldContent
Flex column that groups control and descriptions when the label sits beside the control. Not required if you have no description.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
Notifications
Email, SMS, and push options.
```
### FieldLabel
Label styled for both direct inputs and nested `Field` children.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
Email
```
### FieldTitle
Renders a title with label styling inside `FieldContent`.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
Enable Touch ID
Unlock your device faster.
```
### FieldDescription
Helper text slot that automatically balances long lines in horizontal layouts.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
We never share your email with anyone.
```
### FieldSeparator
Visual divider to separate sections inside a `FieldGroup`. Accepts optional inline content.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
Or continue with
```
### FieldError
Accessible error container that accepts children or an `errors` array (e.g., from `react-hook-form`).
| Prop | Type | Default |
| ----------- | ------------------------------------------ | ------- |
| `errors` | `Array<{ message?: string } \| undefined>` | |
| `className` | `string` | |
```tsx
```
When the `errors` array contains multiple messages, the component renders a list automatically.
`FieldError` also accepts issues produced by any validator that implements [Standard Schema](https://standardschema.dev/), including Zod, Valibot, and ArkType. Pass the `issues` array from the schema result directly to render a unified error list across libraries.
---
# Glass
SVG highlights and CSS frosted surfaces, progressively enhanced with WebGPU refraction.
Page: https://sui.draco.dev/docs/components/glass
## CSS + SVG
CSS Gaussian backdrop blur and SVG edge highlights, without initializing WebGPU or capturing the background. Clear and frosted surfaces use the current theme background, while text keeps its semantic color. Switch the site appearance to compare light and dark modes.
### Example: glass-demo
```tsx
"use client";
import { GlassProvider, GlassSurface } from "@workspace/ui/components/glass";
import { useRef } from "react";
import type { ExampleProps } from "../types";
const tiles = [
"bg-blue-500",
"bg-emerald-500",
"bg-amber-400",
"bg-rose-500",
"bg-violet-500",
"bg-cyan-500",
];
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const scene = useRef(null);
return (
{tiles.map((tile) => (
))}
{chinese ? "普通表面" : "Standard surface"}
{chinese
? "不采样背景,保持默认外观"
: "Keeps the default appearance without sampling the background."}
{chinese ? "清透玻璃" : "Clear glass"}
{chinese
? "轻度模糊与柔和边缘高光,保持背景可辨"
: "A light blur and soft edge highlights keep the background recognizable."}
{chinese ? "磨砂玻璃" : "Frosted glass"}
{chinese
? "更强的模糊和色调遮罩,适合承载正文。无需截图或 GPU"
: "A stronger blur and tint support readable content. No snapshot or GPU is needed."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/glass
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { GlassProvider, GlassSurface } from "@workspace/ui/components/glass";
import { useRef } from "react";
export function GlassPanel() {
const sceneRef = useRef(null);
return (
A glass surface
);
}
```
## How the surface is rendered
Glass starts with directional SVG edge highlights and a CSS backdrop blur. This base appearance is available without a GPU renderer. Server rendering does not depend on a GPU, and the component keeps its ordinary DOM structure and refs.
The provider defaults to `mode="css"`: it does not request a GPU or capture the document. Opt into `mode="auto"` to attempt WebGPU enhancement. When `navigator.gpu` is available and device initialization and background capture both succeed, WebGPU adds real refraction from a DOM snapshot. The presence of `navigator.gpu` alone does not guarantee enhancement. If initialization, sampling, or rendering fails, the SVG and CSS appearance remains in place.
The default treatment adds no border or decorative outer ring: the soft SVG edge defines the outline. Interactive controls retain their keyboard focus rings.
Glass is opt-in: ordinary components keep `glass={false}` by default. `GlassSurface` itself defaults to `glass={true}` and can be disabled for a direct comparison.
The provider shares background capture and rendering settings across its descendants. `captureTarget` can be an element or an element ref; scope it to the visual region you need instead of capturing an unnecessarily large document. In auto mode, instances with the same actual capture target, scene revision, and occlusion set share one background snapshot and one uploaded GPU texture. Each surface uses its own sampling coordinates and rounded outline. Moving a surface updates coordinates without recapturing an unchanged background. Background changes invalidate the snapshot. Portal overlays require another snapshot only when their background or occlusion set differs. Capture uses `html-to-image`; enhancement uses native WebGPU. The Glass runtime uses a static import; the capture dependency and WebGPU renderer load only when enhancement needs them.
CSS, fallback, and enhanced modes share one continuous SVG edge-lighting layer. The GPU handles background refraction and blur without adding a second rim. New frames replace the previous frame only after image decoding completes.
## WebGPU enhancement
The CSS backdrop responds to the underlying page directly. When WebGPU enhancement is active, text changes, ordinary images, grids, resizing, and scrolling can update its background snapshot. Ordinary captures are capped at 5Hz and scrolling captures at 10Hz, with captures serialized through a shared queue, so there is a short delay between a background change and its refracted appearance. This is not a live video feed. Use the provider ref’s `refresh()` when your application needs to request another snapshot.
This demonstration is separate from the CSS + SVG example above. Its status reports the actual rendering mode. The example enables `mode="auto"`, changes background text, and provides refraction, blur, and edge-highlight sliders. Its two independent surfaces share a scene. Scroll the exposed background around the surfaces. Setting strength to `0` lets you compare the captured background with its refracted version when enhancement is active; an unsupported device continues to show the CSS and SVG material.
### Example: glass-dynamic
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
type GlassHandle,
GlassProvider,
GlassSurface,
} from "@workspace/ui/components/glass";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { useEffect, useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
const illustration =
"data:image/svg+xml," +
encodeURIComponent(
' ',
);
const rows = Array.from({ length: 16 }, (_, index) => index + 1);
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const stateLabels = chinese
? {
ready: "当前渲染:WebGPU 增强 + SVG 高光",
fallback: "当前渲染:CSS + SVG(增强不可用)",
loading: "当前渲染:CSS + SVG(等待增强)",
}
: {
ready: "Rendering: WebGPU enhancement + SVG highlights",
fallback: "Rendering: CSS + SVG (enhancement unavailable)",
loading: "Rendering: CSS + SVG (awaiting enhancement)",
};
const id = useId();
const scene = useRef(null);
const controller = useRef(null);
const [rendering, setRendering] = useState<"ready" | "fallback" | "loading">(
"loading",
);
useEffect(() => {
const target = scene.current;
if (!target) return;
const update = () => {
const states = [...target.querySelectorAll('[data-glass="true"]')].map(
(element) => element.getAttribute("data-glass-state"),
);
let next: "ready" | "fallback" | "loading" = "loading";
if (states.length && states.every((state) => state === "ready"))
next = "ready";
else if (states.includes("fallback")) next = "fallback";
setRendering(next);
};
const observer = new MutationObserver(update);
observer.observe(target, {
subtree: true,
attributes: true,
attributeFilter: ["data-glass-state"],
});
update();
return () => observer.disconnect();
}, []);
const [text, setText] = useState("SUI / REFRACTION");
const [strength, setStrength] = useState(22);
const [blur, setBlur] = useState(8);
const [highlight, setHighlight] = useState(0.3);
const controls = [
{
key: "strength",
label: chinese ? "折射强度" : "Refraction strength",
value: strength,
max: 64,
step: 1,
change: setStrength,
},
{
key: "blur",
label: chinese ? "模糊" : "Blur",
value: blur,
max: 24,
step: 1,
change: setBlur,
},
{
key: "highlight",
label: chinese ? "边缘高光" : "Edge highlight",
value: highlight,
max: 1,
step: 0.05,
change: setHighlight,
},
];
return (
{stateLabels[rendering]}
{controls.map(({ key, label, value, max, step, change }) => (
{label}: {value}
change(Number(event.currentTarget.value))}
className="w-full accent-primary"
/>
))}
{rows.map((row) => (
{text}
{chinese ? "网格" : "Grid"} {row}
))}
{chinese ? "文字与图片背景" : "Text and image background"}
{chinese
? "调整滑块或滚动背景,比较折射与原始快照"
: "Adjust the sliders or scroll to compare refraction with the original snapshot."}
{chinese ? "同一场景,独立表面" : "One scene, separate surfaces"}
{chinese
? "同场景共用截图与纹理,独立更新坐标"
: "Matching scenes share a snapshot and texture, with separate sampling coordinates."}
);
}
```
## Inputs, viewers, overlays
Pass `glass` to supported shared components rather than replacing their native controls. Inputs retain their original DOM node, ref, form behavior, and focus ring. Independent buttons, inputs, choice controls, and toolbar actions inherit glass inside a glass container. Layout wrappers, content, and the native input inside an InputGroup reuse their owning surface. An explicit `glass={false}` disables a control or scope. The example uses an ordinary parent surface with independent glass controls, avoiding nested material sampling.
The example combines an editable input, a popover, a dialog, Editor, and Code Viewer. Ordinary content avoids repeated material layers. The dialog input is an independent interactive control and inherits glass, demonstrating nested glass while retaining its native focus ring. Keyboard, focus, copy, and editing behavior remains available.
### Example: glass-composition
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { CodeViewer } from "@workspace/ui/components/code-viewer";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Editor } from "@workspace/ui/components/editor";
import { GlassProvider } from "@workspace/ui/components/glass";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const scene = useRef(null);
const [code, setCode] = useState('const theme = "bamboo";');
return (
);
}
```
[TabBar](/docs/components/tab-bar) provides independent application navigation with a moving glass lens. It shares the same provider and material foundation while keeping routing under application control.
## Materials
Choose `material="clear"` for a more transparent surface or `material="frosted"` for stronger frosting and a denser tint. Clear uses a `6px` blur and a `0.60` CSS tint opacity; frosted uses `8px` and `0.78`. The provider defaults to `frosted`; `GlassSurface` can override the inherited material for one surface. An explicit `options.blur` overrides the material blur. The default demonstration uses CSS and SVG only, with ordinary, clear, and frosted surfaces side by side.
## Themes and fallback
The default tint comes from the current surface background token. It follows light, dark, and accent themes while preserving primary and danger colors. Adjust `strength`, `blur`, `tint`, `tintOpacity`, and `highlight` on the provider for a shared treatment. Soft SVG highlights are confined to the surface edge; `highlight` controls their intensity. The default SVG and CSS appearance retains these edge-light details; both `highlight` and `blur` continue to apply without WebGPU.
When WebGPU or background capture is unavailable, the default frosted surface remains visible. Controls remain usable. The glass effect should improve a surface visually without being required to understand it.
Glass strengthens neutral secondary text and placeholders locally, while preserving the theme's primary, destructive, and focus colors. Check text against the actual background when choosing clear glass or overriding tint opacity; transparency alone cannot guarantee readable contrast over every background. Use a denser tint or an ordinary surface when the underlying content makes text difficult to read.
WebGPU enhancement checks the actual colors of text and input placeholders belonging to the surface, including their alpha, and adjusts the captured background toward a shared tint that supports at least 4.5:1 contrast. Independently painted children and nested glass surfaces handle their own backgrounds. If the collected colors cannot share a readable background, enhancement falls back to CSS and SVG. CSS fallback, generated or SVG text, custom blending, filters, and outer opacity still require checking against the rendered scene.
## Snapshot limitations
The WebGPU enhancement uses DOM capture, which is not a browser compositor screenshot. Cross-origin images need suitable CORS permission; cross-origin iframes cannot be read. Embedded iframe documents, video frames, tainted canvases, and other browser-managed surfaces may be absent or inaccurate in the snapshot. Canvas and other GPU-rendered content are not guaranteed to be captured.
Rapid animation can outpace the capture limit. Use ordinary surfaces or the frosted fallback when accurate live media is important, and keep the capture area modest on mobile devices.
Positive horizontal and vertical scaling preserves the original layout while mapping the snapshot and corner radii to the viewport. Rotation, skew, perspective, and mirrored transforms use the CSS + SVG fallback instead of displaying a misaligned snapshot.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| Provider `mode` | `"css" \| "auto"` | `"css"`; `"auto"` attempts WebGPU refraction. |
| Provider `material` | `"clear" \| "frosted"` | `"frosted"`; shared material. |
| Provider `options` | `GlassOptions` | Shared rendering settings. |
| Provider `captureTarget` | `HTMLElement \| null \| RefObject` | Optional scoped DOM capture target. |
| Provider `ref` | `Ref` | `refresh(): void` requests a snapshot. |
| Options `strength` | `number` | `22`; WebGPU refraction strength. |
| Options `blur` | `number` | Material default; an explicit value overrides CSS and enhanced blur. |
| Options `tint` | `string` | Current surface background color. |
| Options `tintOpacity` | `number` | CSS and enhancement share the material default; an explicit value overrides both. |
| Options `highlight` | `number` | `0.3`. |
| Surface `glass` | `boolean` | `true`; ordinary component props default to `false`. |
| Surface `material` | `"clear" \| "frosted"` | Inherits the provider material. |
| Surface other props | Native div props | Includes `className`, `style`, and `ref`. |
The module exports `GlassProvider`, `GlassSurface`, `GlassOptions`, `GlassCaptureTarget`, `GlassProviderProps`, `GlassMode`, `GlassMaterial`, and `GlassHandle`. SVG and CSS provide the default surface; the optional enhancement uses [WebGPU](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API). The public SUI props above govern component integration.
---
# Hover Card
For sighted users to preview content available behind a link.
Page: https://sui.draco.dev/docs/components/hover-card
### Example: hover-card-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
export default function HoverCardDemo() {
return (
}
>
Hover Here
@nextjs
The React Framework – created and maintained by @vercel.
Joined December 2021
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/hover-card
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card"
```
```tsx showLineNumbers
Hover
The React Framework – created and maintained by @vercel.
```
## Composition
Use the following composition to build a `HoverCard`:
```text
HoverCard
├── HoverCardTrigger
└── HoverCardContent
```
## Trigger Delays
Use `delay` and `closeDelay` on the trigger to control when the card opens and
closes.
```tsx showLineNumbers
Hover
Content
```
## Positioning
Use the `side` and `align` props on `HoverCardContent` to control placement.
```tsx showLineNumbers
Hover
Content
```
## Basic
### Example: hover-card-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
export default function HoverCardDemo() {
return (
}
>
Hover Here
@nextjs
The React Framework – created and maintained by @vercel.
Joined December 2021
);
}
```
## Sides
### Example: hover-card-sides
```tsx
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
const HOVER_CARD_SIDES = ["left", "top", "bottom", "right"] as const;
export function HoverCardSides() {
return (
{HOVER_CARD_SIDES.map((side) => (
}
>
{side}
Hover Card
This hover card appears on the {side} side of the trigger.
))}
);
}
export default HoverCardSides;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: hover-card-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
trigger: "Wireless Headphones",
name: "Wireless Headphones",
price: "$99.99",
"inline-start": "Inline Start",
left: "Left",
top: "Top",
bottom: "Bottom",
right: "Right",
"inline-end": "Inline End",
},
},
ar: {
dir: "rtl",
values: {
trigger: "سماعات لاسلكية",
name: "سماعات لاسلكية",
price: "٩٩.٩٩ $",
"inline-start": "بداية السطر",
left: "يسار",
top: "أعلى",
bottom: "أسفل",
right: "يمين",
"inline-end": "نهاية السطر",
},
},
he: {
dir: "rtl",
values: {
trigger: "אוזניות אלחוטיות",
name: "אוזניות אלחוטיות",
price: "99.99 $",
"inline-start": "תחילת השורה",
left: "שמאל",
top: "למעלה",
bottom: "למטה",
right: "ימין",
"inline-end": "סוף השורה",
},
},
};
const physicalSides = ["left", "top", "bottom", "right"] as const;
const logicalSides = ["inline-start", "inline-end"] as const;
export function HoverCardRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{physicalSides.map((side) => (
}
>
{t[side]}
{t.name}
{t.price}
))}
{logicalSides.map((side) => (
}
>
{t[side]}
{t.name}
{t.price}
))}
);
}
export default HoverCardRtl;
```
## API Reference
See the [Base UI](https://base-ui.com/react/components/hover-card#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/hover-card)
- [API reference](https://base-ui.com/react/components/hover-card#api-reference)
---
# HTML Viewer
An isolated iframe preview for HTML content.
Page: https://sui.draco.dev/docs/components/html-viewer
### Example: html-viewer-demo
```tsx
"use client";
import { HtmlViewer } from "@workspace/ui/components/html-viewer";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const content = `${chinese ? "隔离的 HTML 预览" : "Isolated HTML preview"} ${chinese ? "此按钮仅改变 iframe 内的文字" : "This button changes text only inside the iframe."}
${chinese ? "测试沙箱脚本" : "Test sandboxed script"} `;
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/html-viewer
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { HtmlViewer } from "@workspace/ui/components/html-viewer";
;
```
## Iframe isolation
The component writes `content` to the iframe `srcDoc`. Styles inside the HTML document do not affect the surrounding application, and the application theme is not automatically injected into the document. Include the document styles you want to preview. Give the iframe a descriptive `title`, or translate `labels.preview`.
The default sandbox is `"allow-scripts"`, permitting scripts inside an opaque-origin frame without granting `allow-same-origin`. The first example demonstrates a button that changes only the iframe content.
## Restricting scripts
Set `sandbox=""` for a static preview without script execution. Pass other native iframe attributes, such as `allow`, `loading`, and `ref`, when needed. Changing sandbox permissions changes the capabilities of the content; keep permissions limited to what your preview requires.
### Example: html-viewer-sandbox
```tsx
"use client";
import { HtmlViewer } from "@workspace/ui/components/html-viewer";
import { Label } from "@workspace/ui/components/label";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const [content, setContent] = useState(
chinese
? "静态 HTML 没有脚本权限的预览。
"
: "Static HTML A preview without script permission.
",
);
return (
{chinese ? "HTML 源码" : "HTML source"}
);
}
```
## Layout and glass
The iframe defaults to filling its container. Set an explicit height on the component or its parent. `glass` styles the outer surface; it does not refract, capture, or theme the separate iframe document. Glass background snapshots cannot reliably include iframe contents.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `content` | `string` | Required HTML source; mapped to `srcDoc`. |
| `sandbox` | Native iframe sandbox | `"allow-scripts"`. |
| `title` | `string` | `labels.preview` or `"HTML preview"`. |
| `labels` | `{ preview?: string }` | Fallback accessible title. |
| `glass` | `boolean` | `false`. |
| Other props | Native iframe props | Forwarded, including `className`, `ref`, and `loading`. |
The module exports `HtmlViewerProps`. See the [iframe element documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) for sandbox permissions. For source editing with preview, use [Editor](/docs/components/editor).
---
# Image Viewer
A dialog image viewer with navigation, zoom, rotation, and panning.
Page: https://sui.draco.dev/docs/components/image-viewer
### Example: image-viewer-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ImageViewer } from "@workspace/ui/components/image-viewer";
import { useState } from "react";
import type { ExampleProps } from "../types";
const colors = ["#70866A", "#648493", "#BC6C73"];
const images = colors.map(
(color, index) =>
"data:image/svg+xml," +
encodeURIComponent(
`SUI ${index + 1} `,
),
);
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [open, setOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
return (
{images.map((image, index) => (
{
setInitialIndex(index);
setOpen(true);
}}
>
))}
setOpen(false)}
alt={chinese ? "示例图案" : "Example pattern"}
labels={
chinese
? {
defaultAlt: "图片",
loading: "正在加载图片",
error: "无法加载图片",
retry: "重试",
viewer: "图片查看器",
zoomOut: "缩小",
zoomIn: "放大",
rotateCounterclockwise: "逆时针旋转",
rotateClockwise: "顺时针旋转",
reset: "重置图片",
close: "关闭图片查看器",
previous: "上一张",
next: "下一张",
imageAlt: (alt, index) => `${alt} ${index}`,
open: (alt, index) => `打开${alt} ${index}`,
thumbnail: (alt, index) => `${alt}缩略图 ${index}`,
position: (index, total) => `第 ${index} 张,共 ${total} 张`,
}
: undefined
}
/>
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/image-viewer
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Button } from "@workspace/ui/components/button";
import { ImageViewer } from "@workspace/ui/components/image-viewer";
import { useState } from "react";
const images = ["/photos/first.jpg", "/photos/second.jpg"];
export function Gallery() {
const [open, setOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
return (
<>
{images.map((src, index) => (
{
setInitialIndex(index);
setOpen(true);
}}
>
))}
setOpen(false)}
alt="Photo gallery"
/>
>
);
}
```
## Opening and navigation
Opening is controlled through `open` and `onClose`. Supply one image URL or an array. With multiple images, navigation controls and thumbnails let the reader switch images. Zoom, rotation, reset, and panning actions are available inside the viewer. Empty image arrays render nothing. Wheel and pinch gestures zoom, and dragging pans. Arrow keys navigate, `+`/`-` zoom, and `0` resets the transform. Loading failures provide a retry action. Replace the image paths in the snippet with your own assets.
## Controlled image index
`initialIndex` sets the initial uncontrolled image, using zero-based indexes. For external selection, pass `index` and update it in `onIndexChange`. Selecting another image resets its transform. Reset is enabled only after zooming, rotating, or panning changes the image. The example opens a specific image and displays the selected zero-based index. Opening an uncontrolled viewer restores `initialIndex`; changing `initialIndex` while open also updates the selection.
### Example: image-viewer-controlled
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ImageViewer } from "@workspace/ui/components/image-viewer";
import { useState } from "react";
import type { ExampleProps } from "../types";
const colors = ["#70866A", "#648493", "#BC6C73"];
const images = colors.map(
(color, index) =>
"data:image/svg+xml," +
encodeURIComponent(
`SUI ${index + 1} `,
),
);
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
return (
{images.map((image, imageIndex) => (
{
setIndex(imageIndex);
setOpen(true);
}}
>
))}
{chinese
? `选中索引:${index}(从 0 开始)`
: `Selected index: ${index} (zero-based)`}
setOpen(false)}
index={index}
onIndexChange={setIndex}
alt={chinese ? "几何图案" : "Geometric pattern"}
labels={
chinese
? {
defaultAlt: "图片",
loading: "正在加载图片",
error: "无法加载图片",
retry: "重试",
viewer: "图片查看器",
zoomOut: "缩小",
zoomIn: "放大",
rotateCounterclockwise: "逆时针旋转",
rotateClockwise: "顺时针旋转",
reset: "重置图片",
close: "关闭图片查看器",
previous: "上一张",
next: "下一张",
imageAlt: (alt, index) => `${alt} ${index}`,
open: (alt, index) => `打开${alt} ${index}`,
thumbnail: (alt, index) => `${alt}缩略图 ${index}`,
position: (index, total) => `第 ${index} 张,共 ${total} 张`,
}
: undefined
}
/>
);
}
```
## Labels, focus, and portal container
The viewer uses the shared [Dialog](/docs/components/dialog) for modal focus management and dismissal. `alt` describes the images, and `labels` customizes dialog, navigation, zoom, rotation, thumbnail, and position announcements. Function labels receive one-based image positions, while `index` and `onIndexChange` use zero-based indexes.
Use `container` to choose a Portal container when embedding the viewer in a local themed region. The glass effect applies to its surrounding dialog surface; the image itself remains clear.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `images` | `string \| string[]` | Required image URL(s). |
| `open` | `boolean` | Required controlled dialog state. |
| `onClose` | `() => void` | Required close callback. |
| `index` | `number` | Controlled zero-based image index. |
| `initialIndex` | `number` | `0`; initial uncontrolled image. |
| `onIndexChange` | `(index: number) => void` | Reports selected zero-based index. |
| `alt` | `string` | `labels.defaultAlt` or `"Image"`. |
| `container` | `HTMLElement \| ShadowRoot \| RefObject \| null` | Optional Portal container. |
| `labels` | `Partial` | English action and image labels. |
| `glass` | `boolean` | `false`. |
The module exports `ImageViewerProps` and `ImageViewerLabels`. The underlying modal behavior is documented in the [Base UI Dialog API](https://base-ui.com/react/components/dialog).
---
# Inline Copy Text
Inline code that stays readable and copies its value when activated.
Page: https://sui.draco.dev/docs/components/inline-copy-text
### Example: inline-copy-text-demo
```tsx
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "在终端运行 " : "Run "}
bun run dev
{zh
? " 启动开发服务"
: " in your terminal to start the development server."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/inline-copy-text
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
```tsx
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
```
## Usage
```tsx
Run bun run dev to start.
```
This is a compact, borderless inline code button with keyboard support. It does not submit a surrounding form. Pressing it does not scale or move the text; copy, success, and failure icons share a fixed position so feedback does not change its width. The check mark appears after the asynchronous write succeeds; failure is announced and sent to `onCopyError`.
## Custom display content
Set `value` whenever `children` is a React element rather than a string. The displayed content and copied plain text can differ. `variant="muted"` blends the control into surrounding prose.
### Example: inline-copy-text-custom-value
```tsx
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{zh ? "工作区路径" : "Workspace path"}
packages/ui/…
{zh
? "悬停整行或用键盘聚焦即可显示图标;value 指定完整复制值"
: "Hover the row or focus the control to reveal its icon. The value prop supplies the full path."}
);
}
```
## Resource row
Add an unnamed `group` to the enclosing row to reveal the copy icon when the row is hovered or receives focus within. The icon always reserves its space and changes only visibility; it remains visible on touch devices. A successful checkmark remains until the feedback timer resets.
```tsx
Production database
database_72c31
;
```
## Size and disabled state
Use `size="sm"` for compact text, `truncate={false}` to show the complete visible content, or `disabled` to prevent copying.
### Example: inline-copy-text-disabled
```tsx
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
packages/ui/src/components/inline-copy-text.tsx
{zh ? "暂不可复制" : "Copying unavailable"}
);
}
```
## Feedback
```tsx
console.log(value)}
onCopyError={(error) => console.error(error)}
>
Workspace ID
```
Clipboard access is requested only on activation. If Clipboard API access is unavailable, select and copy the text manually. Pending writes cannot overlap; stale writes cannot overwrite feedback for a changed value or an unmounted control.
## API
| Prop | Type | Default |
| --- | --- | --- |
| `children` | `ReactNode` | Required |
| `value` | `string` | String children |
| `variant` | `"default" \| "muted"` | `"default"` |
| `size` | `"sm" \| "default"` | `"default"` |
| `truncate` | `boolean` | `true` |
| `iconVisibility` | `"hover"` (default) or `"always"`; controls whether the idle copy icon stays visible. |
| `disabled` | `boolean` | `false` |
| `resetDelay` | `number`, milliseconds | `1500` |
| `onCopy` | `(value: string) => void` | — |
| `onCopyError` | `(error: Error) => void` | — |
| `labels` | `{ copy?, pending?, copied?, failed? }` | English labels |
Uses the Base UI Button primitive and supports `ref`, `render`, `onClick`, and `className`. Its inline styling does not inherit the regular Button press movement. Calling `event.preventDefault()` in `onClick` cancels copying. `data-copy-status` exposes `idle`, `pending`, `copied`, or `error`. See [Base UI Button](https://base-ui.com/react/components/button) for composition.
## Icon motion
The copy icon morphs into the success checkmark on the same SVG path, keeps a fixed size, and respects reduced motion. Pending and error states retain their feedback. Import the generic `MorphIcon` from `@workspace/ui/components/morph-icon`: it accepts an `IconNode` or path string, animates changes to `icon`, and defaults to `spring="snappy"` and `reducedMotion="user"`. Size, stroke width, and animation options can be overridden. The `CopyIcon` adapter at `@workspace/ui/components/copy-icon` accepts `status` or the `copied`, `pending`, and `error` flags.
---
# Input
A text input component for forms and user data entry with built-in styling and accessibility features.
Page: https://sui.draco.dev/docs/components/input
### Example: input-demo
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputDemo() {
const previewId = usePreviewId();
return (
API Key
Your API key is encrypted and stored securely.
);
}
export default InputDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/input
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Input } from "@workspace/ui/components/input"
```
```tsx
```
## Basic
### Example: input-basic
```tsx
import { Input } from "@workspace/ui/components/input";
export function InputBasic() {
return ;
}
export default InputBasic;
```
## Field
Use `Field`, `FieldLabel`, and `FieldDescription` to create an input with a
label and description.
### Example: input-field
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputField() {
const previewId = usePreviewId();
return (
Username
Choose a unique username for your account.
);
}
export default InputField;
```
## Field Group
Use `FieldGroup` to show multiple `Field` blocks and to build forms.
### Example: input-fieldgroup
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputFieldgroup() {
const previewId = usePreviewId();
return (
Name
Email
We'll send updates to this address.
Reset
Submit
);
}
export default InputFieldgroup;
```
## Disabled
Use the `disabled` prop to disable the input. To style the disabled state, add the `data-disabled` attribute to the `Field` component.
### Example: input-disabled
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputDisabled() {
const previewId = usePreviewId();
return (
Email
This field is currently disabled.
);
}
export default InputDisabled;
```
## Invalid
Use the `aria-invalid` prop to mark the input as invalid. To style the invalid state, add the `data-invalid` attribute to the `Field` component.
### Example: input-invalid
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputInvalid() {
const previewId = usePreviewId();
return (
Invalid Input
This field contains validation errors.
);
}
export default InputInvalid;
```
## File
Use the `type="file"` prop to create a file input.
### Example: input-file
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputFile() {
const previewId = usePreviewId();
return (
Picture
Select a picture to upload.
);
}
export default InputFile;
```
## Inline
Use `Field` with `orientation="horizontal"` to create an inline input.
Pair with `Button` to create a search input with a button.
### Example: input-inline
```tsx
import { Button } from "@workspace/ui/components/button";
import { Field } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
export function InputInline() {
return (
Search
);
}
export default InputInline;
```
## Grid
Use a grid layout to place multiple inputs side by side.
### Example: input-grid
```tsx
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputGrid() {
const previewId = usePreviewId();
return (
First Name
Last Name
);
}
export default InputGrid;
```
## Required
Use the `required` attribute to indicate required inputs.
### Example: input-required
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputRequired() {
const previewId = usePreviewId();
return (
Required Field *
This field must be filled out.
);
}
export default InputRequired;
```
## Badge
Use `Badge` in the label to highlight a recommended field.
### Example: input-badge
```tsx
import { Badge } from "@workspace/ui/components/badge";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputBadge() {
const previewId = usePreviewId();
return (
Webhook URL{" "}
Beta
);
}
export default InputBadge;
```
## Input Group
To add icons, text, or buttons inside an input, use the `InputGroup` component. See the [Input Group](/docs/components/input-group) component for more examples.
### Example: input-input-group
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@workspace/ui/components/input-group";
import { InfoIcon } from "lucide-react";
import { useId as usePreviewId } from "react";
export function InputInputGroup() {
const previewId = usePreviewId();
return (
Website URL
https://
);
}
export default InputInputGroup;
```
## Button Group
To add buttons to an input, use the `ButtonGroup` component. See the [Button Group](/docs/components/button-group) component for more examples.
### Example: input-button-group
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
export function InputButtonGroup() {
const previewId = usePreviewId();
return (
Search
Search
);
}
export default InputButtonGroup;
```
## Form
A full form example with multiple inputs, a select, and a button.
### Example: input-form
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { useId as usePreviewId } from "react";
export function InputForm() {
const previewId = usePreviewId();
const countries = [
{ label: "United States", value: "us" },
{ label: "United Kingdom", value: "uk" },
{ label: "Canada", value: "ca" },
];
return (
);
}
export default InputForm;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: input-rtl
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
apiKey: "API Key",
placeholder: "sk-...",
description: "Your API key is encrypted and stored securely.",
},
},
ar: {
dir: "rtl",
values: {
apiKey: "مفتاح API",
placeholder: "sk-...",
description: "مفتاح API الخاص بك مشفر ومخزن بأمان.",
},
},
he: {
dir: "rtl",
values: {
apiKey: "מפתח API",
placeholder: "sk-...",
description: "מפתח ה-API שלך מוצפן ונשמר בצורה מאובטחת.",
},
},
};
export function InputRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.apiKey}
{t.description}
);
}
export default InputRtl;
```
---
# Input Group
Add addons, buttons, and helper content to inputs.
Page: https://sui.draco.dev/docs/components/input-group
### Example: input-group-demo
```tsx
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { Search } from "lucide-react";
export function InputGroupDemo() {
return (
12 results
);
}
export default InputGroupDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/input-group
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@workspace/ui/components/input-group"
```
```tsx showLineNumbers
```
## Composition
Use the following composition to build an `InputGroup`:
```text
InputGroup
├── InputGroupInput or InputGroupTextarea
├── InputGroupAddon
├── InputGroupButton
└── InputGroupText
```
## Align
Use the `align` prop on `InputGroupAddon` to position the addon relative to the input.
For proper focus management, `InputGroupAddon` should always be placed after
`InputGroupInput` or `InputGroupTextarea` in the DOM. Use the `align` prop to
visually position the addon.
### inline-start
Use `align="inline-start"` to position the addon at the start of the input. This is the default.
### Example: input-group-inline-start
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { SearchIcon } from "lucide-react";
import { useId as usePreviewId } from "react";
export function InputGroupInlineStart() {
const previewId = usePreviewId();
return (
Input
Icon positioned at the start.
);
}
export default InputGroupInlineStart;
```
### inline-end
Use `align="inline-end"` to position the addon at the end of the input.
### Example: input-group-inline-end
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { EyeOffIcon } from "lucide-react";
import { useId as usePreviewId } from "react";
export function InputGroupInlineEnd() {
const previewId = usePreviewId();
return (
Input
Icon positioned at the end.
);
}
export default InputGroupInlineEnd;
```
### block-start
Use `align="block-start"` to position the addon above the input.
### Example: input-group-block-start
```tsx
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@workspace/ui/components/input-group";
import { CopyIcon, FileCodeIcon } from "lucide-react";
import { useId as usePreviewId } from "react";
export function InputGroupBlockStart() {
const previewId = usePreviewId();
return (
Input
Full Name
Header positioned above the input.
Textarea
script.js
Copy
Header positioned above the textarea.
);
}
export default InputGroupBlockStart;
```
### block-end
Use `align="block-end"` to position the addon below the input.
### Example: input-group-block-end
```tsx
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@workspace/ui/components/input-group";
import { useId as usePreviewId } from "react";
export function InputGroupBlockEnd() {
const previewId = usePreviewId();
return (
Input
USD
Footer positioned below the input.
Textarea
0/280
Post
Footer positioned below the textarea.
);
}
export default InputGroupBlockEnd;
```
## Icon
### Example: input-group-icon
```tsx
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import {
CheckIcon,
CreditCardIcon,
InfoIcon,
MailIcon,
SearchIcon,
StarIcon,
} from "lucide-react";
export default function InputGroupIcon() {
return (
);
}
```
## Text
### Example: input-group-text
```tsx
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@workspace/ui/components/input-group";
export default function InputGroupTextExample() {
return (
$
USD
https://
.com
@company.com
120 characters left
);
}
```
## Button
### Example: input-group-button
```tsx
"use client";
import { CopyIcon } from "@workspace/ui/components/copy-icon";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { useClipboard } from "@workspace/ui/hooks/use-clipboard";
import { InfoIcon, StarIcon } from "lucide-react";
import { useId, useState } from "react";
const profileUrl = "https://x.com/shadcn";
export default function InputGroupButtonExample() {
const addressId = useId();
const { copy, status } = useClipboard(profileUrl);
const [favorite, setFavorite] = useState(false);
const [query, setQuery] = useState("");
const [message, setMessage] = useState("");
return (
copy()}
>
{status === "error" && (
Copy failed. Select the link and copy it manually.
)}
}
>
URL details
This example uses HTTPS. Edit the address to try another URL.
https://
setFavorite((value) => !value)}
size="icon-xs"
>
);
}
```
## Kbd
### Example: input-group-kbd
```tsx
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { Kbd } from "@workspace/ui/components/kbd";
import { SearchIcon } from "lucide-react";
export function InputGroupKbd() {
return (
⌘K
);
}
export default InputGroupKbd;
```
## Dropdown
### Example: input-group-dropdown
```tsx
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { ChevronDownIcon, MoreHorizontal } from "lucide-react";
export function InputGroupDropdown() {
return (
}
>
Settings
Copy path
Open location
}
>
Search In...
Documentation
Blog Posts
Changelog
);
}
export default InputGroupDropdown;
```
## Loader
### Example: input-group-loading
```tsx
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@workspace/ui/components/input-group";
import { Loader } from "@workspace/ui/components/loader";
export default function InputGroupLoading() {
return (
Saving...
Please wait...
);
}
```
## Textarea
### Example: input-group-textarea
```tsx
"use client";
import { CopyIcon } from "@workspace/ui/components/copy-icon";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupTextarea,
} from "@workspace/ui/components/input-group";
import { toast } from "@workspace/ui/components/toast";
import { useClipboard } from "@workspace/ui/hooks/use-clipboard";
import { CornerDownLeftIcon, FileCodeIcon, RefreshCwIcon } from "lucide-react";
import { useId, useState } from "react";
const initialCode = "console.log('Hello, world!');";
export default function InputGroupTextareaExample() {
const sourceId = useId();
const [code, setCode] = useState(initialCode);
const [preview, setPreview] = useState("");
const { copy, status } = useClipboard(code, {
onCopyError: () =>
toast.add({
title: "Copy failed",
description: "Select the code and copy it manually.",
}),
});
return (
setCode(event.target.value)}
className="min-h-[200px]"
/>
script.js
{
setCode(initialCode);
setPreview("");
}}
>
copy()}
>
{code.split("\n").length} lines
setPreview(code)}
disabled={!code.trim()}
>
Preview
{preview && (
{preview}
)}
);
}
```
## Custom Input
Add the `data-slot="input-group-control"` attribute to your custom input for automatic focus state handling.
Here's an example of a custom resizable textarea from a third-party library.
### Example: input-group-custom
```tsx
"use client";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@workspace/ui/components/input-group";
import TextareaAutosize from "react-textarea-autosize";
export default function InputGroupCustom() {
return (
Submit
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: input-group-rtl
```tsx
"use client";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@workspace/ui/components/input-group";
import { Loader } from "@workspace/ui/components/loader";
import { Search } from "lucide-react";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
placeholder: "Search...",
results: "12 results",
searching: "Searching...",
saving: "Saving...",
savingChanges: "Saving changes...",
textareaLabel: "Textarea",
textareaPlaceholder: "Write a comment...",
characterCount: "0/280",
post: "Post",
textareaDescription: "Footer positioned below the textarea.",
},
},
ar: {
dir: "rtl",
values: {
placeholder: "بحث...",
results: "١٢ نتيجة",
searching: "جاري البحث...",
saving: "جاري الحفظ...",
savingChanges: "جاري حفظ التغييرات...",
textareaLabel: "منطقة النص",
textareaPlaceholder: "اكتب تعليقًا...",
characterCount: "٠/٢٨٠",
post: "نشر",
textareaDescription: "تذييل موضع أسفل منطقة النص.",
},
},
he: {
dir: "rtl",
values: {
placeholder: "חפש...",
results: "12 תוצאות",
searching: "מחפש...",
saving: "שומר...",
savingChanges: "שומר שינויים...",
textareaLabel: "אזור טקסט",
textareaPlaceholder: "כתוב תגובה...",
characterCount: "0/280",
post: "פרסם",
textareaDescription: "כותרת תחתונה ממוקמת מתחת לאזור הטקסט.",
},
},
};
export function InputGroupRtl() {
const previewId = usePreviewId();
const { t } = useTranslation(translations, "ar");
return (
{t.results}
{t.saving}
{t.textareaLabel}
{t.characterCount}
{t.post}
{t.textareaDescription}
);
}
export default InputGroupRtl;
```
## API Reference
### InputGroup
The main component that wraps inputs and addons.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
```tsx
```
### InputGroupAddon
Displays icons, text, buttons, or other content alongside inputs.
**Focus Navigation**
For proper focus navigation, the `InputGroupAddon` component should be placed
after the input. Set the `align` prop to position the addon.
| Prop | Type | Default |
| ----------- | ---------------------------------------------------------------- | ---------------- |
| `align` | `"inline-start" \| "inline-end" \| "block-start" \| "block-end"` | `"inline-start"` |
| `className` | `string` | |
```tsx
```
**For ` `, use the `inline-start` or `inline-end` alignment. For ` `, use the `block-start` or `block-end` alignment.**
The `InputGroupAddon` component can have multiple `InputGroupButton` components and icons.
```tsx
Button
Button
```
### InputGroupButton
Displays buttons within input groups.
| Prop | Type | Default |
| ----------- | ----------------------------------------------------------------------------- | --------- |
| `size` | `"xs" \| "icon-xs" \| "sm" \| "icon-sm"` | `"xs"` |
| `variant` | `"default" \| "destructive" \| "outline" \| "secondary" \| "ghost" \| "link"` | `"ghost"` |
| `className` | `string` | |
```tsx
Button
```
### InputGroupInput
Replacement for ` ` when building input groups. This component has the input group styles pre-applied and uses the unified `data-slot="input-group-control"` for focus state handling.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
All other props are passed through to the underlying ` ` component.
```tsx
```
### InputGroupTextarea
Replacement for `` when building input groups. This component has the textarea group styles pre-applied and uses the unified `data-slot="input-group-control"` for focus state handling.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | |
All other props are passed through to the underlying `` component.
```tsx
Send
```
---
# Input OTP
Individual code inputs with character filtering and asynchronous verification feedback
Page: https://sui.draco.dev/docs/components/input-otp
### Example: input-otp-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
type InputOTPStatus,
} from "@workspace/ui/components/input-otp";
import { Label } from "@workspace/ui/components/label";
import { useEffect, useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({
locale,
mask = false,
}: ExampleProps & { mask?: boolean }) {
const chinese = locale === "zh-CN";
const id = useId();
const [value, setValue] = useState("");
const [status, setStatus] = useState("idle");
const [result, setResult] = useState("");
const inputRef = useRef(null);
const request = useRef(0);
const timer = useRef | undefined>(undefined);
useEffect(
() => () => {
request.current++;
clearTimeout(timer.current);
},
[],
);
function verify(code: string) {
const run = ++request.current;
clearTimeout(timer.current);
setStatus("loading");
setResult(chinese ? "正在验证" : "Verifying code");
const verification = new Promise((resolve) => {
timer.current = setTimeout(() => resolve(code === "123456"), 1100);
});
void verification.then((valid) => {
if (request.current !== run) return;
setStatus(valid ? "success" : "error");
const messages = chinese
? {
success: "验证成功",
error: "验证失败,输入已保留,可修改或重新输入",
}
: {
success: "Verified",
error:
"Verification failed. Your code is preserved for editing or retry.",
};
setResult(messages[valid ? "success" : "error"]);
});
}
return (
{chinese
? "输入 123456 查看成功效果,其他六位数字显示失败"
: "Enter 123456 to succeed; any other six digits show the error feedback."}
{chinese ? "验证码" : "Verification code"}
{
setValue(value);
setResult("");
}}
onValueComplete={verify}
status={status}
onStatusChange={setStatus}
>
{[0, 1, 2, 3, 4, 5].map((index) => (
))}
{result}
{
request.current++;
clearTimeout(timer.current);
setValue("");
setStatus("idle");
setResult("");
inputRef.current?.focus();
}}
>
{chinese ? "重新输入" : "Enter again"}
{
inputRef.current?.focus();
verify(value);
}}
>
{chinese ? "重试验证" : "Retry verification"}
);
}
```
Input OTP combines real character inputs with paste, autofill, keyboard navigation, and verification feedback. The first example accepts `123456` as a successful verification and keeps the check visible. Other six-digit codes show failure, then unfold back into editable inputs with the entered value preserved.
## Installation
```bash
bunx --bun shadcn@latest add @sui/input-otp
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases. The component uses Base UI OTP Field.
## Usage
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId } from "react";
function VerificationCode() {
const id = useId();
return (
Verification code
Enter the six-digit verification code you received.
);
}
```
Render one `InputOTPSlot` per character in `length`. Slots register in DOM order, including across groups. `InputOTPGroup` arranges slots and `InputOTPSeparator` provides a visual divider; neither consumes a character.
## Values and completion
Use `value` and `onValueChange` for a controlled value, or `defaultValue` for internal state. `onValueComplete` reports a completed code so the application can verify it. Completion means every character is entered; it does not prove that the code is valid and does not automatically enable success feedback.
```tsx
const [value, setValue] = useState("");
verifyCode(code)}
>
{[0, 1, 2, 3, 4, 5].map((position) => (
))}
;
```
The standard change and completion callbacks receive the value and Base UI event details. A complete pasted code can trigger completion again, allowing verification to retry.
### Example: input-otp-controlled
```tsx
"use client";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import * as React from "react";
export default function InputOTPControlled() {
const [value, setValue] = React.useState("");
const id = React.useId();
return (
One-time password
{value === "" ? (
<>Enter your one-time password.>
) : (
<>You entered: {value}>
)}
);
}
```
## Verification feedback
The application controls `status` and owns the verification request:
| Status | Behavior |
| --- | --- |
| `idle` | Editable inputs; filling them does not mark verification successful |
| `loading` | Inputs become read-only and collapse into a spinning indicator while the request runs |
| `success` | The indicator morphs into a check with a halo and particles; the check stays visible and inputs remain collapsed |
| `error` | The indicator morphs into an X, then fades out as the inputs unfold back to their original positions |
After your request resolves, set `success` or `error`. Success remains displayed until your application explicitly sets `status="idle"`, such as when the user chooses to enter a new code.
Error holds the X for `feedbackDuration`, then fades it out while the slots move back out to their original positions over about `380` milliseconds. The field stays read-only during this reverse transition. Once it finishes, editing and eligible focus are restored without changing the value or mask setting. `onStatusChange` requests `idle` only after error restoration. Connect it to your status setter so retry controls become available too.
```tsx
const id = useId();
const [status, setStatus] = useState("idle");
const feedback = {
idle: "Enter your verification code",
loading: "Verifying code",
success: "Code verified",
error: "Verification failed. Try again",
};
Verification code
{
setStatus("loading");
try {
const valid = await verifyCode(code);
setStatus(valid ? "success" : "error");
} catch {
setStatus("error");
}
}}
>
{slots}
{feedback[status]}
;
```
Import `InputOTPStatus` from the same component module. Keep request cancellation and stale-response handling in the application; the live example cancels its local timer on unmount and ignores superseded requests.
`feedbackDuration` defaults to `1250` milliseconds for the error result hold and does not time out `loading`. Error unfolds after that hold; success keeps its check until your application resets the status.
Reduced motion disables movement and particles while preserving the result hold. Error then returns to inputs without the animated transition; success still keeps its check. Timers complete the lifecycle when animation styles are unavailable. Zero duration skips the result hold. Restoration respects explicit `disabled` and `readOnly` settings. After an error, focus returns to the first slot when the field owned focus before feedback and the user has not moved focus elsewhere.
## Character filtering
`validationType` controls accepted characters. `numeric` is the default; use `alpha` for ASCII letters, `alphanumeric` for ASCII letters and digits, or `none` for custom rules. Spaces are removed and the result is limited to `length`.
Use `normalizeValue` for transformations such as uppercasing. It runs after built-in filtering, and the returned result is filtered again. Keep it idempotent. With `validationType="none"`, it can supply the custom filtering rule. `onValueInvalid` reports rejected typed or pasted characters; `inputMode` supplies a keyboard hint, not a verification result.
```tsx
value.toUpperCase()}
>
{slots}
;
```
### Example: input-otp-pattern
```tsx
"use client";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId as usePreviewId } from "react";
export function InputOTPPattern() {
const previewId = usePreviewId();
return (
Digits Only
);
}
export default InputOTPPattern;
```
## Groups and separators
Divide a code into readable groups while keeping the same total slot count.
### Example: input-otp-separator
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId } from "react";
export default function InputOTPWithSeparator() {
const id = useId();
return (
Verification code
);
}
```
## Disabled and read-only
`disabled` blocks interaction. `readOnly` prevents editing while preserving the current value. Loading and result feedback make the field read-only without clearing its value. Success stays collapsed until the application explicitly resets its status.
### Example: input-otp-disabled
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId } from "react";
export function InputOTPDisabled() {
const id = useId();
return (
Disabled verification code
);
}
export default InputOTPDisabled;
```
## Invalid fields
Use `aria-invalid` on the slots and `data-invalid` on the shared `Field` for persistent invalid styling. The shared `Field` handles layout and labels; use the underlying Base UI Field API when you need its form validation context. `status="error"` controls temporary verification feedback and does not replace the form's invalid state.
### Example: input-otp-invalid
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import * as React from "react";
export function InputOTPInvalid() {
const [value, setValue] = React.useState("000000");
const id = React.useId();
return (
Verification code
The verification code is invalid. Try another code.
);
}
export default InputOTPInvalid;
```
## Four digits
Use `length={4}` and four slots for a numeric PIN.
### Example: input-otp-four-digits
```tsx
"use client";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId } from "react";
export function InputOTPFourDigits() {
const id = useId();
return (
Four-digit PIN
);
}
export default InputOTPFourDigits;
```
## Alphanumeric codes
Use `validationType="alphanumeric"` for codes containing letters and digits.
### Example: input-otp-alphanumeric
```tsx
"use client";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId } from "react";
export function InputOTPAlphanumeric() {
const id = useId();
return (
Alphanumeric verification code
);
}
export default InputOTPAlphanumeric;
```
## Masked entry
Set `mask` to use Base UI's native password presentation for the character inputs. This hides the displayed characters; the controlled value, callbacks, and submitted form value still contain the real code. Masking does not encrypt it.
The masked example runs the same verification flow. A successful check stays visible; after an error, the restored inputs retain their code and continue displaying masked characters.
### Example: input-otp-masked
```tsx
"use client";
import type { ExampleProps } from "../types";
import InputOTPExample from "./input-otp-demo";
export default function Example(props: ExampleProps) {
return ;
}
```
## Forms
`name` submits the combined code through Base UI's hidden validation input. Use `required` for required entry. `autoSubmit` defaults to `false`; enabling it requests submission of the owning form on completion, independently of whether server verification succeeds.
### Example: input-otp-form
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { RefreshCwIcon } from "lucide-react";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export function InputOTPForm({ locale }: ExampleProps) {
const id = useId();
const chinese = locale === "zh-CN";
const [value, setValue] = useState("");
const [submitted, setSubmitted] = useState(null);
const firstInput = useRef(null);
return (
);
}
export default InputOTPForm;
```
## Labels, refs, and direction
`InputOTP` renders a `div`; its `ref` points to that root. Each `InputOTPSlot` renders a real `input` and accepts an `HTMLInputElement` ref, native input attributes, and Base UI input state styling. Use a slot ref to focus or inspect a character input.
Compose a visible label with the shared `Field`, `FieldLabel`, and `useId`, as in the usage example. Alternatively, import `Label` from `@workspace/ui/components/label` and associate its `htmlFor` with the `InputOTP` `id`. The shared `Field` provides layout and label composition; it is distinct from Base UI Field's validation context. Slots preserve Base UI position semantics and native ARIA attributes. The component does not generate labels or status text. Loading sets `aria-busy`; the application displays and announces feedback through `FieldDescription`, `FieldError`, or `role="status"`, as in the verification example.
Use the shared `DirectionProvider` from `@workspace/ui/components/direction` with `direction="rtl"` for right-to-left navigation and layout.
### Example: input-otp-rtl
```tsx
"use client";
import { DirectionProvider } from "@workspace/ui/components/direction";
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@workspace/ui/components/input-otp";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
verificationCode: "Verification code",
},
},
ar: {
dir: "rtl",
values: {
verificationCode: "رمز التحقق",
},
},
he: {
dir: "rtl",
values: {
verificationCode: "קוד אימות",
},
},
};
export function InputOTPRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.verificationCode}
);
}
export default InputOTPRtl;
```
## API reference
### InputOTP
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `length` | `number` | `6`; number of character slots |
| `value`, `defaultValue` | `string` | Controlled value or initial internal value |
| `onValueChange` | `(value, details) => void` | Base UI value change callback |
| `onValueComplete` | `(value, details) => void` | Reports completed entry; does not verify it |
| `status` | `InputOTPStatus` | `"idle"`; controlled verification feedback |
| `onStatusChange` | `(status: InputOTPStatus) => void` | Requests `"idle"` after error restoration |
| `feedbackDuration` | `number` | `1250` ms; error result hold before unfolding |
| `mask` | `boolean` | `false`; hides characters without changing the real value |
| `glass` | `boolean` | `false`; enables the shared material for slots |
`InputOTPGroup` accepts div props. `InputOTPSeparator` accepts Base UI separator props. `InputOTPSlot` accepts Base UI OTP input props and an `HTMLInputElement` ref. Slot order determines position.
The module exports `InputOTPProps` and `InputOTPStatus`. Character filtering, form options, native input attributes, state styling, and event detail types follow the [Base UI OTP Field API](https://base-ui.com/react/components/otp-field#api-reference).
- [Documentation](https://base-ui.com/react/components/otp-field)
---
# Item
A versatile component for displaying content with media, title, description, and actions.
Page: https://sui.draco.dev/docs/components/item
### Example: item-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react";
export function ItemDemo() {
return (
-
Basic Item
A simple item with title and description.
Action
}>
Your profile has been verified.
);
}
export default ItemDemo;
```
The `Item` component is a straightforward flex container that can house nearly any type of content. Use it to display a title, description, and actions. Group it with the `ItemGroup` component to create a list of items.
## Installation
```bash
bunx --bun shadcn@latest add @sui/item
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item"
```
```tsx showLineNumbers
-
Title
Description
Action
```
## Composition
Use the following composition to build an `Item`:
```text
ItemGroup
└── Item
├── ItemHeader
├── ItemMedia
├── ItemContent
│ ├── ItemTitle
│ └── ItemDescription
├── ItemActions
└── ItemFooter
```
## Item vs Field
Use `Field` if you need to display a form input such as a checkbox, input, radio, or select.
If you only need to display content such as a title, description, and actions, use `Item`.
## Variant
Use the `variant` prop to change the visual style of the item.
### Example: item-variant
```tsx
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { InboxIcon } from "lucide-react";
export function ItemVariant() {
return (
-
Default Variant
Transparent background with no border.
-
Outline Variant
Outlined style with a visible border.
-
Muted Variant
Muted background for secondary content.
);
}
export default ItemVariant;
```
## Size
Use the `size` prop to change the size of the item. Available sizes are `default`, `sm`, and `xs`.
### Example: item-size
```tsx
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { InboxIcon } from "lucide-react";
export function ItemSizeDemo() {
return (
-
Default Size
The standard size for most use cases.
-
Small Size
A compact size for dense layouts.
-
Extra Small Size
The most compact size available.
);
}
export default ItemSizeDemo;
```
## Icon
Use `ItemMedia` with `variant="icon"` to display an icon.
### Example: item-icon
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { ShieldAlertIcon } from "lucide-react";
export function ItemIcon() {
return (
-
Security Alert
New login detected from unknown device.
Review
);
}
export default ItemIcon;
```
## Avatar
You can use `ItemMedia` with `variant="avatar"` to display an avatar.
### Example: item-avatar
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { Plus } from "lucide-react";
export function ItemAvatar() {
return (
-
ER
Evil Rabbit
Last seen 5 months ago
-
No Team Members
Invite your team to collaborate on this project.
Invite
);
}
export default ItemAvatar;
```
## Image
Use `ItemMedia` with `variant="image"` to display an image.
### Example: item-image
```tsx
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
const music = [
{
title: "Midnight City Lights",
artist: "Neon Dreams",
album: "Electric Nights",
duration: "3:45",
},
{
title: "Coffee Shop Conversations",
artist: "The Morning Brew",
album: "Urban Stories",
duration: "4:05",
},
{
title: "Digital Rain",
artist: "Cyber Symphony",
album: "Binary Beats",
duration: "3:30",
},
];
export function ItemImage() {
return (
{music.map((song) => (
}
role="listitem"
>
{song.title} -{" "}
{song.album}
{song.artist}
{song.duration}
))}
);
}
export default ItemImage;
```
## Group
Use `ItemGroup` to group related items together.
### Example: item-group
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { PlusIcon } from "lucide-react";
const people = [
{
username: "shadcn",
avatar: "https://github.com/shadcn.png",
email: "shadcn@vercel.com",
},
{
username: "maxleiter",
avatar: "https://github.com/maxleiter.png",
email: "maxleiter@vercel.com",
},
{
username: "evilrabbit",
avatar: "https://github.com/evilrabbit.png",
email: "evilrabbit@vercel.com",
},
];
export function ItemGroupExample() {
return (
{people.map((person, _index) => (
-
{person.username.charAt(0)}
{person.username}
{person.email}
))}
);
}
export default ItemGroupExample;
```
## Header
Use `ItemHeader` to add a header above the item content.
### Example: item-header
```tsx
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemHeader,
ItemTitle,
} from "@workspace/ui/components/item";
const models = [
{
name: "v0-1.5-sm",
description: "Everyday tasks and UI generation.",
image:
"https://images.unsplash.com/photo-1650804068570-7fb2e3dbf888?q=80&w=640&auto=format&fit=crop",
credit: "Valeria Reverdo on Unsplash",
},
{
name: "v0-1.5-lg",
description: "Advanced thinking or reasoning.",
image:
"https://images.unsplash.com/photo-1610280777472-54133d004c8c?q=80&w=640&auto=format&fit=crop",
credit: "Michael Oeser on Unsplash",
},
{
name: "v0-2.0-mini",
description: "Open Source model for everyone.",
image:
"https://images.unsplash.com/photo-1602146057681-08560aee8cde?q=80&w=640&auto=format&fit=crop",
credit: "Cherry Laithang on Unsplash",
},
];
export function ItemHeaderDemo() {
return (
{models.map((model) => (
-
{model.name}
{model.description}
))}
);
}
export default ItemHeaderDemo;
```
## Link
Use the `render` prop to render the item as a link. The hover and focus states will be applied to the anchor element.
### Example: item-link
```tsx
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@workspace/ui/components/item";
import { ChevronRightIcon, ExternalLinkIcon } from "lucide-react";
export function ItemLink() {
return (
}>
Visit our documentation
Learn how to get started with our components.
}
>
External resource
Opens in a new tab with security attributes.
);
}
export default ItemLink;
```
```tsx showLineNumbers
}>
Dashboard
Overview of your account and activity.
```
## Dropdown
### Example: item-dropdown
```tsx
"use client";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { ChevronDownIcon } from "lucide-react";
const people = [
{
username: "shadcn",
avatar: "https://github.com/shadcn.png",
email: "shadcn@vercel.com",
},
{
username: "maxleiter",
avatar: "https://github.com/maxleiter.png",
email: "maxleiter@vercel.com",
},
{
username: "evilrabbit",
avatar: "https://github.com/evilrabbit.png",
email: "evilrabbit@vercel.com",
},
];
export function ItemDropdown() {
return (
}>
Select
{people.map((person) => (
-
{person.username.charAt(0)}
{person.username}
{person.email}
))}
);
}
export default ItemDropdown;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: item-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@workspace/ui/components/item";
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
basicItem: "Basic Item",
basicItemDesc: "A simple item with title and description.",
action: "Action",
verifiedTitle: "Your profile has been verified.",
},
},
ar: {
dir: "rtl",
values: {
basicItem: "عنصر أساسي",
basicItemDesc: "عنصر بسيط يحتوي على عنوان ووصف.",
action: "إجراء",
verifiedTitle: "تم التحقق من ملفك الشخصي.",
},
},
he: {
dir: "rtl",
values: {
basicItem: "פריט בסיסי",
basicItemDesc: "פריט פשוט עם כותרת ותיאור.",
action: "פעולה",
verifiedTitle: "הפרופיל שלך אומת.",
},
},
};
export function ItemRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
-
{t.basicItem}
{t.basicItemDesc}
{t.action}
} dir={dir}>
{t.verifiedTitle}
);
}
export default ItemRtl;
```
## API Reference
### Item
The main component for displaying content with media, title, description, and actions.
| Prop | Type | Default |
| --------- | ----------------------------------- | ----------- |
| `variant` | `"default" \| "outline" \| "muted"` | `"default"` |
| `size` | `"default" \| "sm" \| "xs"` | `"default"` |
| `render` | `React.ReactElement` | |
### ItemGroup
A container that groups related items together with consistent styling.
```tsx
```
### ItemSeparator
A separator between items in a group.
```tsx
```
### ItemMedia
Use `ItemMedia` to display media content such as icons, images, or avatars.
| Prop | Type | Default |
| --------- | -------------------------------- | ----------- |
| `variant` | `"default" \| "icon" \| "image"` | `"default"` |
```tsx
```
```tsx
```
### ItemContent
Wraps the title and description of the item.
```tsx
Title
Description
```
### ItemTitle
Displays the title of the item.
```tsx
Item Title
```
### ItemDescription
Displays the description of the item.
```tsx
Item description
```
### ItemActions
Container for action buttons or other interactive elements.
```tsx
Action
```
### ItemHeader
Displays a header above the item content.
```tsx
-
Header
...
```
### ItemFooter
Displays a footer below the item content.
```tsx
-
...
Footer
```
---
# Kbd
Used to display textual user input from keyboard.
Page: https://sui.draco.dev/docs/components/kbd
### Example: kbd-demo
```tsx
import { Kbd, KbdGroup } from "@workspace/ui/components/kbd";
export default function KbdDemo() {
return (
⌘
⇧
⌥
⌃
Ctrl
+
B
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/kbd
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Kbd } from "@workspace/ui/components/kbd"
```
```tsx
Ctrl
```
## Composition
Use the following composition to build `Kbd` and `KbdGroup`:
```text
Kbd
KbdGroup
├── Kbd
└── Kbd
```
## Group
Use the `KbdGroup` component to group keyboard keys together.
### Example: kbd-group
```tsx
import { Kbd, KbdGroup } from "@workspace/ui/components/kbd";
export default function KbdGroupExample() {
return (
Use{" "}
Ctrl + B
Ctrl + K
{" "}
to open the command palette
);
}
```
## Button
Use the `Kbd` component inside a `Button` component to display a keyboard key inside a button.
### Example: kbd-button
```tsx
import { Button } from "@workspace/ui/components/button";
import { Kbd } from "@workspace/ui/components/kbd";
export default function KbdButton() {
return (
Accept{" "}
⏎
);
}
```
## Tooltip
You can use the `Kbd` component inside a `Tooltip` component to display a tooltip with a keyboard key.
### Example: kbd-tooltip
```tsx
import { Button } from "@workspace/ui/components/button";
import { ButtonGroup } from "@workspace/ui/components/button-group";
import { Kbd, KbdGroup } from "@workspace/ui/components/kbd";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
export default function KbdTooltip() {
return (
}>
Save
Save Changes S
}>
Print
Print Document{" "}
Ctrl
P
);
}
```
## Input Group
You can use the `Kbd` component inside a `InputGroupAddon` component to display a keyboard key inside an input group.
### Example: kbd-input-group
```tsx
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@workspace/ui/components/input-group";
import { Kbd } from "@workspace/ui/components/kbd";
import { SearchIcon } from "lucide-react";
export default function KbdInputGroup() {
return (
⌘
K
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: kbd-rtl
```tsx
"use client";
import { Kbd, KbdGroup } from "@workspace/ui/components/kbd";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {},
},
ar: {
dir: "rtl",
values: {},
},
he: {
dir: "rtl",
values: {},
},
};
export function KbdRtl() {
const { dir } = useTranslation(translations, "ar");
return (
⌘
⇧
⌥
⌃
Ctrl
+
B
);
}
export default KbdRtl;
```
## API Reference
### Kbd
Use the `Kbd` component to display a keyboard key.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | `` |
4
### KbdGroup
Use the `KbdGroup` component to group `Kbd` components together.
| Prop | Type | Default |
| ----------- | -------- | ------- |
| `className` | `string` | `` |
```tsx
Ctrl
B
```
---
# Label
Renders an accessible label associated with controls.
Page: https://sui.draco.dev/docs/components/label
### Example: label-demo
```tsx
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
export default function LabelDemo() {
const previewId = usePreviewId();
return (
Accept terms and conditions
);
}
```
For form fields, use the [Field](/docs/components/field) component which
includes built-in label, description, and error handling.
## Installation
```bash
bunx --bun shadcn@latest add @sui/label
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Label } from "@workspace/ui/components/label"
```
```tsx
Your email address
```
## Label in Field
For form fields, use the [Field](/docs/components/field) component which
includes built-in `FieldLabel`, `FieldDescription`, and `FieldError` components.
```tsx
Your email address
```
### Example: field-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
const months = [
{ label: "MM", value: null },
{ label: "01", value: "01" },
{ label: "02", value: "02" },
{ label: "03", value: "03" },
{ label: "04", value: "04" },
{ label: "05", value: "05" },
{ label: "06", value: "06" },
{ label: "07", value: "07" },
{ label: "08", value: "08" },
{ label: "09", value: "09" },
{ label: "10", value: "10" },
{ label: "11", value: "11" },
{ label: "12", value: "12" },
];
const years = [
{ label: "YYYY", value: null },
{ label: "2024", value: "2024" },
{ label: "2025", value: "2025" },
{ label: "2026", value: "2026" },
{ label: "2027", value: "2027" },
{ label: "2028", value: "2028" },
{ label: "2029", value: "2029" },
];
export default function FieldDemo() {
const previewId = usePreviewId();
return (
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: label-rtl
```tsx
"use client";
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Label } from "@workspace/ui/components/label";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
label: "Accept terms and conditions",
},
},
ar: {
dir: "rtl",
values: {
label: "قبول الشروط والأحكام",
},
},
he: {
dir: "rtl",
values: {
label: "קבל תנאים והגבלות",
},
},
};
export function LabelRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.label}
);
}
export default LabelRtl;
```
## API Reference
See the [Base UI Label](https://base-ui.com/react/components/label#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/label)
- [API reference](https://base-ui.com/react/components/label#api-reference)
---
# Loader
Eighteen loading animations with accessible labels, adjustable speed, and reduced motion support.
Page: https://sui.draco.dev/docs/components/loader
### Example: loader-demo
```tsx
import { Loader, loaderVariantNames } from "@workspace/ui/components/loader";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{loaderVariantNames.map((variant) => (
{variant}
))}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/loader
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
```tsx
import { Loader } from "@workspace/ui/components/loader";
```
## Usage
```tsx
```
`Loader` provides eighteen distinct loading animations, all using the current text color. The root exposes `role="status"` and a loading label while decorative visuals remain hidden from assistive technology. Reduced motion changes stop CSS animations and character timers immediately, leaving a static indicator. Timers and media listeners are cleaned up on unmount. Server rendering uses static character frames to avoid hydration differences.
## Variants
| Variant | Animation |
| --- | --- |
| `spinner` | Rotating arc over a circular track |
| `dash-ring` | Stretching arc that travels smoothly around a rotating ring |
| `dots` | Three staggered bouncing dots |
| `bars` | Four staggered scaling bars |
| `dot-matrix` | A rippling 3 × 3 dot matrix |
| `dither` | A patterned 4 × 4 blinking square matrix |
| `ascii` | Rotating character frames |
| `ascii-line` | Rotating line character frames |
| `ascii-braille` | Braille ring character frames |
| `ascii-blocks` | Rising and falling block characters |
| `ascii-bounce` | Bouncing dot character frames |
| `morph` | Rotating circle, square, triangle, and hexagon morphs |
| `comet` | Rotating comet with a fading trail |
| `scramble` | Scrambled characters resolving to LOADING |
| `metaballs` | Two merging circular blobs |
| `newton` | Alternating end swings of a Newton cradle |
| `helix` | Opposing strands of oscillating dots |
| `percent` | Decorative looping percentage and short bar |
`percent` is a looping loading motif, not measured task progress. Use [Progress](/docs/components/progress) for actual progress.
## Sizes and colors
Use `sm` (16px), `default` (20px), `lg` (28px), or a numeric pixel size. Numeric sizes are clamped to 8–4096px; non-finite values use 20px. Set `className="text-primary"` to use the active theme color.
### Example: loader-size
```tsx
import { Loader } from "@workspace/ui/components/loader";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
{(["spinner", "dash-ring"] as const).map((variant) => (
{variant}
{(["sm", "default", "lg", 40] as const).map((size) => (
{typeof size === "number" ? `${size}px` : size}
))}
))}
{[0.6, 2].map((speed) => (
{speed}s
))}
);
}
```
## Animation speed
`speed` controls the base cycle in seconds and defaults to `1`. Smaller values are faster; values are clamped to `0.1`–`86400` and non-finite values fall back to `1`. Character timers have a minimum interval of 10 milliseconds. Complex animations use fixed multiples of the base cycle.
```tsx
```
## Button loading state
Render the loader only while work is in progress and disable repeated submissions. This example simulates a local save and cleans up its timer on unmount.
### Example: loader-button
```tsx
import { Button } from "@workspace/ui/components/button";
import { Loader } from "@workspace/ui/components/loader";
import { useEffect, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
saving: "正在保存…",
save: "保存设置",
saved: "预览设置已保存",
}
: {
saving: "Saving…",
save: "Save settings",
saved: "Preview settings saved.",
};
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
const timer = useRef | undefined>(undefined);
useEffect(
() => () => {
if (timer.current !== undefined) clearTimeout(timer.current);
},
[],
);
return (
{
setLoading(true);
setSaved(false);
timer.current = setTimeout(() => {
setLoading(false);
setSaved(true);
}, 1200);
}}
>
{loading ? (
) : null}
{loading ? stateLabels.saving : stateLabels.save}
{saved ? stateLabels.saved : ""}
);
}
```
## API
| Prop | Type | Default |
| --- | --- | --- |
| `variant` | `LoaderVariant`, see all eighteen variants above | `"spinner"` |
| `size` | `"sm" \| "default" \| "lg" \| number` | `"default"` |
| `speed` | `number`, base cycle in seconds | `1` |
| `label` | `string` | `"Loading"` |
| `render` | React element or render function | `span` |
Standard `span` props, refs, styles, and `className` are supported. The root exposes `data-slot="loader"` and `data-variant`. Labels describe actual activity; the percentage motif is decorative and does not expose progress values to assistive technology. See [Base UI composition](https://base-ui.com/react/handbook/composition) for `render` and ref behavior.
---
# Locale Toggle
A controlled language button or selector without routing or persistence assumptions.
Page: https://sui.draco.dev/docs/components/locale-toggle
### Example: locale-toggle-demo
```tsx
"use client";
import { LocaleToggle } from "@workspace/ui/components/locale-toggle";
import { useState } from "react";
import type { ExampleProps } from "../types";
const options = [
{ value: "en-US", label: "English" },
{ value: "zh-CN", label: "简体中文" },
];
export default function Example({ locale }: ExampleProps) {
const [value, setValue] = useState(locale ?? "en-US");
const chinese = locale === "zh-CN";
return (
{value === "zh-CN" ? "你好,欢迎使用 SUI" : "Hello, welcome to SUI."}
{chinese
? "此示例只修改本地状态,不改变文档语言或保存偏好"
: "This example changes only local state, without changing the documentation language or saving preferences."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/locale-toggle
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { LocaleToggle } from "@workspace/ui/components/locale-toggle";
import { useState } from "react";
const options = [
{ value: "en-US", label: "English" },
{ value: "zh-CN", label: "简体中文" },
];
export function Language() {
const [value, setValue] = useState("en-US");
return ;
}
```
## Toggle and select modes
In `auto` mode, up to two distinct options use a button that cycles to the next language; more options use the shared Select. Set `mode="toggle"` or `mode="select"` to choose explicitly. A button with fewer than two distinct options is disabled. Duplicate option values are removed.
### Example: locale-toggle-select
```tsx
"use client";
import { LocaleToggle } from "@workspace/ui/components/locale-toggle";
import { useState } from "react";
import type { ExampleProps } from "../types";
const options = [
{ value: "en-US", label: "English" },
{ value: "zh-CN", label: "简体中文" },
{ value: "ja-JP", label: "日本語" },
];
export default function Example({ locale }: ExampleProps) {
const [value, setValue] = useState(locale ?? "en-US");
const chinese = locale === "zh-CN";
return (
{value}
);
}
```
## Integrating routing and content
`onValueChange` reports a locale value after the indicator reaches its new shape. Your application updates content, router state, and any persisted preference. The control locks immediately and keeps `aria-busy` during the morph and any promise returned by the callback. Repeated clicks share the pending request. Reduced motion or a hidden page completes the shape immediately. Rejection releases the pending state and restores the controlled locale indicator; the application callback handles error reporting. Unmounting before the animation ends cancels the callback. The selected locale remains controlled by `value`. No router, translation provider, or storage API is required by the component. The examples keep the selection local and do not navigate the documentation.
For this documentation’s language routes, switching preserves the page path, query, and hash. That behavior belongs to the application’s route integration, not to the shared toggle component.
## Option indicators and labels
Every option has a full locale `value` and readable `label`. The ghost button has a tooltip and a 16px SVG indicator with 2px strokes. ZH and EN use compact stroke lettering with a snappy spring path morph. Common language prefixes have built-in indicators; reduced motion swaps the shape directly. `indicatorPath` overrides its SVG path, while unknown prefixes use the generic language icon. Translate the control `label` and use readable option names. Native button props include `disabled`, `ref`, and `render`.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `value` | `string` | Required selected locale. |
| `onValueChange` | `(value: string) => void \| Promise` | Required selection callback. |
| `options` | `readonly LocaleOption[]` | Required options: `value`, `label`, optional `indicatorPath`. |
| `mode` | `"auto" \| "toggle" \| "select"` | `"auto"`. |
| `label` | `string` | `"Change language"`. |
| `glass` | `boolean` | `false`. |
The module exports `LocaleToggleProps` and `LocaleOption`. The multi-language selector uses the [Base UI Select API](https://base-ui.com/react/components/select).
---
# LongText
Truncated text with a full-text disclosure only when the content overflows.
Page: https://sui.draco.dev/docs/components/long-text
### Example: long-text-demo
```tsx
import { Input } from "@workspace/ui/components/input";
import { LongText } from "@workspace/ui/components/long-text";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function LongTextDemo({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [text, setText] = useState(
"production-api-gateway.asia-east-1.example.com",
);
return (
setText(event.target.value)}
/>
{zh ? "域名" : "Domain"}
{text}
{zh
? "悬停或键盘聚焦查看全文,触屏点击展开。短文本不会启用浮层。"
: "Hover or focus to see the full text; tap on touch devices. Short text needs no overlay."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/long-text
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
```tsx
import { LongText } from "@workspace/ui/components/long-text"
```
## Usage
```tsx
production-api-gateway.asia-east-1.example.com
```
Give the component a constrained width. Overflowing text becomes a keyboard-focusable trigger. Pointer devices show SUI Tooltip on hover or focus; coarse pointers show SUI Popover on tap. Short text remains plain text. Measurements update when content, layout, fonts, or pointer capabilities change.
## Table cells
Use a fixed table layout or constrain the cell width so ellipsis has a boundary.
### Example: long-text-table
```tsx
import { LongText } from "@workspace/ui/components/long-text";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import type { ExampleProps } from "../types";
export default function LongTextTable({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
return (
ID
{zh ? "名称" : "Name"}
{["sui", "production-api-gateway.asia-east-1.example.com"].map(
(name, index) => (
{index + 1}
{name}
),
)}
);
}
```
## API
| Prop | Description |
| --- | --- |
| `children` | Displayed content, repeated in the disclosure. Use text or noninteractive inline content. |
| `className` | Width and layout classes on the outer container. |
| `contentClassName` | Layout classes on the full-text surface. |
| `label` | Optional accessible trigger name. Defaults to the displayed text. |
The pattern is inspired by [shadcn-admin LongText](https://github.com/satnaing/shadcn-admin/blob/main/src/components/long-text.tsx), with SUI Base UI composition and resize-aware overflow detection.
---
# Markdown Viewer
Markdown rendering with tables, task lists, alerts, highlighted code, and sanitized HTML.
Page: https://sui.draco.dev/docs/components/markdown-viewer
### Example: markdown-viewer-demo
````tsx
"use client";
import { MarkdownViewer } from "@workspace/ui/components/markdown-viewer";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const content = chinese
? '# 发布说明\n\n支持 **Markdown**、表格和任务列表。\n\n> [!TIP]\n> 使用语义颜色构建一致的界面。\n\n| 组件 | 状态 |\n| --- | --- |\n| Editor | 就绪 |\n| Markdown Viewer | 就绪 |\n\n- [x] 双语文档\n- [ ] 发布\n\n```tsx\nconst theme = "bamboo";\n```'
: '# Release notes\n\nSupports **Markdown**, tables, and task lists.\n\n> [!TIP]\n> Use semantic colors to keep interfaces consistent.\n\n| Component | Status |\n| --- | --- |\n| Editor | Ready |\n| Markdown Viewer | Ready |\n\n- [x] Bilingual docs\n- [ ] Release\n\n```tsx\nconst theme = "bamboo";\n```';
return (
);
}
````
## Installation
```bash
bunx --bun shadcn@latest add @sui/markdown-viewer
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { MarkdownViewer } from "@workspace/ui/components/markdown-viewer";
;
```
## Supported formatting
Supports headings, links, tables, task lists, strikethrough, line breaks, and fenced code. Fenced code, including blocks without a language, and indented code use [Code Viewer](/docs/components/code-viewer). Blocks without a language render as plain text and retain copying and line breaks. Inline code remains inline. GitHub-style `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` blocks have semantic visual treatments.
Heading anchors are scoped to each viewer instance. A Contents, Table of Contents, or TOC heading can generate a table of contents. Math syntax is parsed, but this component does not load a dedicated mathematical typesetting engine.
## HTML and editable content
Embedded HTML is parsed and sanitized before rendering. Common safe formatting, including `details` and `summary`, is retained; scripts and event-handler attributes are removed. The example lets you edit Markdown containing a disclosure and a script that is not executed. Unlike [Html Viewer](/docs/components/html-viewer), this component does not execute HTML scripts.
### Example: markdown-viewer-sanitized
```tsx
"use client";
import { Label } from "@workspace/ui/components/label";
import { MarkdownViewer } from "@workspace/ui/components/markdown-viewer";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const [content, setContent] = useState(
chinese
? '## 可编辑的内容\n\n展开详情 允许的 HTML 会保留。 \n\n'
: '## Editable content\n\nShow details Allowed HTML is preserved. \n\n',
);
return (
{chinese ? "Markdown 源码" : "Markdown source"}
);
}
```
## Theme and localization
The content follows the surrounding theme unless `theme` is supplied. Use `className` for container layout. `labels.empty` customizes the empty state, while `note`, `tip`, `important`, `warning`, and `caution` translate alert headings. `labels.code` accepts [CodeViewer labels](/docs/components/code-viewer#api-reference) for embedded code blocks, including copy, loading, and failure feedback. Markdown content itself is provided by your application and is not translated automatically.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `content` | `string` | Required Markdown source. |
| `theme` | `"light" \| "dark"` | Follows surrounding theme. |
| `className` | `string` | Container layout classes. |
| `labels` | `Partial` | Empty and alert labels; `code` translates embedded CodeViewer feedback. Defaults to English. |
| `glass` | `boolean` | `false`. |
The module exports `MarkdownViewerProps` and `MarkdownViewerLabels`. Rendering uses [react-markdown](https://github.com/remarkjs/react-markdown).
---
# Marker
Displays an inline status, system note, bordered row, or labeled separator in a conversation.
Page: https://sui.draco.dev/docs/components/marker
### Example: marker-demo
```tsx
import { Loader } from "@workspace/ui/components/loader";
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@workspace/ui/components/marker";
import { GitBranchIcon, SearchIcon } from "lucide-react";
export function MarkerDemo() {
return (
Switched to a new branch
Thinking...
Conversation compacted
Explored 4 files
);
}
export default MarkerDemo;
```
The `Marker` component displays inline conversation markers such as status updates, system notes, bordered rows, and labeled separators. Compose it with [`Message`](/docs/components/message) in a conversation thread.
## Installation
```bash
bunx --bun shadcn@latest add @sui/marker
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Marker, MarkerContent, MarkerIcon } from "@workspace/ui/components/marker"
```
```tsx showLineNumbers
Explored 4 files
```
## Composition
Use the following composition to build a marker:
```text
Marker
├── MarkerIcon
└── MarkerContent
```
## Features
- Inline marker, bordered row, and labeled separator variants
- Decorative icon slot that is hidden from assistive tech
- Polymorphic root via `render` for link and button markers
- Pairs with the [`shimmer`](https://ui.shadcn.com/docs/utils/shimmer) utility for streaming status text
- Customizable styling through the `className` prop on every part
## Variants
Use `variant` to switch between an inline marker, bordered row, and labeled separator.
### Example: marker-variants
```tsx
import { Marker, MarkerContent } from "@workspace/ui/components/marker";
export function MarkerVariantsDemo() {
return (
A default marker for inline notes.
A separator marker
A border marker for row boundaries.
);
}
export default MarkerVariantsDemo;
```
| Variant | Description |
| ----------- | ---------------------------------------------------- |
| `default` | An inline marker for status, notes, and actions. |
| `border` | A default marker with a bottom border under the row. |
| `separator` | A centered label with divider lines on each side. |
## Status
Set `role="status"` and include a [`Loader`](/docs/components/loader) for streaming or in-progress markers so updates are announced.
### Example: marker-status
```tsx
import { Loader } from "@workspace/ui/components/loader";
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@workspace/ui/components/marker";
export function MarkerStatusDemo() {
return (
Compacting conversation
Running tests
);
}
export default MarkerStatusDemo;
```
## Shimmer
Add the [`shimmer`](https://ui.shadcn.com/docs/utils/shimmer) utility class to `MarkerContent` for an animated streaming-text effect. The utility ships with the `shadcn` package — see the shimmer docs for installation.
### Example: marker-shimmer
```tsx
import { Marker, MarkerContent } from "@workspace/ui/components/marker";
export function MarkerShimmerDemo() {
return (
Thinking...
Reading 4 files
);
}
export default MarkerShimmerDemo;
```
## Separator
Use the `separator` variant for labeled dividers, such as dates or section breaks, in a conversation.
### Example: marker-separator
```tsx
import { Marker, MarkerContent } from "@workspace/ui/components/marker";
export function MarkerSeparatorDemo() {
return (
Today
Worked for 42s
Conversation compacted
);
}
export default MarkerSeparatorDemo;
```
## Border
Use the `border` variant for status rows that should keep the default marker alignment while separating the next row.
### Example: marker-border
```tsx
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@workspace/ui/components/marker";
import { FileTextIcon, GitBranchIcon, SearchIcon } from "lucide-react";
export function MarkerBorderDemo() {
return (
Switched to release-candidate
Reviewed 8 related files
Opened implementation notes
);
}
export default MarkerBorderDemo;
```
## With Icon
Use `MarkerIcon` to render an icon alongside the content. Use `flex-col` to stack the icon above the content.
### Example: marker-icon
```tsx
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@workspace/ui/components/marker";
import { BookOpenCheck, GitBranchIcon, SearchIcon } from "lucide-react";
export function MarkerIconDemo() {
return (
Switched to a new branch
Explored 4 files
Syncing completed
);
}
export default MarkerIconDemo;
```
## Links and Buttons
Turn a marker into a link or button with the `render` prop on `Marker`.
### Example: marker-link-button
```tsx
"use client";
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@workspace/ui/components/marker";
import { toast } from "@workspace/ui/components/toast";
import { GitBranchIcon, RotateCcwIcon } from "lucide-react";
export function MarkerLinkButtonDemo() {
return (
}>
View the pull request
toast.add({ title: "You clicked the revert button" })
}
/>
}
>
Revert this change
);
}
export default MarkerLinkButtonDemo;
```
```tsx showLineNumbers
import { Marker, MarkerContent } from "@workspace/ui/components/marker"
export function MarkerLinkDemo() {
return (
}>
View the pull request
)
}
```
## Accessibility
`Marker` is presentational by default. The correct semantics depend on how you use it, so choose the role based on intent rather than relying on a single default.
### Status and Progress
For streaming or progress markers such as "Thinking..." or a running tool, set `role="status"` so assistive tech announces the update as it appears. `Marker` forwards `role` to the underlying element.
```tsx showLineNumbers
Compacting conversation
```
### Labeled Separators
A separator that carries text, such as a date or a section label, needs no role. The divider lines are decorative CSS pseudo-elements, and the text is announced as ordinary content.
```tsx showLineNumbers
Today
```
**Note:** Do not add `role="separator"` to a labeled divider. A separator
takes its accessible name from `aria-label`, not from its text, and its
contents are treated as presentational, so the visible label would not be
announced. Reserve `role="separator"` for a divider with no meaningful text.
### Bordered Markers
A bordered marker keeps the same semantics as the default marker. The bottom border is decorative, so choose `role="status"`, `render`, or no role based on the marker's purpose.
```tsx showLineNumbers
Opened implementation notes
```
### Decorative Icons
`MarkerIcon` is decorative and hidden from assistive tech with `aria-hidden`, so the adjacent `MarkerContent` carries the meaning. For an icon-only marker, provide an `aria-label` or visible text so it is not announced as empty.
```tsx showLineNumbers
```
### Interactive Markers
When a marker links or triggers an action, render it as a real `` or `` with the `render` prop so it is focusable and exposes the correct role. The accessible name comes from the marker text.
```tsx showLineNumbers
}>
Explored 4 files
```
## API Reference
### Marker
The root marker element. The file also exports `markerVariants` for composing the marker styles into custom components.
| Prop | Type | Default | Description |
| ----------- | -------------------------------------- | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "border" \| "separator"` | `"default"` | The marker layout. |
| `render` | `ReactElement \| function` | - | Render as a different element, such as a link. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### MarkerIcon
A decorative icon slot. Hidden from assistive tech with `aria-hidden`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | --------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the icon slot. |
### MarkerContent
The marker text content.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
---
# Menubar
A visually persistent menu common in desktop applications that provides quick access to a consistent set of commands.
Page: https://sui.draco.dev/docs/components/menubar
### Example: menubar-demo
```tsx
import {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarMenu,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@workspace/ui/components/menubar";
export default function MenubarDemo() {
return (
File
New Tab ⌘T
New Window ⌘N
New Incognito Window
Share
Email link
Messages
Notes
Print... ⌘P
Edit
Undo ⌘Z
Redo ⇧⌘Z
Find
Search the web
Find...
Find Next
Find Previous
Cut
Copy
Paste
View
Bookmarks Bar
Full URLs
Reload ⌘R
Force Reload ⇧⌘R
Toggle Fullscreen
Hide Sidebar
Profiles
Andy
Benoit
Luis
Edit...
Add Profile...
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/menubar
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Menubar,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarTrigger,
} from "@workspace/ui/components/menubar"
```
```tsx showLineNumbers
File
New Tab ⌘T
New Window
Share
Print
```
## Composition
Use the following composition to build a `Menubar`:
```text
Menubar
├── MenubarMenu
│ ├── MenubarTrigger
│ └── MenubarContent
│ ├── MenubarGroup
│ │ ├── MenubarLabel
│ │ ├── MenubarItem
│ │ └── MenubarItem
│ ├── MenubarSeparator
│ ├── MenubarGroup
│ │ ├── MenubarLabel
│ │ ├── MenubarCheckboxItem
│ │ └── MenubarCheckboxItem
│ ├── MenubarSeparator
│ ├── MenubarGroup
│ │ ├── MenubarLabel
│ │ └── MenubarRadioGroup
│ │ ├── MenubarRadioItem
│ │ └── MenubarRadioItem
│ └── MenubarSub
│ ├── MenubarSubTrigger
│ └── MenubarSubContent
│ └── MenubarGroup
│ ├── MenubarLabel
│ ├── MenubarItem
│ └── MenubarItem
└── MenubarMenu
├── MenubarTrigger
└── MenubarContent
└── MenubarGroup
├── MenubarLabel
├── MenubarItem
└── MenubarItem
```
## Checkbox
Use `MenubarCheckboxItem` for toggleable options.
### Example: menubar-checkbox
```tsx
import {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarTrigger,
} from "@workspace/ui/components/menubar";
export function MenubarCheckbox() {
return (
View
Always Show Bookmarks Bar
Always Show Full URLs
Reload ⌘R
Force Reload ⇧⌘R
Format
Strikethrough
Code
Superscript
);
}
export default MenubarCheckbox;
```
## Radio
Use `MenubarRadioGroup` and `MenubarRadioItem` for single-select options.
### Example: menubar-radio
```tsx
"use client";
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarTrigger,
} from "@workspace/ui/components/menubar";
import * as React from "react";
export function MenubarRadio() {
const [user, setUser] = React.useState("benoit");
const [theme, setTheme] = React.useState("system");
return (
Profiles
Andy
Benoit
Luis
Edit...
Add Profile...
Theme
Light
Dark
System
);
}
export default MenubarRadio;
```
## Submenu
Use `MenubarSub`, `MenubarSubTrigger`, and `MenubarSubContent` for nested menus.
### Example: menubar-submenu
```tsx
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@workspace/ui/components/menubar";
export function MenubarSubmenu() {
return (
File
Share
Email link
Messages
Notes
Print... ⌘P
Edit
Undo ⌘Z
Redo ⇧⌘Z
Find
Find...
Find Next
Find Previous
Cut
Copy
Paste
);
}
export default MenubarSubmenu;
```
## With Icons
### Example: menubar-icons
```tsx
import {
Menubar,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarTrigger,
} from "@workspace/ui/components/menubar";
import {
FileIcon,
FolderIcon,
HelpCircleIcon,
SaveIcon,
SettingsIcon,
TrashIcon,
} from "lucide-react";
export function MenubarIcons() {
return (
File
New File ⌘N
Open Folder
Save ⌘S
More
Settings
Help
Delete
);
}
export default MenubarIcons;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: menubar-rtl
```tsx
"use client";
import {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarMenu,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@workspace/ui/components/menubar";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
file: "File",
newTab: "New Tab",
newWindow: "New Window",
newIncognitoWindow: "New Incognito Window",
share: "Share",
emailLink: "Email link",
messages: "Messages",
notes: "Notes",
print: "Print...",
edit: "Edit",
undo: "Undo",
redo: "Redo",
find: "Find",
searchTheWeb: "Search the web",
findItem: "Find...",
findNext: "Find Next",
findPrevious: "Find Previous",
cut: "Cut",
copy: "Copy",
paste: "Paste",
view: "View",
bookmarksBar: "Bookmarks Bar",
fullUrls: "Full URLs",
reload: "Reload",
forceReload: "Force Reload",
toggleFullscreen: "Toggle Fullscreen",
hideSidebar: "Hide Sidebar",
profiles: "Profiles",
andy: "Andy",
benoit: "Benoit",
luis: "Luis",
editProfile: "Edit...",
addProfile: "Add Profile...",
},
},
ar: {
dir: "rtl",
values: {
file: "ملف",
newTab: "علامة تبويب جديدة",
newWindow: "نافذة جديدة",
newIncognitoWindow: "نافذة التصفح المتخفي الجديدة",
share: "مشاركة",
emailLink: "رابط البريد الإلكتروني",
messages: "الرسائل",
notes: "الملاحظات",
print: "طباعة...",
edit: "تعديل",
undo: "تراجع",
redo: "إعادة",
find: "بحث",
searchTheWeb: "البحث على الويب",
findItem: "بحث...",
findNext: "البحث التالي",
findPrevious: "البحث السابق",
cut: "قص",
copy: "نسخ",
paste: "لصق",
view: "عرض",
bookmarksBar: "شريط الإشارات المرجعية",
fullUrls: "عناوين URL الكاملة",
reload: "إعادة تحميل",
forceReload: "إعادة تحميل قسري",
toggleFullscreen: "تبديل وضع ملء الشاشة",
hideSidebar: "إخفاء الشريط الجانبي",
profiles: "الملفات الشخصية",
andy: "Andy",
benoit: "Benoit",
luis: "Luis",
editProfile: "تعديل...",
addProfile: "إضافة ملف شخصي...",
},
},
he: {
dir: "rtl",
values: {
file: "קובץ",
newTab: "כרטיסייה חדשה",
newWindow: "חלון חדש",
newIncognitoWindow: "חלון גלישה בסתר חדש",
share: "שתף",
emailLink: "קישור אימייל",
messages: "הודעות",
notes: "הערות",
print: "הדפס...",
edit: "ערוך",
undo: "בטל",
redo: "בצע שוב",
find: "מצא",
searchTheWeb: "חפש באינטרנט",
findItem: "מצא...",
findNext: "מצא הבא",
findPrevious: "מצא הקודם",
cut: "גזור",
copy: "העתק",
paste: "הדבק",
view: "תצוגה",
bookmarksBar: "סרגל סימניות",
fullUrls: "כתובות URL מלאות",
reload: "רענן",
forceReload: "רענן בכוח",
toggleFullscreen: "החלף מסך מלא",
hideSidebar: "הסתר סרגל צד",
profiles: "פרופילים",
andy: "Andy",
benoit: "Benoit",
luis: "Luis",
editProfile: "ערוך...",
addProfile: "הוסף פרופיל...",
},
},
};
export function MenubarRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
const [profile, setProfile] = React.useState("benoit");
return (
{t.file}
{t.newTab} ⌘T
{t.newWindow} ⌘N
{t.newIncognitoWindow}
{t.share}
{t.emailLink}
{t.messages}
{t.notes}
{t.print} ⌘P
{t.edit}
{t.undo} ⌘Z
{t.redo} ⇧⌘Z
{t.find}
{t.searchTheWeb}
{t.findItem}
{t.findNext}
{t.findPrevious}
{t.cut}
{t.copy}
{t.paste}
{t.view}
{t.bookmarksBar}
{t.fullUrls}
{t.reload} ⌘R
{t.forceReload} ⇧⌘R
{t.toggleFullscreen}
{t.hideSidebar}
{t.profiles}
{t.andy}
{t.benoit}
{t.luis}
{t.editProfile}
{t.addProfile}
);
}
export default MenubarRtl;
```
## API Reference
See the [Base UI Menubar](https://base-ui.com/react/components/menubar#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/menubar)
- [API reference](https://base-ui.com/react/components/menubar#api-reference)
---
# Message
Displays a message in a conversation, with optional avatar, header, footer, and alignment.
Page: https://sui.draco.dev/docs/components/message
### Example: message-demo
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@workspace/ui/components/bubble";
import { Marker, MarkerContent } from "@workspace/ui/components/marker";
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
} from "@workspace/ui/components/message";
export function MessageDemo() {
return (
ME
Deploying to prod real quick.
R
It's 4:55 PM. On a Friday.
ME
It's a one-line change.
Delivered
R
It's always a one-line change 😭.
Alright, let me take a look.
👍
Oliver is typing...
);
}
export default MessageDemo;
```
The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface.
For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component.
## Installation
```bash
bunx --bun shadcn@latest add @sui/message
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Avatar, AvatarFallback, AvatarImage } from "@workspace/ui/components/avatar"
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble"
import { Message, MessageAvatar, MessageContent } from "@workspace/ui/components/message"
```
```tsx showLineNumbers
CN
How can I help you today?
```
**Note:** `Message` owns the row layout—avatar, alignment, header, and footer.
Render the visible message surface inside it with
[`Bubble`](/docs/components/bubble). For the scroll container around a
conversation, use [`MessageScroller`](/docs/components/message-scroller).
## Composition
Use the following composition to build a message:
```text
Message
├── MessageAvatar
└── MessageContent
├── MessageHeader
├── Bubble
└── MessageFooter
```
Use `MessageGroup` to stack consecutive messages from the same sender:
```text
MessageGroup
├── Message
└── Message
```
## Features
- Start and end alignment for sender and receiver rows via the `align` prop
- Avatar slot that anchors to the bottom of the message and stays clear of the footer
- Header and footer slots for sender names, status, and message actions
- Footer follows the message side; actions stay aligned on `align="end"` rows
- Group wrapper for stacking consecutive messages from the same sender
- Customizable styling through the `className` prop on every part
## Avatar
Use `MessageAvatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message.
### Example: message-avatar
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@workspace/ui/components/bubble";
import {
Message,
MessageAvatar,
MessageContent,
} from "@workspace/ui/components/message";
export function MessageAvatarDemo() {
return (
R
The build failed during dependency installation.
R
Can you share the exact error?
R
Here's the error from the logs
Something went wrong with the build. The libraries are not
installed correctly. Try running the build again.
);
}
export default MessageAvatarDemo;
```
| align | Description |
| ------- | --------------------------------------------------- |
| `start` | Align the message to the start of the conversation. |
| `end` | Align the message to the end of the conversation. |
## Group
Use `MessageGroup` to stack consecutive messages from the same sender. Render an empty `MessageAvatar` on the earlier messages to keep them aligned with the avatar on the last one.
### Example: message-group
```tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import {
Message,
MessageAvatar,
MessageContent,
MessageGroup,
} from "@workspace/ui/components/message";
export function MessageGroupDemo() {
return (
I checked the registry addresses.
CN
The component and example JSON now live under the UI registry.
);
}
export default MessageGroupDemo;
```
## Header and Footer
Use `MessageHeader` for a sender name and `MessageFooter` for metadata such as a delivery or read status.
### Example: message-header-footer
```tsx
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import {
Message,
MessageContent,
MessageFooter,
MessageHeader,
} from "@workspace/ui/components/message";
export function MessageHeaderFooterDemo() {
return (
Olivia
I already checked the logs.
Send the report to the team. Ping @shadcn if you need help.
Read Yesterday
);
}
export default MessageHeaderFooterDemo;
```
## Actions
Place message-level actions in `MessageFooter`, such as copy, retry, or feedback buttons.
### Example: message-actions
```tsx
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Message,
MessageContent,
MessageFooter,
} from "@workspace/ui/components/message";
import {
CopyIcon,
RefreshCcwIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
export function MessageActionsDemo() {
return (
The install failure is coming from the workspace package.
Okay drop me a link. Taking a look...
Failed to send
);
}
export default MessageActionsDemo;
```
## Attachment
### Example: message-attachment
```tsx
"use client";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@workspace/ui/components/attachment";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import { Message, MessageContent } from "@workspace/ui/components/message";
import { DownloadIcon, FileTextIcon } from "lucide-react";
export function MessageAttachmentDemo() {
return (
Here's the image. Can you add it to the PDF? Use it for the
cover page.
Done. Here's the PDF with the image added as the cover page.
sales-dashboard.pdf
PDF · 2.4 MB
Thanks. Looks good.
);
}
export default MessageAttachmentDemo;
```
## Accessibility
`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it.
### Label icon-only actions
Action buttons in `MessageFooter` are usually icon-only, so give each one an `aria-label`.
```tsx showLineNumbers
```
### Status updates
For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears.
```tsx showLineNumbers
Checking the logs...
```
## API Reference
### Message
The message row wrapper.
| Prop | Type | Default | Description |
| ----------- | ------------------ | --------- | ------------------------------------------------- |
| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. |
| `className` | `string` | - | Additional classes to apply to the row. |
### MessageGroup
Groups consecutive messages from the same sender.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ---------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group root. |
### MessageAvatar
The avatar slot, aligned to the bottom of the message. When the message has a `MessageFooter`, the avatar shifts up to stay aligned with the message surface instead of the footer.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the avatar slot. |
### MessageContent
Wraps the header, message surface, and footer.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
### MessageHeader
Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the header. |
### MessageFooter
Displays content below the message, such as status or actions. Aligns to the message side.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the footer. |
---
# Message Scroller
A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
Page: https://sui.draco.dev/docs/components/message-scroller
### Example: message-scroller-demo
```tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@workspace/ui/components/input-group";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import {
ArrowUpIcon,
GlobeIcon,
ImageIcon,
MessageCircleDashedIcon,
PaperclipIcon,
PlusIcon,
RotateCwIcon,
TelescopeIcon,
} from "lucide-react";
import { createChat, getMessageText } from "../support/ai";
import { MessageAnimated } from "../support/message-animated";
const chat = createChat()
.user(
"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.",
)
.sleep(1000)
.assistant(
"That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.",
)
.user(
"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.",
)
.sleep(1000)
.assistant(
"MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container.",
)
.user(
"And if they've scrolled up to re-read an older answer? I don't want to yank them back down.",
)
.sleep(1000)
.assistant(
"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not.",
)
.user("Last one — does this work with assistive tech?")
.sleep(1000)
.assistant(
'`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.',
);
const initialMessages = chat.get(0);
const transport = chat.transport({ delayMs: 20 });
export function MessageScrollerDemo() {
const { messages, sendMessage, status, setMessages } = useChat({
messages: initialMessages,
transport,
});
const nextMessage = chat.next(messages);
const isBusy = status === "submitted" || status === "streaming";
return (
New Chat
How can I help you today?
setMessages(initialMessages)}
disabled={isBusy}
/>
}
>
Reset
{messages.length === 0 ? (
Morning, shadcn!
What are we working on today? Press send to start a new
conversation
) : (
{messages.map((message) => (
))}
)}
Demo is read only. Press send to send messages.
);
}
export default MessageScrollerDemo;
```
## What Makes a Great Streaming Chat Experience
Building a chat interface used to be simple. You create an inverted list with
an input. Type a message, it appends at the bottom. When a reply comes in, the
list grows and scrolls. Done.
Streaming breaks that model. Messages arrive in chunks while you may still be
reading, scrolling, or looking somewhere else entirely.
Now the challenge is preserving the reader's place while the conversation keeps
changing. Get that wrong and the experience feels jumpy: people are pulled to
the bottom, lose context, and have to find their way back.
In practice, this comes down to scroll: when to follow, when to hold, and when
to let the reader decide. A great streaming chat should:
1. **Move only when the reader asked to move.** If someone is reading, don’t pull them somewhere else. Auto-scroll should never be the default.
2. **Follow only while they’re following.** If they’re at the live edge, keep the stream in view. If they scroll away, leave them there.
3. **Every interaction is a signal.** Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
4. **Start a new turn near the top of the viewport.** This gives the new turn somewhere it can be read from the beginning.
5. **Then stream in the answer.** The answer should grow into the screen, not immediately push everything away.
6. **Keep part of the previous conversation in context.** The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
7. **Let new content arrive offscreen.** The conversation can keep streaming without changing what the reader is looking at.
8. **Show what’s happening out of view.** Make it clear when a response is still streaming or when new messages have arrived.
9. **Make it easy to return to the latest reply.** A “Jump to latest” action should bring the reader back and resume following.
10. **Let people jump anywhere in the conversation.** Long threads need message links, search, unread markers, and direct navigation.
11. **Reopen where the reader left off.** A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
12. **Keep the reader’s place when layout changes.** Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
13. **Handle interruptions without stealing position.** Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
14. **Stay responsive in long threads.** Streaming text, markdown, code, images, and long history should still feel responsive.
15. **Be accessible without the noise.** Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
**Never move the reader against their intent.**
## MessageScroller
MessageScroller is a chat transcript scroller built for these behaviors.
`MessageScrollerProvider` owns the scroll state and transcript-row behavior:
opening position, streamed output, new-turn anchoring, prepended history,
visibility, and scroll controls. `MessageScroller` is the styled frame that
renders inside it.
MessageScroller is scoped to the scroll viewport. It does not own messages, AI state,
transport, persistence, branching, or model state. Your product code stays
focused on composing messages, markers, tools, attachments, and prompt inputs.
It gives you the scroll behavior that chat needs, without taking over the rest
of the chat UI. And it stays fast, even in long conversations with rich
markdown.
## Installation
```bash
bunx --bun shadcn@latest add @sui/message-scroller
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Message } from "@workspace/ui/components/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller"
```
```tsx
{messages.map((message) => (
))}
```
`MessageScroller` fills its parent, so place it inside a height-constrained
container.
```tsx
{/* transcript */}
```
## Composition
```tsx
{/* a message, marker, or row */}
```
- **`MessageScrollerProvider`** — the headless root. Owns scroll state and the
behavior props for opening position, auto-scroll, anchoring, scroll commands,
and visibility tracking.
- **`MessageScroller`** — the styled frame. Lays out the viewport, content, and
controls inside the provider.
- **`MessageScrollerViewport`** — the scrollable element. Receives native scroll
events and preserves the visible row when older messages are prepended.
- **`MessageScrollerContent`** — the transcript container. Holds the rows and
provides the live-region defaults for new messages.
- **`MessageScrollerItem`** — the transcript row boundary. Wrap every direct
child of the content so the scroller can measure, anchor, preserve position,
track visibility, and jump to it. An item can be a message, marker, typing
indicator, separator, join/leave event, or "load earlier" row.
- **`MessageScrollerButton`** — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
## Core Concepts
### Anchoring Turns
A turn is the part of the conversation that starts a new exchange. In a simple
AI chat, that is usually the user's message and the assistant reply that follows.
An anchor is the row the viewport should treat as the start of that turn. Mark
that row with `scrollAnchor`. When a new anchor is appended, the viewport moves
it near the top and keeps a peek of the previous item above it, so the new turn
does not feel detached from its context.
```tsx
// This tells the scroller to anchor the user's message for the next turn.
```
Scroll anchors are not tied to message role. You can turn any row into an anchor:
a user message, a system marker, a handoff event, or anything else that starts a
meaningful turn. `MessageScroller` only needs to know which row should anchor the
viewport.
In the following example, the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
### Example: message-scroller-anchoring
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import {
ArrowUpIcon,
MessageCircleDashedIcon,
RotateCwIcon,
} from "lucide-react";
import * as React from "react";
import { MessageAnimated } from "../support/message-animated";
type AnchorRole = "user" | "assistant";
type ChatMessage = {
id: string;
role: AnchorRole;
text: string;
};
const scriptedMessages: ChatMessage[] = [
{
id: "anchor-1-user",
role: "user",
text: "Can you show me how anchoring behaves when a new prompt starts the turn?",
},
{
id: "anchor-1-assistant",
role: "assistant",
text: "Append the user prompt first, then append the assistant response. With User selected, the prompt settles near the top and the assistant response fills in below it.",
},
{
id: "anchor-2-user",
role: "user",
text: "What changes when assistant messages are the anchor?",
},
{
id: "anchor-2-assistant",
role: "assistant",
text: "Now each assistant response is the item `MessageScroller` keeps in view. This is useful when the reply is the moment you want readers to land on after each turn.",
},
{
id: "anchor-3-user",
role: "user",
text: "Can I switch roles and keep adding turns?",
},
{
id: "anchor-3-assistant",
role: "assistant",
text: "Yes. The next appended message with the selected role becomes the anchor, so you can compare user and assistant anchoring without resetting the demo.",
},
];
export function MessageScrollerAnchoring() {
const [anchorRole, setAnchorRole] = React.useState("user");
const [messages, setMessages] = React.useState([]);
const [messageIndex, setMessageIndex] = React.useState(0);
const nextMessage = scriptedMessages[messageIndex];
return (
Anchoring Turns
Choose which role settles near the top edge.
{
setMessages([]);
setMessageIndex(0);
}}
>
{messages.length === 0 ? (
No anchored messages yet
Send the first message to see the selected role anchor.
) : (
{messages.map((message) => (
))}
)}
{
const nextValue = value[0];
if (nextValue === "user" || nextValue === "assistant") {
setAnchorRole(nextValue);
setMessages([]);
setMessageIndex(0);
}
}}
>
User
Assistant
{
if (!nextMessage) {
return;
}
setMessages((messages) => [...messages, nextMessage]);
setMessageIndex((index) => index + 1);
}}
>
Send Message
Toggle the anchor role, then send messages to compare where turns
settle.
);
}
export default MessageScrollerAnchoring;
```
### Group Chat
In a group chat, the turn boundary is more specific than "the user message". It is often
the message that asks the model to respond, or a marker like "Marcus joined the
chat". Typing indicators and history controls usually should not anchor.
Because anchoring is role-independent, you can anchor a marker just as easily as
a message.
```tsx
Marcus joined the chat
```
### Example: message-scroller-group-chat
```tsx
"use client";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Marker, MarkerContent } from "@workspace/ui/components/marker";
import {
Message,
MessageContent,
MessageHeader,
} from "@workspace/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { RotateCwIcon } from "lucide-react";
import * as React from "react";
const currentUser = "Grace";
const initialItems = [
{
id: "group-1",
type: "message",
sender: "Grace",
role: "participant",
text: "@mary, the astrophage line keeps matching Venus energy output. Can you check my math?",
},
{
id: "group-2",
type: "message",
sender: "Mary (Agent)",
role: "assistant",
text: "Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.",
},
{
id: "group-3",
type: "message",
sender: "Grace",
role: "participant",
text: "ping @rocky",
scrollAnchor: true,
},
] satisfies GroupChatItem[];
const rockyMarker = {
id: "group-4",
type: "event",
text: "Rocky has joined the chat",
scrollAnchor: true,
} satisfies GroupChatItem;
const rockyMessage = {
id: "group-5",
type: "message",
sender: "Rocky",
role: "participant",
text: "Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.",
} satisfies GroupChatItem;
type GroupChatItem =
| {
id: string;
type: "event";
text: string;
scrollAnchor?: boolean;
}
| {
id: string;
type: "message";
sender: string;
role: "assistant" | "participant";
text: string;
scrollAnchor?: boolean;
};
export function MessageScrollerGroupChat() {
const [demoKey, setDemoKey] = React.useState(0);
const [rockyTurn, setRockyTurn] = React.useState<
"idle" | "marker" | "message"
>("idle");
let items: GroupChatItem[] = initialItems;
if (rockyTurn === "message")
items = [...initialItems, rockyMarker, rockyMessage];
else if (rockyTurn === "marker") items = [...initialItems, rockyMarker];
const buttonLabel =
rockyTurn === "idle" ? "Add Rocky" : "Send Message as Rocky";
const isComplete = rockyTurn === "message";
return (
Group Chat
A group chat with several participants and an assistant. The
Marker is marked as a turn.
{
setRockyTurn("idle");
setDemoKey((key) => key + 1);
}}
/>
}
>
Reset
{items.map((item) =>
item.type === "message" ? (
) : (
),
)}
setRockyTurn((turn) => (turn === "idle" ? "marker" : "message"))
}
className="w-full"
variant="secondary"
>
{buttonLabel}
{rockyTurn === "idle"
? "This will create a marker and make it the anchor"
: "Now send Rocky's reply into the conversation"}
When a user joins, a marker is created. scrollAnchor on the marker
marks it as the next turn
);
}
function GroupChatMessage({
item,
}: {
item: Extract;
}) {
const isCurrentUser = item.sender === currentUser;
const roleVariant = item.role === "assistant" ? "ghost" : "tinted";
const variant = isCurrentUser ? "muted" : roleVariant;
return (
{!isCurrentUser && {item.sender} }
{item.text}
);
}
function GroupChatMarker({
item,
scrollAnchor = false,
}: {
item: Extract;
scrollAnchor?: boolean;
}) {
return (
{item.text}
);
}
export default MessageScrollerGroupChat;
```
### Keeping Context Visible
When a new turn starts, it should still feel like part of the same continuous
thread. `scrollPreviousItemPeek` keeps a slice of the previous item visible
above the anchor, so the reader keeps their context instead of feeling like the
conversation restarted on a blank page.
```tsx
// Keep 64px of the previous turn visible above the newly anchored row.
{/* anchored turns */}
```
Adjust the peek amount in the example below to see how it affects the conversation.
### Example: message-scroller-previous-context
```tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@workspace/ui/components/input-group";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import { Slider } from "@workspace/ui/components/slider";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import {
ArrowUpIcon,
GlobeIcon,
ImageIcon,
PaperclipIcon,
PlusIcon,
RotateCwIcon,
TelescopeIcon,
} from "lucide-react";
import * as React from "react";
import { createChat, getMessageText } from "../support/ai";
import { MessageAnimated } from "../support/message-animated";
const DEFAULT_PEEK = 64;
const chat = createChat()
.user(
"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.",
)
.sleep(1000)
.assistant(
"That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.",
)
.user(
"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.",
)
.sleep(1000)
.assistant(
"MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container.",
)
.user(
"And if they've scrolled up to re-read an older answer? I don't want to yank them back down.",
)
.sleep(1000)
.assistant(
"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not.",
)
.user("Last one — does this work with assistive tech?")
.sleep(1000)
.assistant(
'`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.',
);
const initialMessages = chat.get(2);
const transport = chat.transport({ delayMs: 35 });
export function MessageScrollerPreviousContext() {
const [demoKey, setDemoKey] = React.useState(0);
const [peek, setPeek] = React.useState(DEFAULT_PEEK);
const { messages, sendMessage, setMessages, status } = useChat({
messages: initialMessages,
transport,
});
const nextMessage = chat.next(messages);
const isBusy = status === "submitted" || status === "streaming";
return (
Keeping Context Visible
New turns keep part of the previous reply in view.
{
setMessages(initialMessages);
setPeek(DEFAULT_PEEK);
setDemoKey((key) => key + 1);
}}
/>
}
>
Reset
{messages.map((message) => (
))}
Adjust the slider and send. Observe the previous message peak
);
}
export default MessageScrollerPreviousContext;
```
### Following the Live Edge
When the reader is at the live edge, either because they stayed there or
returned there, `autoScroll` keeps streamed replies in view as they grow.
Scrolling away from the live edge releases the view, whether by wheel, touch,
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
releases it too. New chunks can then arrive without moving the reader.
`autoScroll` composes with turn anchoring. When a new turn anchors near the
top, the view stays put while the reply streams into the room below it. Once
the reply fills the viewport, the reader is back at the live edge and
follow-output takes over from the anchor.
```tsx
{/* streamed turns */}
```
### Example: message-scroller-streaming
```tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@workspace/ui/components/input-group";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import {
ArrowUpIcon,
GlobeIcon,
ImageIcon,
MessageCircleDashedIcon,
PaperclipIcon,
PlusIcon,
RotateCwIcon,
TelescopeIcon,
} from "lucide-react";
import { createChat, getMessageText } from "../support/ai";
import { MessageAnimated } from "../support/message-animated";
const chat = createChat()
.user(
"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.",
)
.sleep(1000)
.assistant(
"That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.",
)
.user(
"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.",
)
.sleep(1000)
.assistant(
"MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container.",
)
.user(
"And if they've scrolled up to re-read an older answer? I don't want to yank them back down.",
)
.sleep(1000)
.assistant(
"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not.",
)
.user("Last one — does this work with assistive tech?")
.sleep(1000)
.assistant(
'`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.',
);
const initialMessages = chat.get(0);
const transport = chat.transport({ delayMs: 20 });
export function MessageScrollerStreaming() {
const { messages, sendMessage, setMessages, status } = useChat({
messages: initialMessages,
transport,
});
const nextMessage = chat.next(messages);
const isBusy = status === "submitted" || status === "streaming";
return (
Streaming Messages
Auto-scroll follows the live edge of the conversation.
setMessages(initialMessages)}
disabled={messages.length === 0 || isBusy}
/>
}
>
Reset
{messages.length === 0 ? (
Ready to Stream
Press send to stream a scripted launch summary.
) : (
{messages.map((message) => (
))}
)}
Streaming is simulated. `autoScroll` is enabled.
);
}
export default MessageScrollerStreaming;
```
Calling `scrollToEnd`, or pressing `MessageScrollerButton`, re-engages
follow-output when `autoScroll` is enabled, so a reader who scrolled away can
return to the live edge and keep following. The root and viewport expose
`data-autoscrolling` while that programmatic scroll to the latest message runs,
so you can conditionally apply styles during the transition.
### Opening Saved Threads
It can seem reasonable to reopen a saved thread at the absolute end of the
transcript, but that often drops the reader into the conversation without enough
context. A better default is `"last-anchor"`: show the last meaningful turn,
like the user's latest message, with the reply below it.
That gives the reader an immediate place in the thread. They can see what they
asked, where the answer starts, and continue from there without reconstructing
the conversation from the bottom edge.
```tsx
{/* transcript */}
```
### Example: message-scroller-opening-position
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
"use client";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Message, MessageContent } from "@workspace/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
} from "@workspace/ui/components/message-scroller";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import * as React from "react";
const messages = [
{
id: "open-1",
role: "user",
text: "This is the first message the user sent in the conversation.",
},
{
id: "open-2",
role: "assistant",
text: "Workspace creation rose 8%, but first invite completion only rose 2%.",
},
{
id: "open-3",
role: "user",
text: "This is the last message the user sent in the conversation.",
},
{
id: "open-4",
role: "assistant",
text: "Start with the invite step. Teams are creating workspaces but waiting to add collaborators.\n\nRecommended follow-up:\n\n1. Compare invite drop-off by account size.\n2. Check whether users who skip invites still return within 24 hours.\n3. Review the empty-state copy on the first project screen.\n4. Segment activation by template, since template users may not need invites right away.\n\nIf that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.",
},
] satisfies Array<{
id: string;
role: "user" | "assistant";
text: string;
}>;
const positions = [
{ value: "start", label: "start" },
{ value: "end", label: "end" },
{ value: "last-anchor", label: "last-anchor" },
] satisfies Array<{
value: "start" | "end" | "last-anchor";
label: string;
}>;
export function MessageScrollerOpeningPosition() {
const [positionKey, setPositionKey] = React.useState(0);
const [position, setPosition] = React.useState<
"start" | "end" | "last-anchor"
>("last-anchor");
return (
Opening Position
Choose where a saved transcript opens.
{
if (
value === "start" ||
value === "end" ||
value === "last-anchor"
) {
setPosition(value);
setPositionKey((key) => key + 1);
}
}}
className="w-full"
>
{positions.map((option) => (
{option.label}
))}
Toggle the defaultScrollPosition to see where the transcript starts when
you open the thread
);
}
function OpeningPositionScroller({
position,
positionKey,
}: {
position: "start" | "end" | "last-anchor";
positionKey: number;
}) {
const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller();
React.useLayoutEffect(() => {
// The replay key intentionally restarts the same opening scroll position.
void positionKey;
const frame = requestAnimationFrame(() => {
if (position === "start") {
scrollToStart({ behavior: "auto" });
return;
}
if (position === "end") {
scrollToEnd({ behavior: "auto" });
return;
}
scrollToMessage("open-3", {
align: "start",
behavior: "auto",
scrollMargin: 64,
});
});
return () => {
cancelAnimationFrame(frame);
};
}, [position, positionKey, scrollToEnd, scrollToMessage, scrollToStart]);
return (
{messages.map((message) => {
const isUserMessage = message.role === "user";
return (
{message.text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph, index) => (
{paragraph}
))}
);
})}
);
}
export default MessageScrollerOpeningPosition;
```
`"last-anchor"` is keyed on `scrollAnchor`, not message role. If no anchor
exists, or the last anchored turn already fits in the viewport, it falls back to
`"end"`.
Use `"start"` when you want to resume at the beginning of a conversation, or
`"end"` when the absolute latest message is the right place to land.
### Avoiding a Flash on Reload
A scroll container always opens at the top. HTML has no way to set `scrollTop`,
so a server-rendered transcript shows the oldest messages first. After
JavaScript runs, `defaultScrollPosition` moves the view, and you see a jump.
When `defaultScrollPosition` is `"end"` or `"last-anchor"`, the viewport has
`data-pending-scroll` until that position is applied. The styled viewport stays
hidden while the attribute is present, so you see the frame instead of the jump.
`"start"` does not need this.
If you want `"end"` visible on first paint, add an inline script right after the
viewport. Give the viewport an `id`, scroll it to the bottom, and remove
`data-pending-scroll`.
```tsx
const scrollToEndScript = `(function () {
var viewport = document.getElementById("messages")
if (!viewport) {
return
}
viewport.scrollTop = viewport.scrollHeight
viewport.removeAttribute("data-pending-scroll")
})()`
{/* transcript */}
```
Put the script in your page, not in the scroller. It only works for `"end"`, and
only when the messages are already in the HTML. Add `suppressHydrationWarning` on
the viewport. If you use a Content Security Policy, pass a `nonce`.
Do not use this script with `"last-anchor"`. Skip it when messages load on the
client.
### Loading Earlier Messages
Loading earlier messages should not move the conversation the reader is already
looking at. When older rows are prepended above the current transcript,
`MessageScrollerViewport` preserves the visible row so the reader stays in the
same place while history loads above them.
This is enabled by default through `preserveScrollOnPrepend`.
### Example: message-scroller-load-history
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses append-only positional paragraph fixtures.
"use client";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Marker, MarkerContent } from "@workspace/ui/components/marker";
import { Message, MessageContent } from "@workspace/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import { toast } from "@workspace/ui/components/toast";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { RotateCwIcon } from "lucide-react";
import * as React from "react";
import { createChat, getMessageText } from "../support/ai";
const chat = createChat()
.user("Can you summarize the incident channel?")
.assistant(
"The first alert was a delayed export job. It started backing up around 09:42 UTC and triggered the warning once the retry queue crossed the threshold.\n\nNo customer-facing checkout paths were affected, but exports for larger workspaces were running about 12 minutes behind.",
)
.user("Was checkout affected?")
.assistant(
"No checkout errors were reported. Payment authorization, order creation, and confirmation emails stayed inside their normal latency bands.\n\nThe only elevated metric was export queue depth, which maps to analytics downloads instead of checkout.",
)
.user("What changed in the last deploy?")
.assistant(
"Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.\n\nThe app deploy did not include checkout, pricing, or billing API changes.",
)
.user("Do we need to roll back?")
.assistant(
"Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.\n\nKeep rollback ready if the queue starts climbing again, but the current trend points toward recovery.",
)
.user("Keep watching for customer-visible issues.")
.assistant(
"I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.\n\nIf those stay quiet through the next batch window, we can close this as an internal degradation.",
);
const history = chat.get();
const INITIAL_VISIBLE_COUNT = 5;
export function MessageScrollerLoadHistory() {
const [demoKey, setDemoKey] = React.useState(0);
const [visibleCount, setVisibleCount] = React.useState(INITIAL_VISIBLE_COUNT);
const visibleMessages = history.slice(-visibleCount);
const canLoadHistory = visibleCount < history.length;
return (
Load History
Prepended messages keep your place.
{
setVisibleCount(INITIAL_VISIBLE_COUNT);
setDemoKey((key) => key + 1);
}}
/>
}
>
Reset
{visibleMessages.map((message) => {
const isUserMessage = message.role === "user";
return (
{getMessageText(message)
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph, index) => (
{paragraph}
))}
);
})}
End of Conversation
{
setVisibleCount(history.length);
toast.add({
title: "History loaded",
...{
description: "Scroll up to see earlier messages.",
},
});
}}
className="w-full"
variant="secondary"
>
{canLoadHistory ? "Load History" : "History Loaded"}
Restore earlier messages while keeping your place.
Click Load History to load the entire conversation
);
}
export default MessageScrollerLoadHistory;
```
Use stable `messageId` values for message rows. That gives the scroller a
specific row to preserve instead of guessing from whichever pixel happens to sit
at the viewport edge.
### Animating New Messages
`MessageScrollerItem` can be animated directly. Create a motion version of the
item, keep `messageId` and `scrollAnchor` on it, and use transform and opacity
for the entrance.
A common chat pattern is to animate the user's message when it is sent, then let
the assistant reply stream into a regular row below it. Start the user row below
its final position so it feels like it rises from the live edge of the viewport.
```tsx
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
```
### Example: message-scroller-animation
```tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
ArrowUpIcon,
MessageCircleDashedIcon,
RotateCwIcon,
} from "lucide-react";
import * as React from "react";
import { createChat } from "../support/ai";
import { MessageAnimated } from "../support/message-animated";
import {
MESSAGE_ANIMATIONS,
type MessageAnimationId,
} from "../support/message-animations";
const chat = createChat()
.user("Can user messages pop in like iMessage without breaking anchoring?")
.sleep(1000)
.assistant(
"Yes. Animate the user row with transform and opacity, and let the assistant response stream normally below it.\n\nThat keeps the row measurement predictable while still giving the newly sent bubble a more tactile entrance.",
)
.user("What makes the animation feel more like iMessage?")
.sleep(1000)
.assistant(
"Use a quick spring from the trailing edge: a little scale, a small upward move, and no layout animation.\n\nThe bubble feels tactile, but the measured row stays predictable, so anchoring and auto-scroll do not have to fight a changing layout.",
)
.user("Can I switch between presets while testing the same thread?")
.sleep(1000)
.assistant(
"Yes. Keep the conversation in place while you change the preset, then send the next message to compare the new entrance against the same context.\n\nThat makes it easier to judge the difference between a subtle fade, a snappy pop, and a more dramatic 3D tilt without rebuilding the scenario each time.",
);
const initialMessages = chat.get(0);
const transport = chat.transport({ delayMs: 15 });
export function MessageScrollerAnimation() {
const { messages, sendMessage, setMessages, status } = useChat({
messages: initialMessages,
transport,
});
const [presetId, setPresetId] = React.useState("fade");
const nextMessage = chat.next(messages);
const isBusy = status === "submitted" || status === "streaming";
const preset = MESSAGE_ANIMATIONS[presetId as MessageAnimationId];
return (
Animation
Choose how user messages are animated when they are added to the
conversation.
setMessages(initialMessages)}
>
{messages.length === 0 ? (
No Messages Yet
Click the button below to send the first message.
) : (
{messages.map((message) => (
))}
)}
{
setPresetId(value as MessageAnimationId);
}}
>
{preset.name}
{Object.values(MESSAGE_ANIMATIONS).map((animation) => (
{animation.name}
))}
{
if (!nextMessage || isBusy) {
return;
}
void sendMessage(nextMessage);
}}
>
Send Message
Select an animation then click send to see it in action.
);
}
export default MessageScrollerAnimation;
```
Avoid animating height, margin, or padding for row entrances; those changes can
fight the scroller's positioning work. If the reader prefers reduced motion,
skip the entrance animation and keep the scroll behavior the same.
### Jumping to Messages
Search results, permalinks, outline items, and toolbar buttons often need to
drive the transcript from outside the message list. Use `useMessageScroller` for
those controls. Because the hooks read from `MessageScrollerProvider`, they work
in any component inside the provider, including controls rendered outside the
`MessageScroller` frame.
```tsx
import { useMessageScroller } from "@workspace/ui/components/message-scroller"
```
```tsx
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
```
### Example: message-scroller-commands
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses append-only positional paragraph fixtures.
"use client";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Message, MessageContent } from "@workspace/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
} from "@workspace/ui/components/message-scroller";
import { createChat, getMessageText } from "../support/ai";
const chat = createChat()
.user(
"We're seeing activation dip after workspace creation. Can you help me find the likely step?",
{ id: "command-activation" },
)
.assistant(
"The sharpest drop is between creating the workspace and inviting the first teammate.\n\nWorkspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.",
)
.user("What should I compare before we change the onboarding flow?", {
id: "command-compare",
})
.assistant(
"Compare three cohorts:\n\n1. Users who choose a template before inviting teammates.\n2. Users who start from a blank workspace.\n3. Users who skip invites and return within 24 hours.\n\nIf template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.",
)
.user("Can you turn that into an experiment?", {
id: "command-experiment",
})
.assistant(
"Yes. Create a variant that shows a short checklist after workspace creation:\n\n- Pick a template.\n- Add one project detail.\n- Invite a teammate when the workspace has context.\n\nMeasure first invite completion, 24-hour return rate, and whether teams create a second project.",
)
.user("What's the risk if we delay the invite prompt?", {
id: "command-risk",
})
.assistant(
"The main risk is reducing team creation for accounts that already know who they want to invite.\n\nTo protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.",
);
const messages = chat.get();
const userMessages = messages.filter((message) => message.role === "user");
export function MessageScrollerCommands() {
return (
Commands
Drive the transcript from outside.
{messages.map((message) => {
const isUserMessage = message.role === "user";
const text = getMessageText(message);
return (
{text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph, index) => (
{paragraph}
))}
);
})}
Use the controls to jump to any message in the conversation.
);
}
function CommandMenu() {
const { scrollToMessage } = useMessageScroller();
return (
}
>
Jump to...
Conversations
{userMessages.map((message) => (
scrollToMessage(message.id, {
align: "start",
behavior: "smooth",
})
}
>
{getTrimmedMessageText(message)}
))}
);
}
function getTrimmedMessageText(message: (typeof userMessages)[number]) {
const text = getMessageText(message);
return text.length > 42 ? `${text.slice(0, 39)}...` : text;
}
export default MessageScrollerCommands;
```
`scrollToMessage` targets the `messageId` on `MessageScrollerItem`, so rows that
need to be addressable should have stable ids. `scrollToMessage` returns `false`
when the target is not mounted and cannot be queued.
`scrollToMessage` can queue a target before items exist, which covers
client-resolved permalinks while the transcript mounts. After rows have mounted,
a missing id returns `false` instead of starting a guessed retry loop. A `true`
result means the scroll ran or was queued, not that the row is already in view.
### Tracking the Reader's Position
Use `useMessageScrollerVisibility` to track the reader's position in the
conversation. A common example is a table-of-contents or a jump menu that
highlights the current anchored turn.
```tsx
import { useMessageScrollerVisibility } from "@workspace/ui/components/message-scroller"
```
```tsx
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
```
### Example: message-scroller-visibility
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses append-only positional paragraph fixtures.
"use client";
import { Bubble, BubbleContent } from "@workspace/ui/components/bubble";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import { Message, MessageContent } from "@workspace/ui/components/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerVisibility,
} from "@workspace/ui/components/message-scroller";
import { createChat, getMessageText } from "../support/ai";
const chat = createChat()
.user("Review the incident handoff and tell me what to read first.", {
id: "vis-brief",
})
.assistant(
"Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.",
)
.user("What was the customer impact?", {
id: "vis-impact",
})
.assistant(
"Impact was limited to delayed processing.\n\nNo records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.",
)
.user("What actions are open?", {
id: "vis-actions",
})
.assistant(
"Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.",
)
.user("Give me the follow-up checklist.", {
id: "vis-checklist",
})
.assistant(
"After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline. That makes it easier for support and engineering to answer the same customer questions without re-reading the whole incident thread.\n\nI would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.\n\nKeep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\n\nThe alert should fire on sustained queue growth, not a single short spike.",
);
const messages = chat.get();
const userMessages = messages.filter((message) => message.role === "user");
export function MessageScrollerVisibility() {
return (
Transcript Outline
Track the current anchored turn.
{messages.map((message) => {
const isUserMessage = message.role === "user";
const text = getMessageText(message);
return (
{text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph, index) => (
{paragraph}
))}
);
})}
Open the outline to jump between anchored turns as you read.
);
}
function TranscriptOutline() {
const { scrollToMessage } = useMessageScroller();
const { currentAnchorId } = useMessageScrollerVisibility();
return (
}
>
{userMessages.map((message) => (
))}
{userMessages.map((message) => (
scrollToMessage(message.id, {
align: "start",
behavior: "smooth",
})
}
>
{getTrimmedMessageText(message)}
))}
);
}
function getTrimmedMessageText(message: (typeof userMessages)[number]) {
const text = getMessageText(message);
return text.length > 42 ? `${text.slice(0, 39)}...` : text;
}
export default MessageScrollerVisibility;
```
`currentAnchorId` answers "where am I" by reporting the current anchored turn,
and it stays set after that anchor scrolls above the viewport. `visibleMessageIds`
answers "what is on screen", in document order.
Visibility is pay-for-what-you-use. Tracking only runs while something
subscribes to `useMessageScrollerVisibility`, and rows need a `messageId` to
participate.
### Reading Scroll State
Use `useMessageScrollerScrollable` when you need scroll state in JavaScript, such
as a status indicator or a custom "jump to latest" control. It reports which
edges the viewport can still scroll toward; "at the start/end" is the negation
(`!start` / `!end`), and "scrollable at all" is `start || end`. For styling the
scroller itself, prefer the `data-scrollable` attribute.
```tsx
import { useMessageScrollerScrollable } from "@workspace/ui/components/message-scroller"
```
```tsx
const { start, end } = useMessageScrollerScrollable()
```
### Example: message-scroller-scrollable
```tsx
"use client";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScrollerScrollable,
} from "@workspace/ui/components/message-scroller";
import { MessageAnimated } from "../support/message-animated";
const messages = Array.from({ length: 12 }, (_, index) => ({
id: `scrollable-${index + 1}`,
role: index % 2 === 0 ? "user" : "assistant",
text:
index % 2 === 0
? `Review scroll checkpoint ${index + 1}.`
: `Checkpoint ${index + 1} is synced. The scrollable hook updates as the viewport moves.\n\nWhen the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.\n\nAt the latest message, the footer should switch again and only point them back up.`,
})) satisfies Array<{
id: string;
role: "user" | "assistant";
text: string;
}>;
export function MessageScrollerScrollable() {
return (
Scroll Status
Where the reader can go scroll to based on current scroll position.
Scroll the transcript to see the footer update.
);
}
function Transcript() {
return messages.map((message) => (
));
}
function ScrollStateFooter() {
const { start, end } = useMessageScrollerScrollable();
const status = getScrollStatus({ start, end });
return (
{status}
);
}
function getScrollStatus({ start, end }: { start: boolean; end: boolean }) {
if (start && end) {
return "You can scroll both ways.";
}
if (end) {
return "You are at the top. You can only scroll down.";
}
if (start) {
return "You are at the bottom. You can only scroll up.";
}
return "All messages fit in the viewport.";
}
export default MessageScrollerScrollable;
```
## Performance
`MessageScroller` is benchmarked against large transcripts with markdown and
composed message rows.
Our performance goal for `MessageScroller` is to keep the scroll hot path outside of React state: no React rerenders for
transcript rows, no forced layout on every scroll, and as little off-screen paint
work as the browser can avoid.
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through `data-*` attributes, so scrolling and streaming do not rerender transcript rows.
The styled `MessageScrollerItem` also ships with `content-visibility: auto` and
`contain-intrinsic-size`. Rows stay in the DOM for selection, copy,
find-in-page, SSR, and assistive tech, but the browser can skip rendering work
for rows far outside the viewport.
Visibility tracking is pay-for-what-you-use. A jump menu or active
turn indicator costs nothing until something subscribes to
`useMessageScrollerVisibility`.
This is comfortable for the expected range of a chat transcript: hundreds to low
thousands of turns, including messages with markdown and composed components.
## Virtualization
Virtualization is intentionally left outside the primitive. `MessageScroller`
renders real DOM rows and stays fast well into the thousands of turns (see
[Performance](#performance)), so most transcripts never need it.
When a transcript is large enough to need virtualization, use
`MessageScrollerViewport` as the scroll element and let the virtualizer own the
rows.
```tsx showLineNumbers
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
function VirtualizedTranscript({
messages,
}: {
messages: Array<{ id: string; content: React.ReactNode }>
}) {
const viewportRef = React.useRef(null)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 86,
getItemKey: (index) => messages[index]?.id ?? index,
overscan: 8,
})
return (
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index]
if (!message) {
return null
}
return (
{message.content}
)
})}
)
}
```
## Accessibility
`MessageScroller` keeps the scroll container keyboard reachable and the
transcript announceable without forcing a specific message UI.
`MessageScrollerViewport` is a labelled, keyboard-focusable scroll region by
default. It uses `role="region"`, `aria-label="Messages"`, and `tabIndex={0}`,
so keyboard users can focus the transcript and scroll it directly.
`MessageScrollerContent` marks the transcript as a live region with
`role="log"` and `aria-relevant="additions"`. New rows can be announced, but
streamed text mutations do not have to be announced token by token.
```tsx
{/* messages */}
```
Pass `aria-busy` while a turn streams if announcements should wait for the
completed message row.
`MessageScrollerButton` renders a real button. When there is nothing to scroll
toward, it sets `inert`, uses `tabIndex={-1}`, and exposes `data-active="false"`
so inactive scroll controls do not create extra focus stops.
## Unstyled
The behavior in `MessageScroller` comes from the `@shadcn/react` package. To use
it directly with your own markup and styles, see
[Message Scroller](https://ui.shadcn.com/docs/react/message-scroller) under @shadcn/react.
## API Reference
The props, data attributes, and hooks for every part are documented on the
[@shadcn/react Message Scroller](https://ui.shadcn.com/docs/react/message-scroller#api-reference) page.
They are identical for the styled component and the unstyled parts.
---
# Native Select
A styled native HTML select element with consistent design system integration.
Page: https://sui.draco.dev/docs/components/native-select
For a styled select component, see the [Select](/docs/components/select)
component.
### Example: native-select-demo
```tsx
import {
NativeSelect,
NativeSelectOption,
} from "@workspace/ui/components/native-select";
export default function NativeSelectDemo() {
return (
Select status
Todo
In Progress
Done
Cancelled
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/native-select
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
} from "@workspace/ui/components/native-select"
```
```tsx showLineNumbers
Select a fruit
Apple
Banana
Blueberry
Pineapple
```
## Composition
### Simple
Options placed directly under `NativeSelect` (no `NativeSelectOptGroup`).
```text
NativeSelect
├── NativeSelectOption
├── NativeSelectOption
├── NativeSelectOption
└── NativeSelectOption
```
### With groups
Use `NativeSelectOptGroup` to organize options into categories.
```text
NativeSelect
├── NativeSelectOptGroup
│ ├── NativeSelectOption
│ └── NativeSelectOption
└── NativeSelectOptGroup
├── NativeSelectOption
└── NativeSelectOption
```
## Groups
Use `NativeSelectOptGroup` to organize options into categories.
### Example: native-select-groups
```tsx
import {
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
} from "@workspace/ui/components/native-select";
export default function NativeSelectGroups() {
return (
Select department
Frontend
Backend
DevOps
Sales Rep
Account Manager
Sales Director
Customer Support
Product Manager
Operations Manager
);
}
```
## Disabled
Add the `disabled` prop to the `NativeSelect` component to disable the select.
### Example: native-select-disabled
```tsx
import {
NativeSelect,
NativeSelectOption,
} from "@workspace/ui/components/native-select";
export function NativeSelectDisabled() {
return (
Disabled
Apple
Banana
Blueberry
);
}
export default NativeSelectDisabled;
```
## Invalid
Use `aria-invalid` to show validation errors and the `data-invalid` attribute to the `Field` component for styling.
### Example: native-select-invalid
```tsx
import {
NativeSelect,
NativeSelectOption,
} from "@workspace/ui/components/native-select";
export function NativeSelectInvalid() {
return (
Error state
Apple
Banana
Blueberry
);
}
export default NativeSelectInvalid;
```
## Native Select vs Select
- Use `NativeSelect` for native browser behavior, better performance, or mobile-optimized dropdowns.
- Use `Select` for custom styling, animations, or complex interactions.
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: native-select-rtl
```tsx
"use client";
import {
NativeSelect,
NativeSelectOption,
} from "@workspace/ui/components/native-select";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
placeholder: "Select status",
todo: "Todo",
inProgress: "In Progress",
done: "Done",
cancelled: "Cancelled",
},
},
ar: {
dir: "rtl",
values: {
placeholder: "اختر الحالة",
todo: "مهام",
inProgress: "قيد التنفيذ",
done: "منجز",
cancelled: "ملغي",
},
},
he: {
dir: "rtl",
values: {
placeholder: "בחר סטטוס",
todo: "לעשות",
inProgress: "בתהליך",
done: "הושלם",
cancelled: "בוטל",
},
},
};
export function NativeSelectRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.placeholder}
{t.todo}
{t.inProgress}
{t.done}
{t.cancelled}
);
}
export default NativeSelectRtl;
```
## API Reference
### NativeSelect
The main select component that wraps the native HTML select element.
```tsx
Option 1
Option 2
```
### NativeSelectOption
Represents an individual option within the select.
| Prop | Type | Default |
| ---------- | --------- | ------- |
| `value` | `string` | |
| `disabled` | `boolean` | `false` |
### NativeSelectOptGroup
Groups related options together for better organization.
| Prop | Type | Default |
| ---------- | --------- | ------- |
| `label` | `string` | |
| `disabled` | `boolean` | `false` |
```tsx
Apple
Banana
```
---
# Navigation Menu
A collection of links for navigating websites.
Page: https://sui.draco.dev/docs/components/navigation-menu
### Example: navigation-menu-demo
```tsx
"use client";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@workspace/ui/components/navigation-menu";
import {
CircleAlertIcon,
CircleCheckIcon,
CircleDashedIcon,
} from "lucide-react";
import type * as React from "react";
const components: { title: string; href: string; description: string }[] = [
{
title: "Alert Dialog",
href: "/docs/primitives/alert-dialog",
description:
"A modal dialog that interrupts the user with important content and expects a response.",
},
{
title: "Hover Card",
href: "/docs/primitives/hover-card",
description:
"For sighted users to preview content available behind a link.",
},
{
title: "Progress",
href: "/docs/primitives/progress",
description:
"Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.",
},
{
title: "Scroll-area",
href: "/docs/primitives/scroll-area",
description: "Visually or semantically separates content.",
},
{
title: "Tabs",
href: "/docs/primitives/tabs",
description:
"A set of layered sections of content—known as tab panels—that are displayed one at a time.",
},
{
title: "Tooltip",
href: "/docs/primitives/tooltip",
description:
"A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.",
},
];
export default function NavigationMenuDemo() {
return (
Getting started
Re-usable components built with Tailwind CSS.
How to install dependencies and structure your app.
Styles for headings, paragraphs, lists...etc
Components
{components.map((component) => (
{component.description}
))}
With Icon
}
>
Backlog
}
>
To Do
}
>
Done
}
className={navigationMenuTriggerStyle()}
>
Docs
);
}
function ListItem({
title,
children,
href,
...props
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
return (
}>
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/navigation-menu
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@workspace/ui/components/navigation-menu"
```
```tsx showLineNumbers
Item One
Link
```
## Composition
Use the following composition to build a `NavigationMenu`:
```text
NavigationMenu
├── NavigationMenuList
│ ├── NavigationMenuItem
│ │ ├── NavigationMenuTrigger
│ │ └── NavigationMenuContent
│ │ ├── NavigationMenuLink
│ │ └── NavigationMenuLink
│ └── NavigationMenuItem
│ └── NavigationMenuLink
└── NavigationMenuIndicator
```
## Link Component
Use the `render` prop to compose a custom link component such as Next.js `Link`.
```tsx showLineNumbers
import Link from "next/link"
import {
NavigationMenuItem,
NavigationMenuLink,
navigationMenuTriggerStyle,
} from "@workspace/ui/components/navigation-menu"
export function NavigationMenuDemo() {
return (
}
className={navigationMenuTriggerStyle()}
>
Documentation
)
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: navigation-menu-rtl
```tsx
"use client";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@workspace/ui/components/navigation-menu";
import {
CircleAlertIcon,
CircleCheckIcon,
CircleDashedIcon,
} from "lucide-react";
import type * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
gettingStarted: "Getting started",
introduction: "Introduction",
introductionDesc: "Re-usable components built with Tailwind CSS.",
installation: "Installation",
installationDesc: "How to install dependencies and structure your app.",
typography: "Typography",
typographyDesc: "Styles for headings, paragraphs, lists...etc",
components: "Components",
alertDialog: "Alert Dialog",
alertDialogDesc:
"A modal dialog that interrupts the user with important content and expects a response.",
hoverCard: "Hover Card",
hoverCardDesc:
"For sighted users to preview content available behind a link.",
progress: "Progress",
progressDesc:
"Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.",
scrollArea: "Scroll-area",
scrollAreaDesc: "Visually or semantically separates content.",
tabs: "Tabs",
tabsDesc:
"A set of layered sections of content—known as tab panels—that are displayed one at a time.",
tooltip: "Tooltip",
tooltipDesc:
"A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.",
withIcon: "With Icon",
backlog: "Backlog",
toDo: "To Do",
done: "Done",
docs: "Docs",
},
},
ar: {
dir: "rtl",
values: {
gettingStarted: "البدء",
introduction: "مقدمة",
introductionDesc:
"مكونات قابلة لإعادة الاستخدام مبنية باستخدام Tailwind CSS.",
installation: "التثبيت",
installationDesc: "كيفية تثبيت التبعيات وتنظيم تطبيقك.",
typography: "الطباعة",
typographyDesc: "أنماط للعناوين والفقرات والقوائم...إلخ",
components: "المكونات",
alertDialog: "حوار التنبيه",
alertDialogDesc: "حوار نافذة يقطع المستخدم بمحتوى مهم ويتوقع استجابة.",
hoverCard: "بطاقة التحويم",
hoverCardDesc: "للمستخدمين المبصرين لمعاينة المحتوى المتاح خلف الرابط.",
progress: "التقدم",
progressDesc:
"يعرض مؤشرًا يوضح تقدم إتمام المهمة، عادةً يتم عرضه كشريط تقدم.",
scrollArea: "منطقة التمرير",
scrollAreaDesc: "يفصل المحتوى بصريًا أو دلاليًا.",
tabs: "التبويبات",
tabsDesc:
"مجموعة من أقسام المحتوى المتعددة الطبقات—المعروفة بألواح التبويب—التي يتم عرضها واحدة في كل مرة.",
tooltip: "تلميح",
tooltipDesc:
"نافذة منبثقة تعرض معلومات متعلقة بعنصر عندما يتلقى العنصر التركيز على لوحة المفاتيح أو عند تحويم الماوس فوقه.",
withIcon: "مع أيقونة",
backlog: "قائمة الانتظار",
toDo: "المهام",
done: "منجز",
docs: "الوثائق",
},
},
he: {
dir: "rtl",
values: {
gettingStarted: "התחלה",
introduction: "הקדמה",
introductionDesc: "רכיבים לשימוש חוזר שנבנו עם Tailwind CSS.",
installation: "התקנה",
installationDesc: "כיצד להתקין תלויות ולבנות את האפליקציה שלך.",
typography: "טיפוגרפיה",
typographyDesc: "סגנונות לכותרות, פסקאות, רשימות...וכו'",
components: "רכיבים",
alertDialog: "דיאלוג התראה",
alertDialogDesc: "דיאלוג מודאלי שמפריע למשתמש עם תוכן חשוב ומצפה לתגובה.",
hoverCard: "כרטיס ריחוף",
hoverCardDesc:
"למשתמשים רואים כדי להציג תצוגה מקדימה של תוכן זמין מאחורי קישור.",
progress: "התקדמות",
progressDesc:
"מציג אינדיקטור המציג את התקדמות ההשלמה של משימה, בדרך כלל מוצג כסרגל התקדמות.",
scrollArea: "אזור גלילה",
scrollAreaDesc: "מפריד תוכן חזותית או סמנטית.",
tabs: "כרטיסיות",
tabsDesc:
"קבוצה של חלקי תוכן מרובדים—המכונים לוחות כרטיסיות—המוצגים אחד בכל פעם.",
tooltip: "טולטיפ",
tooltipDesc:
"חלון קופץ המציג מידע הקשור לאלמנט כאשר האלמנט מקבל מיקוד מקלדת או כאשר העכבר מרחף מעליו.",
withIcon: "עם אייקון",
backlog: "רשימת המתנה",
toDo: "לעשות",
done: "הושלם",
docs: "תיעוד",
},
},
};
const components = [
{
titleKey: "alertDialog" as const,
descriptionKey: "alertDialogDesc" as const,
href: "/docs/primitives/alert-dialog",
},
{
titleKey: "hoverCard" as const,
descriptionKey: "hoverCardDesc" as const,
href: "/docs/primitives/hover-card",
},
{
titleKey: "progress" as const,
descriptionKey: "progressDesc" as const,
href: "/docs/primitives/progress",
},
{
titleKey: "scrollArea" as const,
descriptionKey: "scrollAreaDesc" as const,
href: "/docs/primitives/scroll-area",
},
{
titleKey: "tabs" as const,
descriptionKey: "tabsDesc" as const,
href: "/docs/primitives/tabs",
},
{
titleKey: "tooltip" as const,
descriptionKey: "tooltipDesc" as const,
href: "/docs/primitives/tooltip",
},
] as const;
export function NavigationMenuRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
return (
{t.gettingStarted}
{t.introductionDesc}
{t.installationDesc}
{t.typographyDesc}
{t.components}
{components.map((component) => (
{t[component.descriptionKey]}
))}
{t.withIcon}
}
>
{t.backlog}
}
>
{t.toDo}
}
>
{t.done}
}
className={navigationMenuTriggerStyle()}
data-lang={dir === "rtl" ? language : undefined}
>
{t.docs}
);
}
function ListItem({
title,
children,
href,
...props
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
return (
}>
);
}
export default NavigationMenuRtl;
```
## API Reference
See the [Base UI Navigation Menu](https://base-ui.com/react/components/navigation-menu#api-reference) documentation for more information.
- [Documentation](https://base-ui.com/react/components/navigation-menu)
- [API reference](https://base-ui.com/react/components/navigation-menu#api-reference)
---
# NavigationProgress
A controlled navigation loading bar with delayed start and completion feedback.
Page: https://sui.draco.dev/docs/components/navigation-progress
### Example: navigation-progress-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import { NavigationProgress } from "@workspace/ui/components/navigation-progress";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function NavigationProgressDemo({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
navigating: "页面切换中…",
ready: "页面已就绪",
}
: {
navigating: "Navigating…",
ready: "Page ready",
};
const [active, setActive] = useState(false);
return (
{active ? stateLabels.navigating : stateLabels.ready}
setActive(true)} disabled={active}>
{zh ? "开始导航" : "Start navigation"}
setActive(false)}
disabled={!active}
>
{zh ? "完成" : "Complete"}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/navigation-progress
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
```tsx
import { NavigationProgress } from "@workspace/ui/components/navigation-progress"
```
## Router integration
The shared UI component accepts loading state from any router. The documentation application connects it to TanStack Router:
```tsx
import { useRouterState } from "@tanstack/react-router"
import { NavigationProgress } from "@workspace/ui/components/navigation-progress"
function RouterProgress() {
const active = useRouterState({ select: (state) => state.status === "pending" })
return
}
```
The bar waits before appearing, advances toward a capped estimate, completes when `active` becomes false, then hides. Quick transitions that finish before `delay` produce no flash. The displayed length is an estimate; the accessible progress state remains indeterminate during loading. Reduced-motion preferences disable transition animation.
## API
| Prop | Default | Description |
| --- | --- | --- |
| `active` | Required | Whether navigation is pending. |
| `delay` | `120` | Delay before showing the bar, in milliseconds. |
| `finishDelay` | `180` | Time to show completion before hiding. |
| `position` | `fixed` | `fixed` for the page top, `absolute` within a positioned container. |
| `label` | `Loading page` | Accessible progress name. |
| `className` | — | Placement and layout classes. |
Inspired by [shadcn-admin NavigationProgress](https://github.com/satnaing/shadcn-admin/blob/main/src/components/navigation-progress.tsx). This implementation uses the existing Base UI dependency, SUI semantic tokens, and React timers, with no `react-top-loading-bar` or router dependency in the UI package.
---
# Pagination
Compact data pagination with range information, page navigation, and page-size selection.
Page: https://sui.draco.dev/docs/components/pagination
`Pagination` combines a range summary and compact navigation controls. Use it for data tables, lists, or sequential API results. The component manages navigation state; your application supplies and renders the data for the selected page.
### Example: pagination-demo
```tsx
"use client";
import {
Pagination,
type PaginationRangeInfo,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
const records = Array.from({ length: 100 }, (_, index) => ({
id: `request-${index + 1}`,
number: index + 1,
}));
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(1);
return (
{records.slice((page - 1) * 10, page * 10).map((record) => (
{chinese ? "请求" : "Request"} {record.number}
))}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/pagination
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
When `children` is omitted, `Pagination` renders `PaginationInfo` and `PaginationControls` automatically. Provide `totalCount` to enable first, previous, page input, next, and last controls.
```tsx
import { Pagination } from "@workspace/ui/components/pagination";
import { useState } from "react";
export function ResultsPagination() {
const [page, setPage] = useState(1);
return (
);
}
```
Pages start at `1`. Omit `page` for internal page state, starting at page `1`; `onPageChange` still reports changes. With a controlled `page`, update it in `onPageChange`. Use the current page and page size to slice local data or request the corresponding server results.
For known totals, the page input commits on Enter or blur. Whole-number input is clamped to the available page range; empty, fractional, or nonnumeric input restores the current page. Escape cancels editing. Enter does not submit an enclosing form.
## Simple
Set `controls="simple"` to retain the range summary and show only previous and next buttons. The first and last buttons and page selector are hidden.
### Example: pagination-simple
```tsx
"use client";
import {
Pagination,
type PaginationRangeInfo,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(1);
return (
{chinese ? `当前页:${page}` : `Current page: ${page}`}
);
}
```
```tsx
```
## Unknown totals
Omit `totalCount` for APIs that only indicate whether another page exists. Set `hasNextPage` from the response. Unknown totals always use sequential previous and next controls, even when `controls="full"`; there is no first, last, or arbitrary-page selector.
The default summary is `Page N`. `PaginationInfo` can render a custom summary. Its `from` and `to` values are inferred from the page size when the total is unknown; they do not describe the actual number of items returned by the server.
### Example: pagination-unknown-total
```tsx
"use client";
import {
Pagination,
PaginationControls,
PaginationInfo,
type PaginationRangeInfo,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
const batches = [
["evt-101", "evt-102", "evt-103"],
["evt-201", "evt-202", "evt-203"],
["evt-301", "evt-302"],
];
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(1);
return (
{batches[page - 1]?.map((event) => (
{chinese ? "事件" : "Event"} {event}
))}
{({ page: currentPage }) =>
chinese ? `第 ${currentPage} 批` : `Batch ${currentPage}`
}
{chinese
? "此模拟接口只返回下一批是否存在,不提供总数;第 3 批没有下一批"
: "This simulated response reports only whether another batch exists. Batch 3 has no next page."}
);
}
```
```tsx
```
Without `hasNextPage={true}`, the next button is disabled. Previous navigation remains available after the first page.
## States
Known totals work with middle pages and large datasets. `totalCount={0}` displays `Showing 0–0 of 0` and disables navigation. Root `disabled` also disables the page selector, navigation buttons, and nested page-size selector.
### Example: pagination-states
```tsx
"use client";
import {
Pagination,
PaginationControls,
PaginationInfo,
PaginationPageSize,
type PaginationRangeInfo,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [middlePage, setMiddlePage] = useState(5);
const [largePage, setLargePage] = useState(1);
const [disabledPage, setDisabledPage] = useState(3);
const [disabledSize, setDisabledSize] = useState(10);
return (
{chinese ? "中间页" : "Middle page"}
{chinese ? "大数据集" : "Large dataset"}
{chinese ? "没有结果" : "No results"}
{chinese ? "禁用状态" : "Disabled"}
);
}
```
If the total or page size changes and the requested page is outside the new range, the UI immediately displays the nearest valid page and reports that correction through `onPageChange`.
## Composition
Pass children to arrange the summary, controls, and page-size selector yourself. `PaginationInfo`, `PaginationControls`, and `PaginationPageSize` read shared state from the parent `Pagination`. Passing `children={null}` intentionally renders no default content.
```tsx
import {
Pagination,
PaginationControls,
PaginationInfo,
PaginationPageSize,
PaginationSeparator,
} from "@workspace/ui/components/pagination";
```
```text
Pagination
├── PaginationInfo
└── div
├── PaginationPageSize
├── PaginationSeparator
└── PaginationControls
```
### Page sizes and custom summaries
`PaginationPageSize` is controlled independently. Keep its `value` synchronized with root `perPage`, and usually return to page `1` when the size changes. Its options default to `[10, 20, 50, 100]`; invalid or repeated options are removed, and the current value is included automatically.
Use a render function in `PaginationInfo` to customize the summary. Alternatively, set `labels.range` once on the root to customize every default `PaginationInfo` summary.
### Example: pagination-custom
```tsx
"use client";
import {
Pagination,
PaginationControls,
PaginationInfo,
PaginationPageSize,
type PaginationRangeInfo,
PaginationSeparator,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
return (
{({ page: currentPage, pageCount, perPage: size }) =>
chinese
? `第 ${currentPage} 页/共 ${pageCount} 页,每页 ${size} 条`
: `Page ${currentPage} of ${pageCount}, ${size} rows per page`
}
{
setPerPage(size);
setPage(1);
}}
options={[10, 20, 50]}
label={chinese ? "每页条数" : "Rows per page"}
/>
);
}
```
```tsx
{({ page, pageCount, perPage }) =>
`Page ${page} of ${pageCount}, ${perPage} rows per page`
}
{
setPerPage(size);
setPage(1);
}}
options={[10, 20, 50]}
label="Rows per page"
/>
```
### Dropdown page selector
Set `pageSelector="dropdown"` on `PaginationControls` to select a page from a menu. This applies to full controls with a known total. Above 200 pages, it automatically falls back to the numeric input to keep the menu bounded.
### Example: pagination-dropdown
```tsx
"use client";
import {
Pagination,
PaginationControls,
PaginationInfo,
PaginationPageSize,
type PaginationRangeInfo,
PaginationSeparator,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
return (
{
setPerPage(size);
setPage(1);
}}
options={[10, 25, 50]}
label={chinese ? "每页条数" : "Rows per page"}
/>
);
}
```
```tsx
```
## Icons only
Combine simple controls with a page-size selector for a compact table footer. The navigation buttons use icons with accessible labels.
### Example: pagination-icons-only
```tsx
"use client";
import {
Pagination,
PaginationControls,
PaginationInfo,
PaginationPageSize,
type PaginationRangeInfo,
PaginationSeparator,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
return (
{
setPerPage(size);
setPage(1);
}}
options={[10, 25, 50]}
label={chinese ? "每页条数" : "Rows per page"}
/>
);
}
```
## Traditional numbered links
The existing `PaginationContent`, `PaginationItem`, `PaginationLink`, `PaginationPrevious`, `PaginationNext`, and `PaginationEllipsis` components remain available for URL-based navigation. They do not automatically update root page state or generate page numbers; supply the links, current page, and disabled boundaries yourself.
`PaginationLink` renders an anchor and uses `isActive` to set `aria-current="page"`. Disabled links have no `href`, are removed from the Tab order, and ignore click handlers. The example uses meaningful `?page=N` destinations and handles navigation locally so the preview stays on the documentation page.
### Example: pagination-links
```tsx
"use client";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
const pageNumbers = [1, 2, 3, 4, 5];
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [page, setPage] = useState(2);
return (
{
event.preventDefault();
setPage(Math.max(1, page - 1));
}}
/>
{pageNumbers.map((number) => (
{
event.preventDefault();
setPage(number);
}}
>
{number}
))}
{
event.preventDefault();
setPage(Math.min(5, page + 1));
}}
/>
{chinese ? `当前页:${page}` : `Current page: ${page}`}
);
}
```
```tsx
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@workspace/ui/components/pagination";
```
```tsx
1
2
```
## RTL
Wrap right-to-left interfaces in the shared `DirectionProvider` and set `dir="rtl"` on the relevant container. Navigation icons mirror automatically. Pass translated `labels` and a localized `range` formatter separately; direction does not select a language.
### Example: pagination-rtl
```tsx
"use client";
import { DirectionProvider } from "@workspace/ui/components/direction";
import {
Pagination,
type PaginationRangeInfo,
} from "@workspace/ui/components/pagination";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const labels = chinese
? {
navigation: "分页",
firstPage: "首页",
previousPage: "上一页",
nextPage: "下一页",
lastPage: "末页",
pageNumber: "页码",
pageSize: "每页条数",
range: ({ page, totalCount, from, to }: PaginationRangeInfo) =>
totalCount === undefined
? `第 ${page} 页`
: `显示第 ${from}–${to} 条,共 ${totalCount} 条`,
}
: undefined;
const [page, setPage] = useState(5);
return (
);
}
```
## Accessibility and localization
The root is a labeled `nav`. Every icon button and selector has an accessible name from `labels`; `PaginationInfo` announces summary changes politely. All data-navigation buttons use `type="button"`.
The page input supports keyboard editing, Enter to commit, and Escape to restore the current page. The dropdown selectors use the shared [Select](/docs/components/select) component and its keyboard behavior.
Translate `navigation`, `firstPage`, `previousPage`, `nextPage`, `lastPage`, `pageNumber`, `pageSize`, and `range`. For traditional links, also translate the visible `text` on `PaginationPrevious` and `PaginationNext`, and provide meaningful labels for numbered links. Set the `PaginationPageSize` visible `label` explicitly when localizing it.
## API reference
### Pagination
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `page` | `number` | Internal state, initially `1` | Controlled one-based page. |
| `onPageChange` | `(page: number) => void` | — | Reports navigation and corrections after range changes. |
| `perPage` | `number` | `10` | Items per page. |
| `totalCount` | `number` | — | Total items; omit for unknown totals. |
| `hasNextPage` | `boolean` | — | Enables the next page when the total is unknown. |
| `controls` | `"full" \| "simple"` | `"full"` | Default controls mode. |
| `disabled` | `boolean` | `false` | Disables nested navigation and selectors. |
| `labels` | `PaginationLabels` | English labels | Accessible names and summary formatter. |
| `children` | `ReactNode` | Info + controls | Custom composition; `null` suppresses the default layout. |
Other native `nav` props, including `className`, `aria-label`, and `ref`, are forwarded.
### Parts
| Component | Additional props | Behavior |
| --- | --- | --- |
| `PaginationInfo` | `children?: ReactNode \| ((info: PaginationRangeInfo) => ReactNode)` | Custom content or summary render function. |
| `PaginationControls` | `controls?: "full" \| "simple"`, `pageSelector?: "input" \| "dropdown"` | Inherits root mode; selector defaults to `"input"`. |
| `PaginationPageSize` | `value: number`, `onValueChange: (value: number) => void`, `options?: readonly number[]`, `label?: ReactNode`, `disabled?: boolean` | Controlled size selector; visible label defaults to `"Rows per page"`; `label={null}` hides it. |
| `PaginationSeparator` | [Separator props](/docs/components/separator) | Vertical by default. |
| `PaginationLink` | `isActive?: boolean`, `disabled?: boolean`, `size` | Native anchor props plus active and disabled states. |
| `PaginationPrevious`, `PaginationNext` | Link props, `text?: string` | Previous/next links; visible text defaults to `"Previous"`/`"Next"`. |
`PaginationInfo`, `PaginationControls`, and `PaginationPageSize` forward native `div` props. `PaginationRangeInfo` contains `page`, `perPage`, `from`, `to`, and optional `totalCount` and `pageCount`. Empty known totals produce `from=0`, `to=0`, and `pageCount=1`.
The range, labels, and component prop types are exported from `@workspace/ui/components/pagination`. The composed selectors use the shared Select wrapper; see the [Base UI Select API](https://base-ui.com/react/components/select) for its underlying behavior.
---
# Popover
Displays rich content in a portal, triggered by a button.
Page: https://sui.draco.dev/docs/components/popover
### Example: popover-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { useId as usePreviewId } from "react";
export default function PopoverDemo() {
const previewId = usePreviewId();
return (
}>
Open popover
Dimensions
Set the dimensions for the layer.
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/popover
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover"
```
```tsx showLineNumbers
}>
Open Popover
Title
Description text here.
```
## Composition
Use the following composition to build a `Popover`:
```text
Popover
├── PopoverTrigger
└── PopoverContent
```
## Basic
A simple popover with a header, title, and description.
### Example: popover-basic
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover";
export function PopoverBasic() {
return (
}>
Open Popover
Dimensions
Set the dimensions for the layer.
);
}
export default PopoverBasic;
```
## Align
Use the `align` prop on `PopoverContent` to control the horizontal alignment.
### Example: popover-alignments
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
export function PopoverAlignments() {
return (
}>
Start
Aligned to start
}>
Center
Aligned to center
}>
End
Aligned to end
);
}
export default PopoverAlignments;
```
## With Form
A popover with form fields inside.
### Example: popover-form
```tsx
import { Button } from "@workspace/ui/components/button";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { useId as usePreviewId } from "react";
export function PopoverForm() {
const previewId = usePreviewId();
return (
}>
Open Popover
Dimensions
Set the dimensions for the layer.
Width
Height
);
}
export default PopoverForm;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: popover-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
title: "Dimensions",
description: "Set the dimensions for the layer.",
"inline-start": "Inline Start",
left: "Left",
top: "Top",
bottom: "Bottom",
right: "Right",
"inline-end": "Inline End",
},
},
ar: {
dir: "rtl",
values: {
title: "الأبعاد",
description: "تعيين الأبعاد للطبقة.",
"inline-start": "بداية السطر",
left: "يسار",
top: "أعلى",
bottom: "أسفل",
right: "يمين",
"inline-end": "نهاية السطر",
},
},
he: {
dir: "rtl",
values: {
title: "מימדים",
description: "הגדר את המימדים לשכבה.",
"inline-start": "תחילת השורה",
left: "שמאל",
top: "למעלה",
bottom: "למטה",
right: "ימין",
"inline-end": "סוף השורה",
},
},
};
const physicalSides = ["left", "top", "bottom", "right"] as const;
const logicalSides = ["inline-start", "inline-end"] as const;
export function PopoverRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{physicalSides.map((side) => (
}>
{t[side]}
{t.title}
{t.description}
))}
{logicalSides.map((side) => (
}>
{t[side]}
{t.title}
{t.description}
))}
);
}
export default PopoverRtl;
```
## API Reference
See the [Base UI Popover](https://base-ui.com/react/components/popover#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/popover)
- [API reference](https://base-ui.com/react/components/popover#api-reference)
---
# Progress
Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.
Page: https://sui.draco.dev/docs/components/progress
### Example: progress-demo
```tsx
"use client";
import { Progress } from "@workspace/ui/components/progress";
import * as React from "react";
export default function ProgressDemo() {
const [progress, setProgress] = React.useState(13);
React.useEffect(() => {
const timer = setTimeout(() => setProgress(66), 500);
return () => clearTimeout(timer);
}, []);
return ;
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/progress
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Progress } from "@workspace/ui/components/progress"
```
```tsx showLineNumbers
```
## Composition
### With label and value
Use `ProgressLabel` and `ProgressValue` to add a label and value display.
```tsx showLineNumbers
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@workspace/ui/components/progress"
;
Upload progress
```
```text
Progress
├── ProgressLabel
├── ProgressValue
└── ProgressTrack
└── ProgressIndicator
```
## Label
Use `ProgressLabel` and `ProgressValue` to add a label and value display.
### Example: progress-label
```tsx
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@workspace/ui/components/progress";
export function ProgressWithLabel() {
return (
Upload progress
);
}
export default ProgressWithLabel;
```
## Controlled
A progress bar that can be controlled by a slider.
### Example: progress-controlled
```tsx
"use client";
import { Progress } from "@workspace/ui/components/progress";
import { Slider } from "@workspace/ui/components/slider";
import * as React from "react";
export function ProgressControlled() {
const [value, setValue] = React.useState(50);
return (
setValue(value as number)}
min={0}
max={100}
step={1}
/>
);
}
export default ProgressControlled;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: progress-rtl
```tsx
"use client";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@workspace/ui/components/progress";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
label: "Upload progress",
},
},
ar: {
dir: "rtl",
values: {
label: "تقدم الرفع",
},
},
he: {
dir: "rtl",
values: {
label: "התקדמות העלאה",
},
},
};
function toArabicNumerals(num: number): string {
const arabicNumerals = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"];
return num
.toString()
.split("")
.map((digit) => arabicNumerals[parseInt(digit, 10)])
.join("");
}
export function ProgressRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
const formatNumber = (num: number): string => {
if (language === "ar") {
return toArabicNumerals(num);
}
return num.toString();
};
return (
{t.label}
{(value) => (
{formatNumber(parseFloat(value ?? "0"))}%
)}
);
}
export default ProgressRtl;
```
## API Reference
See the [Base UI Progress](https://base-ui.com/react/components/progress#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/progress)
- [API reference](https://base-ui.com/react/components/progress#api-reference)
---
# QRCode
A theme-aware QR code with loading feedback and optional reveal animation.
Page: https://sui.draco.dev/docs/components/qr-code
### Example: qr-code-demo
```tsx
"use client";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { QRCode } from "@workspace/ui/components/qr-code";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const [value, setValue] = useState("https://example.com/");
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/qr-code
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { QRCode } from "@workspace/ui/components/qr-code";
;
```
## Loading and empty states
Generation loads on demand in the client. The server renders a stable loading placeholder. Changing `value` generates a fresh code and discards the previous request result. `loading` keeps the placeholder visible even if generation has completed. An absent or empty value keeps the loading placeholder rather than generating an invalid code.
By default, the code uses the neutral `--foreground` color on a solid `--card` surface. It follows light and dark modes without inheriting the accent color. Theme changes recolor the existing code without generating it again. The basic code appears as soon as its image loads. Loading and empty states use independently pulsing dots with a roughly 800ms blur entrance and no visible text. Generation failures show an error icon. The loading dots, error icon, and logo surface follow the same neutral theme. The logo appears only when the code is ready.
### Example: qr-code-states
```tsx
"use client";
import { QRCode } from "@workspace/ui/components/qr-code";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
{chinese ? "基础二维码" : "Basic QR code"}
{chinese ? "加载中" : "Loading"}
{chinese ? "尚无内容" : "No content"}
);
}
```
## Optional animation
Enable `animated` for a roughly 750ms reveal. Both modes use the dot-matrix skeleton while loading. Reduced motion freezes the dots and skips the reveal. The basic version displays the result directly.
### Example: qr-code-animated
```tsx
"use client";
import { QRCode } from "@workspace/ui/components/qr-code";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
);
}
```
## Margin and finder patterns
`margin` controls the quiet space around the code in CSS pixels at the requested `size`, defaulting to `16`. Set it to `0` to supply your own solid-color quiet zone. It scales with the code in smaller containers. Negative values become zero, excessive values are limited to `size / 2 - 4`, and non-finite values use the default.
Finder patterns use concentric rounded outer rings and rounded solid centers. Their outer edge, inner edge, and center have corner radii of `2.5`, `1.5`, and `0.5` modules, preserving the same corner centers as each edge moves inward. Insufficient quiet space, small sizes, and busy backgrounds reduce scan reliability. Verify scanning at the actual display size.
### Example: qr-code-margin
```tsx
"use client";
import { QRCode } from "@workspace/ui/components/qr-code";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
{[0, 16, 24].map((margin) => (
margin={margin}
))}
);
}
```
## Logo and scan reliability
`logo` places a small React node over the center. The generator uses high error correction and retains the surrounding quiet zone. Keep the logo small, preserve the quiet zone, and verify scanning at the size and background where the code will be used. A logo can still reduce scan reliability even with error correction.
`QRCode` renders only the code. Compose your own layout or caption when needed. The code stays sharp while its neutral foreground and default solid background follow the theme. Test both light and dark modes with the scanners your application supports.
### Example: qr-code-logo
```tsx
"use client";
import { QRCode } from "@workspace/ui/components/qr-code";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
S
}
/>
);
}
```
## Glass mode
Enable `glass` to use the shared glass material across the entire background, and configure its material and rendering mode with `GlassProvider`. The code fills the surface without a separate rim or solid inner panel. Its `--foreground` modules remain sharp SVG content over the transparent code layer; the small logo surface remains solid. Switching the theme or glass material preserves the encoded content. `glassMaterial` overrides the provider material for this instance.
The background behind the glass changes the visible contrast and quiet zone. Test scanning on the actual background, in both appearance modes and at the final display size. Glass does not guarantee the contrast of the default solid surface.
### Example: qr-code-glass
```tsx
"use client";
import { GlassProvider } from "@workspace/ui/components/glass";
import { QRCode } from "@workspace/ui/components/qr-code";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const defaultLabel = chinese ? "默认" : "Default";
return (
{[false, true].map((glass) => (
{glass ? "glass" : defaultLabel}
))}
);
}
```
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `value` | `string` | Content to encode; empty keeps placeholder. |
| `loading` | `boolean` | `false`. |
| `animated` | `boolean` | `false`; optional reveal animation. |
| `size` | `number` | `256`; requested width, bounded to 64–1024 pixels and constrained by the container. |
| `label` | `string` | `"QR code"`; accessible description. |
| `margin` | `number` | `16`; quiet space in CSS pixels at the requested size. |
| `glass` | `boolean` | Off by default; uses glass across the code background. |
| `glassMaterial` | `"clear" \| "frosted"` | Overrides the provider material. |
| `logo` | `ReactNode` | Optional center overlay. |
| `render`, `ref`, other props | Div / useRender props | Forwarded to the root. |
The module exports `QRCode`, `QRCodeProps`, and `qrCodeVariants`. Generation uses [qr-code-styling](https://github.com/kozakdenys/qr-code-styling).
---
# Questionnaire
A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions.
Page: https://sui.draco.dev/docs/components/questionnaire
### Example: questionnaire-demo
```tsx
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import type * as React from "react";
const questionnaireItems = [
{
choices: [
{
description: "Show what the agent ran and what came back.",
label: "Tool call timeline",
value: "tool-calls",
},
{
description: "Ask before sensitive or destructive actions.",
label: "Approval checkpoints",
value: "approvals",
},
{
description: "Make delegated work and results easier to follow.",
label: "Sub-agent handoffs",
value: "handoffs",
},
],
description: "Choose a direction or describe another task.",
input: {
label: "Another agent feature",
placeholder: "Describe another feature…",
},
name: "direction",
required: true,
title: "What should the agent build next?",
},
{
choices: [
{ label: "Progress", value: "progress" },
{ label: "Decisions", value: "decisions" },
{ label: "Risks", value: "risks" },
{ label: "Next step", value: "next-step" },
],
description: "Select all that apply, or skip this question.",
multiple: true,
name: "signals",
required: false,
title: "What should every progress update include?",
},
{
choices: [
{ label: "Start now", value: "now" },
{ label: "Next development cycle", value: "next-cycle" },
{ label: "Add it to the backlog", value: "backlog" },
],
description: "Choose when the agent should begin the work.",
name: "timing",
required: true,
title: "When should work begin?",
},
] as const;
export function QuestionnaireDemo() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const answers = {
direction: formData.get("direction"),
signals: formData.getAll("signals"),
timing: formData.get("timing"),
};
toast.add({
title: "Agent plan saved",
...{
description: `Direction: ${answers.direction ?? "None"} · Progress signals: ${answers.signals.join(", ") || "None"} · Timing: ${answers.timing ?? "None"}`,
},
});
}
return (
{questionnaireItems.map((question) => (
{question.title}
{question.description}
{question.choices.map((choice) => (
{choice.label}
{"description" in choice ? (
{choice.description}
) : null}
))}
{"input" in question ? (
) : null}
))}
Next
Save plan
);
}
export default QuestionnaireDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/questionnaire
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire"
```
```tsx
const items = [
{
name: "direction",
required: true,
prompt: "What should we prototype next?",
description: "Choose a direction or write your own.",
choices: [
{
value: "delegation",
label: "Delegation",
description: "Show how work moves to a specialist.",
},
{
value: "questions",
label: "Question prompts",
description: "Show choices while the interface waits.",
},
{ value: "both", label: "Both together" },
],
input: { label: "Another answer", placeholder: "Type another answer…" },
},
{
name: "detail",
required: false,
prompt: "How much detail should it include?",
description: "Skip this if you are not sure yet.",
choices: [
{ value: "focused", label: "Focused" },
{ value: "complete", label: "Complete flow" },
],
},
] as const
```
Define the collection once: pass it to `Questionnaire` for server-rendered
progress, actions, and shortcuts, then map it into the parts.
```tsx
{items.map((question) => (
{question.prompt}
{question.description}
{question.choices.map((choice) => (
{choice.label}
{"description" in choice ? (
{choice.description}
) : null}
))}
{"input" in question ? (
) : null}
))}
```
```tsx
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const answers = new FormData(event.currentTarget)
// answers.get("direction"), answers.getAll(...) for multiple items.
}
```
## Composition
```text
Questionnaire
├── QuestionnaireProgress
├── QuestionnaireItem
│ ├── QuestionnaireTitle
│ ├── QuestionnaireDescription
│ ├── QuestionnaireChoices
│ │ ├── QuestionnaireChoice
│ │ └── QuestionnaireInput
│ └── QuestionnaireError
└── QuestionnaireActions
├── QuestionnairePrevious
├── QuestionnaireSkip
├── QuestionnaireNext
└── QuestionnaireSubmit
```
Questionnaire owns the ordered items, active item, answer state, validation,
progress, and navigation. The containing page, card, dialog, or drawer owns
close and cancellation behavior, persistence, transport, and branching.
## Server Rendering
Pass `items` to server-render the active item, progress, actions, and answer
shortcuts. See the
[headless Questionnaire](https://ui.shadcn.com/docs/react/questionnaire) for the complete behavior.
## Multiple Selection
Use `multiple` for an item that accepts more than one fixed answer.
### Example: questionnaire-multiple
```tsx
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import type * as React from "react";
const items = [
{
choices: [
{ value: "source" },
{ value: "tests" },
{ value: "docs" },
{ value: "history" },
],
name: "context",
required: true,
},
] as const;
export function QuestionnaireMultiple() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const context = new FormData(event.currentTarget).getAll("context");
toast.add({
title: "Context selected",
...{
description: `Context: ${context.join(", ") || "None"}`,
},
});
}
return (
What context should the agent inspect?
Select every source that may affect the implementation.
Relevant source files
Existing tests
Architecture documentation
Recent commit history
Share context
);
}
export default QuestionnaireMultiple;
```
## Freeform Answer
Compose `QuestionnaireInput` with fixed choices when the user can provide another answer.
### Example: questionnaire-freeform
```tsx
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import type * as React from "react";
const items = [
{
choices: [
{ value: "incremental" },
{ value: "module" },
{ value: "rewrite" },
],
name: "approach",
required: true,
},
] as const;
export function QuestionnaireFreeform() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const approach = new FormData(event.currentTarget).get("approach");
toast.add({
title: "Approach selected",
...{
description: `Approach: ${approach ?? "None"}`,
},
});
}
return (
How should the agent approach this refactor?
Choose a strategy or write a more specific instruction.
Make the smallest safe change
Refactor one module at a time
Replace the implementation completely
Use this approach
);
}
export default QuestionnaireFreeform;
```
## Explicit Skip
Add `QuestionnaireSkip` when an optional item may be intentionally left unanswered.
### Example: questionnaire-skip
```tsx
"use client";
import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
const items = [
{ name: "task", required: true },
{ name: "constraints" },
{ name: "review", required: true },
] as const;
export function QuestionnaireSkipExample() {
const [constraintStatus, setConstraintStatus] =
React.useState("unanswered");
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const answers = {
task: formData.get("task"),
constraints: formData.get("constraints"),
constraintStatus,
review: formData.get("review"),
};
toast.add({
title: "Agent brief submitted",
...{
description: `Task: ${answers.task ?? "None"} · Constraints: ${
answers.constraintStatus === "skipped"
? "Skipped"
: (answers.constraints ?? "None")
} · Review: ${answers.review ?? "None"}`,
},
});
}
return (
What kind of change is this?
Choose the category that best describes the work.
New feature
Bug fix
Refactor
Are there any implementation constraints?
Answer if needed, or intentionally skip this question.
Do not add dependencies
Do not change the database
Preserve the public API
How should the work be reviewed?
Choose the checks the agent should complete before handoff.
Run the test suite
Review the final diff
Tests and diff review
Next
Submit brief
);
}
export default QuestionnaireSkipExample;
```
## Shortcuts
Assign a letter or number key to each answer with `shortcuts`.
### Example: questionnaire-shortcuts
```tsx
"use client";
import {
NativeSelect,
NativeSelectOption,
} from "@workspace/ui/components/native-select";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
const items = [
{
choices: [{ value: "inspect" }, { value: "tests" }, { value: "patch" }],
name: "action",
required: true,
},
] as const;
type ShortcutMode = React.ComponentProps["shortcuts"];
export function QuestionnaireShortcuts() {
const [shortcuts, setShortcuts] = React.useState("letters");
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const action = new FormData(event.currentTarget).get("action");
toast.add({
title: "Next action selected",
...{
description: `Action: ${action ?? "None"} · Shortcuts: ${shortcuts ?? "none"}`,
},
});
}
return (
{
const value = event.target.value;
setShortcuts(
value === "letters" || value === "numbers" ? value : undefined,
);
}}
>
No shortcuts
Letters
Numbers
What should the agent do next?
Use the displayed shortcut or navigate with the keyboard.
Inspect the implementation
Run the relevant tests
Prepare the patch
Confirm action
);
}
export default QuestionnaireShortcuts;
```
## Custom Validation
Combine controlled navigation with an external schema such as Zod to return to an invalid item and present its error.
### Example: questionnaire-validation
```tsx
"use client";
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
} from "@workspace/ui/components/card";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
import { z } from "zod";
const items = [
{ name: "detail", required: true },
{ name: "audience", required: true },
] as const;
const questionnaireSchema = z
.object({
detail: z.enum(["summary", "complete"]),
audience: z.enum(["team", "public"]),
})
.superRefine((answers, context) => {
if (answers.audience === "public" && answers.detail === "summary") {
context.addIssue({
code: z.ZodIssueCode.custom,
message:
"Public answers need enough context. Choose a complete answer.",
path: ["detail"],
});
}
});
type QuestionnaireItemName = keyof z.infer;
type QuestionnaireErrors = Partial>;
function ValidationProgress() {
return (
(
{state.current} / {state.total}
)}
/>
);
}
export function QuestionnaireValidation() {
const [item, setItem] = React.useState("detail");
const [errors, setErrors] = React.useState({});
function clearError(name: QuestionnaireItemName) {
setErrors((currentErrors) => {
if (!currentErrors[name]) {
return currentErrors;
}
const nextErrors = { ...currentErrors };
delete nextErrors[name];
return nextErrors;
});
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const result = questionnaireSchema.safeParse(
Object.fromEntries(new FormData(event.currentTarget)),
);
if (result.success) {
setErrors({});
toast.add({
title: "Agent response configured",
...{
description: `Detail: ${result.data.detail} · Audience: ${result.data.audience}`,
},
});
return;
}
const nextErrors: QuestionnaireErrors = {};
for (const issue of result.error.issues) {
const name = issue.path[0];
if ((name === "detail" || name === "audience") && !nextErrors[name]) {
nextErrors[name] = issue.message;
}
}
const firstInvalidItem = result.error.issues[0]?.path[0];
setErrors(nextErrors);
if (firstInvalidItem === "detail" || firstInvalidItem === "audience") {
setItem(firstInvalidItem);
}
}
return (
How much detail should the answer include?
Choose the response depth.
clearError("detail")}
>
Concise summary
clearError("detail")}
>
Complete answer
{errors.detail}
Who will read the answer?
Public answers require complete context.
clearError("audience")}
>
My team
clearError("audience")}
>
Public audience
{errors.audience}
Next
Validate answers
);
}
export default QuestionnaireValidation;
```
## Controlled
Control the active item from host state, such as returning to an invalid step.
### Example: questionnaire-controlled
```tsx
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
const items = [
{ name: "scope", required: true },
{ name: "checks", required: true },
{ name: "output", required: true },
] as const;
const itemLabels: Record = {
scope: "Change scope",
checks: "Verification",
output: "Final output",
};
export function QuestionnaireControlled() {
const [item, setItem] = React.useState("scope");
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
toast.add({
title: "Agent workflow configured",
...{
description: `Scope: ${formData.get("scope") ?? "None"} · Verification: ${formData.get("checks") ?? "None"} · Output: ${formData.get("output") ?? "None"}`,
},
});
}
return (
Current checkpoint: {itemLabels[item]}
What may the agent change?
The host stores the active checkpoint while Questionnaire navigates.
Only the target component
Component and related tests
The complete feature area
Which verification level should it use?
Targeted tests
Package tests and typecheck
Full workspace verification
What should the agent return when finished?
Concise summary
Summary with changed files
Detailed implementation handoff
Next
Save workflow
);
}
export default QuestionnaireControlled;
```
## Resume
Restore a saved active item and default answers, then reset changes back to that saved state.
### Example: questionnaire-resume
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import type * as React from "react";
const items = [
{ name: "change", required: true },
{ name: "verification", required: true },
{ name: "notes" },
] as const;
export function QuestionnaireResume() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const answers = {
change: formData.get("change"),
verification: formData.getAll("verification"),
notes: formData.get("notes"),
};
toast.add({
title: "Draft updated",
...{
description: `Migration: ${answers.change ?? "None"} · Verification: ${answers.verification.join(", ") || "None"} · Notes: ${answers.notes || "None"}`,
},
});
}
return (
toast.add({ title: "Saved answers restored" })}
onSubmit={handleSubmit}
>
What kind of migration is this?
This answer was saved during the previous session.
Incremental migration
Single cutover
How should the migration be verified?
These checks were selected during the previous session.
Run migration tests
Run the typecheck
Perform a manual smoke test
Anything else the agent should remember?
This note was saved with the draft.
Reset changes
Next
Update draft
);
}
export default QuestionnaireResume;
```
## Conditional Items
Disable items that do not apply to the user's earlier answers.
### Example: questionnaire-conditional
```tsx
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
export function QuestionnaireConditional() {
const [runtime, setRuntime] = React.useState("local");
const items = React.useMemo(
() => [
{ name: "runtime", required: true },
{
disabled: runtime !== "cloud",
name: "environment",
required: true,
},
{ name: "approval", required: true },
],
[runtime],
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
toast.add({
title: "Execution plan saved",
...{
description: `Runtime: ${formData.get("runtime") ?? "None"} · Environment: ${formData.get("environment") ?? "Not applicable"} · Approval: ${formData.get("approval") ?? "None"}`,
},
});
}
return (
Where should the agent run?
Cloud runs add an environment question to this flow.
setRuntime("local")}
>
Local workspace
setRuntime("cloud")}
>
Cloud workspace
Which cloud environment should it use?
Preview
Staging
Isolated sandbox
When should the agent request approval?
Before writing files
Before running commands
Only for sensitive actions
Next
Save execution plan
);
}
export default QuestionnaireConditional;
```
## Navigation State
Read item status to opt into disabled navigation and custom action styling.
### Example: questionnaire-navigation-state
```tsx
"use client";
import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
const items = [
{ name: "permission", required: true },
{ name: "verification", required: true },
] as const;
type ItemName = "permission" | "verification";
export function QuestionnaireNavigationState() {
const [item, setItem] = React.useState("permission");
const [statuses, setStatuses] = React.useState<
Record
>({
permission: "unanswered",
verification: "unanswered",
});
const unanswered = statuses[item] === "unanswered";
function setStatus(name: ItemName, status: QuestionnaireItemStatus) {
setStatuses((current) => ({ ...current, [name]: status }));
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
toast.add({
title: "Permissions saved",
...{
description: `Permission: ${formData.get("permission") ?? "None"} · Verification: ${formData.get("verification") ?? "None"}`,
},
});
}
return (
setItem(nextItem as ItemName)}
onSubmit={handleSubmit}
>
setStatus("permission", status)}
>
What may the agent modify?
Next is intentionally disabled until an answer is selected.
Project files
Project files and tests
Files, tests, and configuration
setStatus("verification", status)}
>
What must pass before completion?
Tests
Tests and types
Tests, types, and visual QA
Next
Save permissions
);
}
export default QuestionnaireNavigationState;
```
## Custom Progress
Use the Progress render state to build a custom progress indicator.
### Example: questionnaire-progress
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import type * as React from "react";
const items = [
{ name: "scope", required: true },
{ name: "strategy", required: true },
{ name: "tests", required: true },
{ name: "delivery", required: true },
] as const;
export function QuestionnaireProgressExample() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
toast.add({
title: "Pull request plan ready",
...{
description: `Scope: ${formData.get("scope") ?? "None"} · Commits: ${formData.get("strategy") ?? "None"} · Tests: ${formData.get("tests") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`,
},
});
}
return (
(
{Array.from({ length: state.total }, (_, index) => (
))}
Checkpoint {state.current} of {state.total}
)}
/>
How large is the change?
Small patch
Feature-sized change
Cross-package change
How should commits be organized?
Single commit
Logical commits
Squash before review
Which tests should run?
Targeted tests
Package suite
Full workspace
How should the work be delivered?
Patch only
Committed locally
Push a review branch
Next
Finish plan
);
}
export default QuestionnaireProgressExample;
```
## Animated Items
Animate the active item while keeping progress and navigation stationary.
### Example: questionnaire-animated
```tsx
"use client";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import type * as React from "react";
const items = [
{ name: "task", required: true },
{ name: "review", required: true },
{ name: "delivery", required: true },
] as const;
const itemClassName =
"data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:animate-none";
export function QuestionnaireAnimated() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
toast.add({
title: "Agent workflow saved",
...{
description: `Task: ${formData.get("task") ?? "None"} · Review: ${formData.get("review") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`,
},
});
}
return (
What should the agent do?
Choose the task for this run.
Implement the requested change
Debug the current behavior
Review the implementation
How should the work be reviewed?
Select the verification depth.
Targeted checks
Complete test suite
Tests and manual QA
How should the result be delivered?
Choose the final handoff format.
Concise summary
Summary and changed files
Detailed review handoff
Next
Save workflow
);
}
export default QuestionnaireAnimated;
```
## Card
Compose Questionnaire with Card slots while keeping the question title and description semantic.
### Example: questionnaire-card
```tsx
"use client";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
const items = [
{
choices: [{ value: "fix" }, { value: "refactor" }, { value: "docs" }],
name: "task",
required: true,
},
{
choices: [{ value: "summary" }, { value: "files" }, { value: "review" }],
name: "output",
required: true,
},
] as const;
export function QuestionnaireCard() {
const taskTitleId = React.useId();
const outputTitleId = React.useId();
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
toast.add({
title: "Agent task created",
...{
description: `Task: ${formData.get("task") ?? "None"} · Handoff: ${formData.get("output") ?? "None"}`,
},
});
}
return (
}>
What should the agent work on?
}>
Choose the task that should be handled next.
Fix the failing tests
Refactor the data layer
Update the integration guide
}>
What should the final handoff include?
}>
Pick the level of detail needed for review.
Summary only
Summary and changed files
Full review handoff
Next
Create task
);
}
export default QuestionnaireCard;
```
## Dialog
Compose Questionnaire inside a Dialog while keeping cancellation and dismissal host-owned.
### Example: questionnaire-dialog
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@workspace/ui/components/questionnaire";
import { toast } from "@workspace/ui/components/toast";
import * as React from "react";
const items = [
{ name: "scope", required: true },
{ name: "tests", required: true },
] as const;
export function QuestionnaireDialog() {
const [open, setOpen] = React.useState(false);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setOpen(false);
toast.add({
title: "Clarification sent",
...{
description: `Scope: ${formData.get("scope") ?? "None"} · Verification: ${formData.get("tests") ?? "None"}`,
},
});
}
return (
}>
Open clarification
}>
Which files are in scope?
}>
Choose how broadly the agent can update the workspace.
Component only
Complete feature directory
Any related workspace file
}>
How much verification is needed?
}>
Choose the checks the agent should run before handoff.
Targeted tests
Package tests
Full workspace verification
}>
Cancel
Next
Send answer
);
}
export default QuestionnaireDialog;
```
## Accessibility
`QuestionnaireItem` renders a `fieldset`, and `QuestionnaireTitle` renders its
`legend`. Descriptions and active errors are associated with the current item,
and invalid items and answer controls expose `aria-invalid`.
Fixed choices preserve native radio and checkbox behavior. Progress is exposed
as a named progressbar, navigation uses real buttons, and inactive items and
actions are hidden and inert. Successful navigation focuses the newly active
item; failed validation focuses an available answer control.
Always give `QuestionnaireInput` an accessible name with a visible label,
`aria-label`, or `aria-labelledby`. A placeholder is not a label. See the
[Questionnaire accessibility guide](https://ui.shadcn.com/docs/react/questionnaire#accessibility)
for labeling custom compositions and the complete keyboard behavior.
## Unstyled
The behavior in `Questionnaire` comes from the `@shadcn/react` package. To use
it directly with your own markup and styles, see
[Questionnaire](https://ui.shadcn.com/docs/react/questionnaire) under @shadcn/react.
## API Reference
The props, data attributes, and render states for every part are documented on
the [@shadcn/react Questionnaire](https://ui.shadcn.com/docs/react/questionnaire#api-reference) page.
The styled components inherit the corresponding unstyled props. Navigation
components also accept Button `size` and `variant` props, and
`QuestionnaireActions` is a styled-only layout helper.
---
# Radio Group
A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
Page: https://sui.draco.dev/docs/components/radio-group
### Example: radio-group-demo
```tsx
import { Label } from "@workspace/ui/components/label";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function RadioGroupDemo() {
const previewId = usePreviewId();
return (
Default
Comfortable
Compact
);
}
export default RadioGroupDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/radio-group
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Label } from "@workspace/ui/components/label"
import { RadioGroup, RadioGroupItem } from "@workspace/ui/components/radio-group"
```
```tsx showLineNumbers
Option One
Option Two
```
## Composition
Use the following composition to build a `RadioGroup`:
```text
RadioGroup
├── RadioGroupItem
└── RadioGroupItem
```
## Description
Radio group items with a description using the `Field` component.
### Example: radio-group-description
```tsx
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function RadioGroupDescription() {
const previewId = usePreviewId();
return (
Default
Standard spacing for most use cases.
Comfortable
More space between elements.
Compact
Minimal spacing for dense layouts.
);
}
export default RadioGroupDescription;
```
## Choice Card
Use `FieldLabel` to wrap the entire `Field` for a clickable card-style selection.
### Example: radio-group-choice-card
```tsx
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function RadioGroupChoiceCard() {
const previewId = usePreviewId();
return (
Plus
For individuals and small teams.
Pro
For growing businesses.
Enterprise
For large teams and enterprises.
);
}
export default RadioGroupChoiceCard;
```
## Fieldset
Use `FieldSet` and `FieldLegend` to group radio items with a label and description.
### Example: radio-group-fieldset
```tsx
import {
Field,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function RadioGroupFieldset() {
const previewId = usePreviewId();
return (
Subscription Plan
Yearly and lifetime plans offer significant savings.
Monthly ($9.99/month)
Yearly ($99.99/year)
Lifetime ($299.99)
);
}
export default RadioGroupFieldset;
```
## Disabled
Use the `disabled` prop on `RadioGroup` to disable all items.
### Example: radio-group-disabled
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function RadioGroupDisabled() {
const previewId = usePreviewId();
return (
Disabled
Option 2
Option 3
);
}
export default RadioGroupDisabled;
```
## Invalid
Use `aria-invalid` on `RadioGroupItem` and `data-invalid` on `Field` to show validation errors.
### Example: radio-group-invalid
```tsx
import {
Field,
FieldDescription,
FieldLabel,
FieldLegend,
FieldSet,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
export function RadioGroupInvalid() {
const previewId = usePreviewId();
return (
Notification Preferences
Choose how you want to receive notifications.
Email only
SMS only
Both Email & SMS
);
}
export default RadioGroupInvalid;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: radio-group-rtl
```tsx
"use client";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
default: "Default",
defaultDescription: "Standard spacing for most use cases.",
comfortable: "Comfortable",
comfortableDescription: "More space between elements.",
compact: "Compact",
compactDescription: "Minimal spacing for dense layouts.",
},
},
ar: {
dir: "rtl",
values: {
default: "افتراضي",
defaultDescription: "تباعد قياسي لمعظم حالات الاستخدام.",
comfortable: "مريح",
comfortableDescription: "مساحة أكبر بين العناصر.",
compact: "مضغوط",
compactDescription: "تباعد أدنى للتخطيطات الكثيفة.",
},
},
he: {
dir: "rtl",
values: {
default: "ברירת מחדל",
defaultDescription: "ריווח סטנדרטי לרוב מקרי השימוש.",
comfortable: "נוח",
comfortableDescription: "יותר מקום בין האלמנטים.",
compact: "קומפקטי",
compactDescription: "ריווח מינימלי לפריסות צפופות.",
},
},
};
export function RadioGroupRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.default}
{t.defaultDescription}
{t.comfortable}
{t.comfortableDescription}
{t.compact}
{t.compactDescription}
);
}
export default RadioGroupRtl;
```
## API Reference
See the [Base UI Radio Group](https://base-ui.com/react/components/radio-group#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/radio-group)
- [API reference](https://base-ui.com/react/components/radio-group#api-reference)
---
# Resizable
Accessible resizable panel groups and layouts with keyboard support.
Page: https://sui.draco.dev/docs/components/resizable
### Example: resizable-demo
```tsx
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@workspace/ui/components/resizable";
export default function ResizableDemo() {
return (
One
Two
Three
);
}
```
## About
The `Resizable` component is built on top of [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) by [bvaughn](https://github.com/bvaughn).
## Installation
```bash
bunx --bun shadcn@latest add @sui/resizable
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@workspace/ui/components/resizable"
```
```tsx showLineNumbers
One
Two
```
## Composition
Use the following composition to build a `ResizablePanelGroup`:
```text
ResizablePanelGroup
├── ResizablePanel
├── ResizableHandle
└── ResizablePanel
```
## Vertical
Use `orientation="vertical"` for vertical resizing.
### Example: resizable-vertical
```tsx
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@workspace/ui/components/resizable";
export function ResizableVertical() {
return (
Header
Content
);
}
export default ResizableVertical;
```
## Handle
Use the `withHandle` prop on `ResizableHandle` to show a visible handle.
### Example: resizable-handle
```tsx
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@workspace/ui/components/resizable";
export default function ResizableHandleDemo() {
return (
Sidebar
Content
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: resizable-rtl
```tsx
"use client";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@workspace/ui/components/resizable";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
one: "One",
two: "Two",
three: "Three",
},
},
ar: {
dir: "rtl",
values: {
one: "واحد",
two: "اثنان",
three: "ثلاثة",
},
},
he: {
dir: "rtl",
values: {
one: "אחד",
two: "שניים",
three: "שלושה",
},
},
};
export function ResizableRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.one}
{t.two}
{t.three}
);
}
export default ResizableRtl;
```
## API Reference
See the [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels/tree/main/packages/react-resizable-panels) documentation.
## Changelog
### 2025-02-02 `react-resizable-panels` v4
Updated to `react-resizable-panels` v4. See the [v4.0.0 release notes](https://github.com/bvaughn/react-resizable-panels/releases/tag/4.0.0) for full details.
If you're using `react-resizable-panels` primitives directly, note the following changes:
| v3 | v4 |
| ---------------------------- | ----------------------- |
| `PanelGroup` | `Group` |
| `PanelResizeHandle` | `Separator` |
| `direction` prop | `orientation` prop |
| `defaultSize={50}` | `defaultSize="50%"` |
| `onLayout` | `onLayoutChange` |
| `ImperativePanelHandle` | `PanelImperativeHandle` |
| `ref` prop on Panel | `panelRef` prop |
| `data-panel-group-direction` | `aria-orientation` |
The shadcn/ui wrapper components (`ResizablePanelGroup`, `ResizablePanel`,
`ResizableHandle`) remain unchanged.
- [Documentation](https://github.com/bvaughn/react-resizable-panels)
- [API reference](https://github.com/bvaughn/react-resizable-panels/tree/main/packages/react-resizable-panels)
---
# Scroll Area
Augments native scroll functionality for custom, cross-browser styling.
Page: https://sui.draco.dev/docs/components/scroll-area
### Example: scroll-area-demo
```tsx
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { Separator } from "@workspace/ui/components/separator";
import * as React from "react";
const tags = Array.from({ length: 50 }).map(
(_, i, a) => `v1.2.0-beta.${a.length - i}`,
);
export function ScrollAreaDemo() {
return (
Tags
{tags.map((tag) => (
{tag}
))}
);
}
export default ScrollAreaDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/scroll-area
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { ScrollArea, ScrollBar } from "@workspace/ui/components/scroll-area"
```
```tsx showLineNumbers
Your scrollable content here.
```
## Composition
Use the following composition to build a `ScrollArea`:
```text
ScrollArea
└── ScrollBar
```
## Horizontal
Use `ScrollBar` with `orientation="horizontal"` for horizontal scrolling.
### Example: scroll-area-horizontal-demo
```tsx
import { ScrollArea, ScrollBar } from "@workspace/ui/components/scroll-area";
export interface Artwork {
artist: string;
art: string;
}
export const works: Artwork[] = [
{
artist: "Ornella Binni",
art: "https://images.unsplash.com/photo-1465869185982-5a1a7522cbcb?auto=format&fit=crop&w=300&q=80",
},
{
artist: "Tom Byrom",
art: "https://images.unsplash.com/photo-1548516173-3cabfa4607e9?auto=format&fit=crop&w=300&q=80",
},
{
artist: "Vladimir Malyavko",
art: "https://images.unsplash.com/photo-1494337480532-3725c85fd2ab?auto=format&fit=crop&w=300&q=80",
},
];
export function ScrollAreaHorizontalDemo() {
return (
{works.map((artwork) => (
Photo by{" "}
{artwork.artist}
))}
);
}
export default ScrollAreaHorizontalDemo;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: scroll-area-rtl
```tsx
"use client";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { Separator } from "@workspace/ui/components/separator";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const tags = Array.from({ length: 50 }).map(
(_, i, a) => `v1.2.0-beta.${a.length - i}`,
);
const translations: Translations = {
en: {
dir: "ltr",
values: {
tags: "Tags",
},
},
ar: {
dir: "rtl",
values: {
tags: "العلامات",
},
},
he: {
dir: "rtl",
values: {
tags: "תגיות",
},
},
};
export function ScrollAreaRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.tags}
{tags.map((tag) => (
{tag}
))}
);
}
export default ScrollAreaRtl;
```
## API Reference
See the [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/scroll-area)
- [API reference](https://base-ui.com/react/components/scroll-area#api-reference)
---
# Select
Displays a list of options for the user to pick from—triggered by a button.
Page: https://sui.draco.dev/docs/components/select
### Example: select-demo
```tsx
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
{ label: "Grapes", value: "grapes" },
{ label: "Pineapple", value: "pineapple" },
];
export function SelectDemo() {
return (
Fruits
{items.map((item) => (
{item.label}
))}
);
}
export default SelectDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/select
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select"
```
```tsx showLineNumbers
const items = [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
{ label: "System", value: "system" },
]
{items.map((item) => (
{item.label}
))}
```
## Composition
Use the following composition to build a `Select`:
```text
Select
├── SelectTrigger
│ └── SelectValue
└── SelectContent
├── SelectGroup
│ ├── SelectLabel
│ ├── SelectItem
│ └── SelectItem
├── SelectSeparator
└── SelectGroup
├── SelectLabel
├── SelectItem
└── SelectItem
```
## Align Item With Trigger
Use `alignItemWithTrigger` on `SelectContent` to control whether the selected item aligns with the trigger. When `true` (default), the popup positions so the selected item appears over the trigger. When `false`, the popup aligns to the trigger edge.
### Example: select-align-item
```tsx
"use client";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@workspace/ui/components/field";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Switch } from "@workspace/ui/components/switch";
import * as React from "react";
import { useId as usePreviewId } from "react";
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
{ label: "Grapes", value: "grapes" },
{ label: "Pineapple", value: "pineapple" },
];
export function SelectAlignItem() {
const previewId = usePreviewId();
const [alignItemWithTrigger, setAlignItemWithTrigger] = React.useState(true);
return (
Align Item
Toggle to align the item with the trigger.
{items.map((item) => (
{item.label}
))}
);
}
export default SelectAlignItem;
```
## Groups
Use `SelectGroup`, `SelectLabel`, and `SelectSeparator` to organize items.
### Example: select-groups
```tsx
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
export function SelectGroups() {
const fruits = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
];
const vegetables = [
{ label: "Carrot", value: "carrot" },
{ label: "Broccoli", value: "broccoli" },
{ label: "Spinach", value: "spinach" },
];
const allItems = [
{ label: "Select a fruit", value: null },
...fruits,
...vegetables,
];
return (
Fruits
{fruits.map((item) => (
{item.label}
))}
Vegetables
{vegetables.map((item) => (
{item.label}
))}
);
}
export default SelectGroups;
```
## Scrollable
A select with many items that scrolls.
### Example: select-scrollable
```tsx
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
const northAmerica = [
{ label: "Eastern Standard Time", value: "est" },
{ label: "Central Standard Time", value: "cst" },
{ label: "Mountain Standard Time", value: "mst" },
{ label: "Pacific Standard Time", value: "pst" },
{ label: "Alaska Standard Time", value: "akst" },
{ label: "Hawaii Standard Time", value: "hst" },
];
const europeAfrica = [
{ label: "Greenwich Mean Time", value: "gmt" },
{ label: "Central European Time", value: "cet" },
{ label: "Eastern European Time", value: "eet" },
{ label: "Western European Summer Time", value: "west" },
{ label: "Central Africa Time", value: "cat" },
{ label: "East Africa Time", value: "eat" },
];
const asia = [
{ label: "Moscow Time", value: "msk" },
{ label: "India Standard Time", value: "ist" },
{ label: "China Standard Time", value: "cst_china" },
{ label: "Japan Standard Time", value: "jst" },
{ label: "Korea Standard Time", value: "kst" },
{ label: "Indonesia Central Standard Time", value: "ist_indonesia" },
];
const australiaPacific = [
{ label: "Australian Western Standard Time", value: "awst" },
{ label: "Australian Central Standard Time", value: "acst" },
{ label: "Australian Eastern Standard Time", value: "aest" },
{ label: "New Zealand Standard Time", value: "nzst" },
{ label: "Fiji Time", value: "fjt" },
];
const southAmerica = [
{ label: "Argentina Time", value: "art" },
{ label: "Bolivia Time", value: "bot" },
{ label: "Brasilia Time", value: "brt" },
{ label: "Chile Standard Time", value: "clt" },
];
const items = [
{ label: "Select a timezone", value: null },
...northAmerica,
...europeAfrica,
...asia,
...australiaPacific,
...southAmerica,
];
export function SelectScrollable() {
return (
North America
{northAmerica.map((item) => (
{item.label}
))}
Europe & Africa
{europeAfrica.map((item) => (
{item.label}
))}
Asia
{asia.map((item) => (
{item.label}
))}
Australia & Pacific
{australiaPacific.map((item) => (
{item.label}
))}
South America
{southAmerica.map((item) => (
{item.label}
))}
);
}
export default SelectScrollable;
```
## Disabled
### Example: select-disabled
```tsx
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
export function SelectDisabled() {
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
{ label: "Grapes", value: "grapes", disabled: true },
{ label: "Pineapple", value: "pineapple" },
];
return (
{items.map((item) => (
{item.label}
))}
);
}
export default SelectDisabled;
```
## Invalid
Add the `data-invalid` attribute to the `Field` component and the `aria-invalid` attribute to the `SelectTrigger` component to show an error state.
```tsx showLineNumbers /data-invalid/ /aria-invalid/
Fruit
```
### Example: select-invalid
```tsx
import { Field, FieldError, FieldLabel } from "@workspace/ui/components/field";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
];
export function SelectInvalid() {
return (
Fruit
{items.map((item) => (
{item.label}
))}
Please select a fruit.
);
}
export default SelectInvalid;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: select-rtl
```tsx
"use client";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import * as React from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
selectFruit: "Select a fruit",
fruits: "Fruits",
apple: "Apple",
banana: "Banana",
blueberry: "Blueberry",
grapes: "Grapes",
pineapple: "Pineapple",
vegetables: "Vegetables",
carrot: "Carrot",
broccoli: "Broccoli",
spinach: "Spinach",
},
},
ar: {
dir: "rtl",
values: {
selectFruit: "اختر فاكهة",
fruits: "الفواكه",
apple: "تفاح",
banana: "موز",
blueberry: "توت أزرق",
grapes: "عنب",
pineapple: "أناناس",
vegetables: "الخضروات",
carrot: "جزر",
broccoli: "بروكلي",
spinach: "سبانخ",
},
},
he: {
dir: "rtl",
values: {
selectFruit: "בחר פרי",
fruits: "פירות",
apple: "תפוח",
banana: "בננה",
blueberry: "אוכמניה",
grapes: "ענבים",
pineapple: "אננס",
vegetables: "ירקות",
carrot: "גזר",
broccoli: "ברוקולי",
spinach: "תרד",
},
},
};
export function SelectRtl() {
const { dir, t, language } = useTranslation(translations, "ar");
const [selectedFruit, setSelectedFruit] = React.useState(null);
const fruits = [
{ label: t.apple, value: "apple" },
{ label: t.banana, value: "banana" },
{ label: t.blueberry, value: "blueberry" },
{ label: t.grapes, value: "grapes" },
{ label: t.pineapple, value: "pineapple" },
];
const vegetables = [
{ label: t.carrot, value: "carrot" },
{ label: t.broccoli, value: "broccoli" },
{ label: t.spinach, value: "spinach" },
];
const allItems = [
{ label: t.selectFruit, value: null },
...fruits,
...vegetables,
];
return (
{t.fruits}
{fruits.map((item) => (
{item.label}
))}
{t.vegetables}
{vegetables.map((item) => (
{item.label}
))}
);
}
export default SelectRtl;
```
## API Reference
See the [Base UI Select](https://base-ui.com/react/components/select#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/select)
- [API reference](https://base-ui.com/react/components/select#api-reference)
---
# Sensitive Input
A sensitive-value input with a fixed mask, click-to-reveal interaction, and copy feedback.
Page: https://sui.draco.dev/docs/components/sensitive-input
### Example: sensitive-input-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
const defaultPassword = "a-private-example";
export default function SensitiveInputDemo({ locale }: ExampleProps) {
const id = useId();
const inputRef = useRef(null);
const [copyResult, setCopyResult] = useState<
"matched" | "mismatched" | "failed" | null
>(null);
const [matchesDefault, setMatchesDefault] = useState(null);
const [submitted, setSubmitted] = useState(null);
const [submissions, setSubmissions] = useState(0);
const chinese = locale === "zh-CN";
const stateLabels = chinese
? {
idle: "尚未复制",
failed: "复制失败,请重试",
matched: "复制值与当前输入匹配",
mismatched: "复制值与当前输入不匹配",
defaultMatched: "复制值与初始默认值匹配",
defaultMismatched: "复制值与初始默认值不匹配",
submittedMatched: ";提交值与当前输入匹配",
submittedMismatched: ";提交值与当前输入不匹配",
}
: {
idle: "Not copied yet",
failed: "Copy failed. Try again.",
matched: "Copied value matches the current input.",
mismatched: "Copied value does not match the current input.",
defaultMatched: "Copied value matches the original default.",
defaultMismatched: "Copied value does not match the original default.",
submittedMatched: "; submitted value matches the current input.",
submittedMismatched:
"; submitted value does not match the current input.",
};
return (
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/sensitive-input
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
;
```
A nonempty hidden value is covered by a fixed `••••••••` mask, regardless of its length. The native input stays mounted underneath, but editing begins after revealing the value. Hovering or focusing the control shows “Click to reveal” without changing its layout.
Click the mask, or focus its reveal button and press Enter or Space, to reveal the actual input and focus it. The revealed input uses `type="text"`; its eye button hides the value again. Escape returns to the mask and focuses the reveal button. All reveal, hide, and copy actions are `type="button"` controls and do not submit their containing form.
An empty value has no mask and can be edited directly. The first input requests revealing the value. When focus leaves the entire group, a nonempty value is masked again. Moving focus between the input and its internal copy or eye buttons keeps the current state.
## Copying the current value
`copyable` defaults to `true`. The built-in button reads the native input's actual `value` when clicked, including uncontrolled edits and the value restored by a form reset. It does not copy a stale initial value. Copying works while the value is hidden or read-only and does not change visibility.
The copy control is a text tab above the top-right edge, shown on hover or focus within the group. Its width is reserved for pending, copied, and failed feedback so state changes do not shift the layout. The copy button remains available for empty values and can copy an empty string.
The button reports a pending state while the clipboard write is in progress and prevents repeated attempts. It only reports success after the write completes. Successful feedback resets after `resetDelay` milliseconds, with a default of `1500`. A failed attempt provides generic feedback and can be retried; feedback never includes the sensitive value.
`onCopySuccess` receives the successfully written value. `onCopyError` receives an `Error` when writing fails. Use the callbacks for state updates without rendering or logging the sensitive value. For example, compare against the current input through its native ref and display only whether the values match:
```tsx
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useRef, useState } from "react";
export function CopyVerification() {
const inputRef = useRef(null);
const [matches, setMatches] = useState(null);
const [failed, setFailed] = useState(false);
return (
<>
{
setFailed(false);
setMatches(value === inputRef.current?.value);
}}
onCopyError={() => {
setFailed(true);
setMatches(null);
}}
/>
{failed && "Copy failed. Try again."}
{!failed && matches === null && "Not copied yet"}
{!failed && matches !== null && (
matches
? "Copied value matches the current input."
: "Copied value does not match the current input."
)}
>
);
}
```
The input's native `onCopy` remains a clipboard event handler for selection-based copying. The built-in button uses `onCopySuccess` for its asynchronous result instead.
## Controlled state
Control the input value with native `value` and `onChange`. Visibility is independent: use `visible` and `onVisibleChange`, or set `defaultVisible` for an uncontrolled input.
Controlled `visible` remains authoritative. Clicks, keyboard actions, first input into an empty field, and leaving the group request changes through `onVisibleChange`; update `visible` in that callback to accept them. Copying always reads the current native value and never requests a visibility change.
This example verifies the copied value through `ref`, exposes the native `onCopy` event count, and keeps all sensitive content out of its feedback.
### Example: sensitive-input-controlled
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function SensitiveInputControlled({ locale }: ExampleProps) {
const id = useId();
const inputRef = useRef(null);
const [value, setValue] = useState("example-api-key");
const [visible, setVisible] = useState(false);
const [copyMatchesInput, setCopyMatchesInput] = useState(
null,
);
const [copyFailed, setCopyFailed] = useState(false);
const [nativeCopyEvents, setNativeCopyEvents] = useState(0);
const chinese = locale === "zh-CN";
const stateLabels = chinese
? {
idle: "尚未复制",
failed: "复制失败,请重试",
matched: "复制值与当前输入匹配",
mismatched: "复制值与当前输入不匹配",
}
: {
idle: "Not copied yet",
failed: "Copy failed. Try again.",
matched: "Copied value matches the current input.",
mismatched: "Copied value does not match the current input.",
};
let copyFeedback = stateLabels.idle;
if (copyFailed) copyFeedback = stateLabels.failed;
else if (copyMatchesInput !== null)
copyFeedback = copyMatchesInput
? stateLabels.matched
: stateLabels.mismatched;
return (
{chinese ? "API 密钥" : "API key"}
{
setValue(event.target.value);
setCopyMatchesInput(null);
setCopyFailed(false);
}}
visible={visible}
onVisibleChange={setVisible}
onCopy={() => setNativeCopyEvents((count) => count + 1)}
onCopySuccess={(copiedValue) => {
setCopyFailed(false);
setCopyMatchesInput(copiedValue === inputRef.current?.value);
}}
onCopyError={() => {
setCopyFailed(true);
setCopyMatchesInput(null);
}}
resetDelay={1500}
autoComplete="off"
aria-describedby={`${id}-description`}
labels={
chinese
? {
reveal: "点击显示",
instruction: "点击或按 Enter 显示内容",
hidden: "内容已隐藏",
show: "显示密钥",
hide: "隐藏密钥",
copy: "复制密钥",
pending: "正在复制密钥…",
copied: "已复制密钥",
failed: "复制失败,请重试",
}
: undefined
}
/>
{chinese
? "点击遮罩或按 Enter 显示。Escape 或离开输入组会请求隐藏;受控 visible 决定最终状态。选中显示的文字后复制,可观察原生 onCopy 事件"
: "Click the mask or press Enter to reveal. Escape or leaving the group requests hiding; controlled visible remains authoritative. Select revealed text to exercise native onCopy."}
setVisible(false)}>
{chinese ? "从外部隐藏" : "Hide from outside"}
{copyFeedback}
{chinese
? `原生复制事件:${nativeCopyEvents}`
: `Native copy events: ${nativeCopyEvents}`}
);
}
```
## Read-only and disabled
`readOnly` prevents editing while keeping pointer and keyboard revealing, hiding, and copying available. `disabled` prohibits input, revealing, hiding, and copying. Set `copyable={false}` to remove only the built-in copy button; revealing, editing, and native input events remain available.
The example also includes an empty field: type into it directly, then move focus outside the group to see its nonempty value become masked.
### Example: sensitive-input-states
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function SensitiveInputStates({ locale }: ExampleProps) {
const id = useId();
const readOnlyRef = useRef(null);
const [copyResult, setCopyResult] = useState<
"matched" | "mismatched" | "failed" | null
>(null);
const chinese = locale === "zh-CN";
const stateLabels = chinese
? {
idle: "尚未复制",
failed: "复制失败,请重试",
matched: "复制值与只读输入匹配",
mismatched: "复制值与只读输入不匹配",
}
: {
idle: "Not copied yet",
failed: "Copy failed. Try again.",
matched: "Copied value matches the read-only input.",
mismatched: "Copied value does not match the read-only input.",
};
const labels = chinese
? {
reveal: "点击显示",
instruction: "点击或按 Enter 显示内容",
hidden: "内容已隐藏",
show: "显示内容",
hide: "隐藏内容",
copy: "复制内容",
pending: "正在复制内容…",
copied: "已复制内容",
failed: "复制失败,请重试",
}
: undefined;
return (
{chinese ? "空值" : "Empty value"}
{chinese
? "空值没有遮罩,可直接编辑;首次输入后显示内容,离开控件后重新遮罩。空值也可复制"
: "An empty value can be edited directly. Typing reveals the value; leaving the group masks it again. Empty values can also be copied."}
{chinese ? "只读密钥" : "Read-only key"}
setCopyResult(
value === readOnlyRef.current?.value ? "matched" : "mismatched",
)
}
onCopyError={() => setCopyResult("failed")}
/>
{chinese
? "只读内容可以点击或用键盘揭示,也可以复制,但不能编辑"
: "Read-only values can be revealed by pointer or keyboard and copied, but cannot be edited."}
{stateLabels[copyResult ?? "idle"]}
{chinese ? "禁用" : "Disabled"}
{chinese
? "输入、显隐和复制操作均被禁用"
: "Input, visibility, and copy actions are disabled."}
{chinese ? "不提供复制按钮" : "Without a copy button"}
{chinese
? "copyable=false 隐藏内置复制按钮,保留编辑与显隐"
: "copyable=false hides the built-in copy action while retaining editing and visibility."}
);
}
```
## Composition and forms
Sensitive Input composes `InputGroup`, `InputGroupInput`, `InputGroupAddon`, and `InputGroupButton`. Use the normal `Field`, `FieldLabel`, and `FieldDescription` components around it; the visible label should reference the input's `id`.
`className` styles the group; `inputClassName` styles the input. A `ref` points to the native input, and native attributes such as `name`, `form`, `required`, `autoComplete`, and `aria-describedby` pass through to it.
The first example is an uncontrolled form with explicit Submit and Reset actions. Copying and toggling visibility leave its submission count unchanged. Submit verifies `FormData` against the current native input without displaying the value; Reset restores `defaultValue` and clears feedback. After editing and copying, reset and copy again: the separate boolean feedback verifies whether the copied value matches the original default. Neither feedback area displays the value. In controlled mode, reset the application-owned value in your form's `onReset` handler.
## Accessibility
Provide a visible associated label or an `aria-label` for the input. The mask has a native reveal button that can be reached with Tab and activated with Enter or Space. Instructions explain how to reveal the value, and a status message announces when it is hidden. The underlying masked input stays mounted for native refs and form data.
The revealed input accepts normal keyboard editing. Escape returns focus to the reveal button; leaving the group requests masking, while internal focus movement preserves the state. Visibility actions reference the input with `aria-controls`, and the eye icon is decorative. The copy button references the same input and reports its pending state with `aria-busy`; copy feedback is announced through a status region.
Localize the visible reveal hint with `labels.reveal`, its keyboard instructions with `labels.instruction`, and the hidden-state announcement with `labels.hidden`. Use `labels.show` and `labels.hide` for action names, and `labels.copy`, `labels.pending`, `labels.copied`, and `labels.failed` for copy feedback. Failure labels should explain a useful next step without including sensitive data.
## API Reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `visible` | `boolean` | — | Controlled visibility. |
| `defaultVisible` | `boolean` | `false` | Initial uncontrolled visibility. |
| `onVisibleChange` | `(visible: boolean) => void` | — | Called when pointer, keyboard, first input, or group blur requests a visibility change. |
| `copyable` | `boolean` | `true` | Include the built-in copy action. |
| `onCopySuccess` | `(value: string) => void` | — | Called after the current value is successfully written. |
| `onCopyError` | `(error: Error) => void` | — | Called when the clipboard write fails. |
| `resetDelay` | `number` | `1500` | Successful copy feedback duration in milliseconds. |
| `labels` | `{ reveal?: string; instruction?: string; hidden?: string; show?: string; hide?: string; copy?: string; pending?: string; copied?: string; failed?: string }` | English labels | Reveal hint, keyboard instructions, hidden announcement, action names, and copy feedback. |
| `className` | `string` | — | Additional group classes. |
| `inputClassName` | `string` | — | Additional input classes. |
| `...props` | Native input props, excluding `type` | — | Includes `value`, `defaultValue`, `onChange`, native `onCopy`, `ref`, `disabled`, and `readOnly`. |
The underlying input behavior follows the [Base UI Input API](https://base-ui.com/react/components/input#api-reference).
- [Documentation](https://base-ui.com/react/components/input)
- [API reference](https://base-ui.com/react/components/input#api-reference)
---
# Separator
Visually or semantically separates content.
Page: https://sui.draco.dev/docs/components/separator
### Example: separator-demo
```tsx
import { Separator } from "@workspace/ui/components/separator";
export default function SeparatorDemo() {
return (
shadcn/ui
The Foundation for your Design System
A set of beautifully designed components that you can customize, extend,
and build on.
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/separator
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Separator } from "@workspace/ui/components/separator"
```
```tsx showLineNumbers
```
## Vertical
Use `orientation="vertical"` for a vertical separator.
### Example: separator-vertical
```tsx
import { Separator } from "@workspace/ui/components/separator";
export function SeparatorVertical() {
return (
);
}
export default SeparatorVertical;
```
## Menu
Vertical separators between menu items with descriptions.
### Example: separator-menu
```tsx
import { Separator } from "@workspace/ui/components/separator";
export function SeparatorMenu() {
return (
Settings
Manage preferences
Account
Profile & security
Help
Support & docs
);
}
export default SeparatorMenu;
```
## List
Horizontal separators between list items.
### Example: separator-list
```tsx
import { Separator } from "@workspace/ui/components/separator";
export function SeparatorList() {
return (
Item 1
Value 1
Item 2
Value 2
Item 3
Value 3
);
}
export default SeparatorList;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: separator-rtl
```tsx
"use client";
import { Separator } from "@workspace/ui/components/separator";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
title: "shadcn/ui",
subtitle: "The Foundation for your Design System",
description:
"A set of beautifully designed components that you can customize, extend, and build on.",
},
},
ar: {
dir: "rtl",
values: {
title: "shadcn/ui",
subtitle: "الأساس لنظام التصميم الخاص بك",
description:
"مجموعة من المكونات المصممة بشكل جميل يمكنك تخصيصها وتوسيعها والبناء عليها.",
},
},
he: {
dir: "rtl",
values: {
title: "shadcn/ui",
subtitle: "הבסיס למערכת העיצוב שלך",
description:
"סט של רכיבים מעוצבים בצורה יפה שאתה יכול להתאים אישית, להרחיב ולבנות עליהם.",
},
},
};
export function SeparatorRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
);
}
export default SeparatorRtl;
```
## API Reference
See the [Base UI Separator](https://base-ui.com/react/components/separator#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/separator)
- [API reference](https://base-ui.com/react/components/separator#api-reference)
---
# Sheet
Extends the Dialog component to display content that complements the main content of the screen.
Page: https://sui.draco.dev/docs/components/sheet
### Example: sheet-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { useId as usePreviewId } from "react";
export default function SheetDemo() {
const previewId = usePreviewId();
return (
}>Open
Edit profile
Make changes to your profile here. Click save when you're done.
Save changes
}>Close
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/sheet
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet"
```
```tsx showLineNumbers
Open
Are you absolutely sure?
This action cannot be undone.
```
## Composition
Use the following composition to build a `Sheet`:
```text
Sheet
├── SheetTrigger
└── SheetContent
├── SheetHeader
│ ├── SheetTitle
│ └── SheetDescription
└── SheetFooter
```
## Side
Use the `side` prop on `SheetContent` to set the edge of the screen where the sheet appears. Values are `top`, `right`, `bottom`, or `left`.
### Example: sheet-side
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Button } from "@workspace/ui/components/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
const SHEET_SIDES = ["top", "right", "bottom", "left"] as const;
export default function SheetSide() {
return (
{SHEET_SIDES.map((side) => (
}
>
{side}
Edit profile
Make changes to your profile here. Click save when you're
done.
{Array.from({ length: 10 }).map((_, index) => (
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute
irure dolor in reprehenderit in voluptate velit esse cillum
dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
))}
Save changes
}>
Cancel
))}
);
}
```
## No Close Button
Use `showCloseButton={false}` on `SheetContent` to hide the close button.
### Example: sheet-no-close-button
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
export default function SheetNoCloseButton() {
return (
}>
Open Sheet
No Close Button
This sheet doesn't have a close button in the top-right corner.
Click outside to close.
);
}
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: sheet-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { Input } from "@workspace/ui/components/input";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
open: "Open",
editProfile: "Edit profile",
description:
"Make changes to your profile here. Click save when you're done.",
name: "Name",
username: "Username",
save: "Save changes",
close: "Close",
},
},
ar: {
dir: "rtl",
values: {
open: "فتح",
editProfile: "تعديل الملف الشخصي",
description:
"قم بإجراء تغييرات على ملفك الشخصي هنا. انقر حفظ عند الانتهاء.",
name: "الاسم",
username: "اسم المستخدم",
save: "حفظ التغييرات",
close: "إغلاق",
},
},
he: {
dir: "rtl",
values: {
open: "פתח",
editProfile: "עריכת פרופיל",
description: "בצע שינויים בפרופיל שלך כאן. לחץ שמור כשתסיים.",
name: "שם",
username: "שם משתמש",
save: "שמור שינויים",
close: "סגור",
},
},
};
export function SheetRtl() {
const previewId = usePreviewId();
const { dir, t, language } = useTranslation(translations, "ar");
return (
}>
{t.open}
{t.editProfile}
{t.description}
{t.name}
{t.username}
{t.save}
}>
{t.close}
);
}
export default SheetRtl;
```
## API Reference
See the [Base UI Dialog](https://base-ui.com/react/components/dialog#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/dialog)
- [API reference](https://base-ui.com/react/components/dialog#api-reference)
---
# Sidebar
A composable, themeable and customizable sidebar component.
Page: https://sui.draco.dev/docs/components/sidebar
### Example: sidebar-demo
```tsx
"use client";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@workspace/ui/components/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@workspace/ui/components/sidebar";
import {
AudioWaveform,
BadgeCheck,
Bell,
BookOpen,
Bot,
ChevronRight,
ChevronsUpDown,
Command,
CreditCard,
Folder,
Forward,
Frame,
GalleryVerticalEnd,
LogOut,
Map as MapIcon,
MoreHorizontal,
PieChart,
Plus,
Settings2,
Sparkles,
SquareTerminal,
Trash2,
} from "lucide-react";
import * as React from "react";
// This is sample data.
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
teams: [
{
name: "Acme Inc",
logo: GalleryVerticalEnd,
plan: "Enterprise",
},
{
name: "Acme Corp.",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
plan: "Free",
},
],
navMain: [
{
title: "Playground",
url: "#",
icon: SquareTerminal,
isActive: true,
items: [
{
title: "History",
url: "#",
},
{
title: "Starred",
url: "#",
},
{
title: "Settings",
url: "#",
},
],
},
{
title: "Models",
url: "#",
icon: Bot,
items: [
{
title: "Genesis",
url: "#",
},
{
title: "Explorer",
url: "#",
},
{
title: "Quantum",
url: "#",
},
],
},
{
title: "Documentation",
url: "#",
icon: BookOpen,
items: [
{
title: "Introduction",
url: "#",
},
{
title: "Get Started",
url: "#",
},
{
title: "Tutorials",
url: "#",
},
{
title: "Changelog",
url: "#",
},
],
},
{
title: "Settings",
url: "#",
icon: Settings2,
items: [
{
title: "General",
url: "#",
},
{
title: "Team",
url: "#",
},
{
title: "Billing",
url: "#",
},
{
title: "Limits",
url: "#",
},
],
},
],
projects: [
{
name: "Design Engineering",
url: "#",
icon: Frame,
},
{
name: "Sales & Marketing",
url: "#",
icon: PieChart,
},
{
name: "Travel",
url: "#",
icon: MapIcon,
},
],
};
function TeamSwitcher({
teams,
}: {
teams: {
name: string;
logo: React.ElementType;
plan: string;
}[];
}) {
const { isMobile } = useSidebar();
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
if (!activeTeam) {
return null;
}
return (
}
>
{activeTeam.name}
{activeTeam.plan}
Teams
{teams.map((team, index) => (
setActiveTeam(team)}
className="gap-2 p-2"
>
{team.name}
⌘{index + 1}
))}
Add team
);
}
function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: React.ElementType;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
return (
Platform
{items.map((item) => (
}
>
{item.icon && }
{item.title}
{item.items?.map((subItem) => (
}>
{subItem.title}
))}
))}
);
}
function NavProjects({
projects,
}: {
projects: {
name: string;
url: string;
icon: React.ElementType;
}[];
}) {
const { isMobile } = useSidebar();
return (
Projects
{projects.map((item) => (
}>
{item.name}
}>
More
View Project
Share Project
Delete Project
))}
More
);
}
function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
const { isMobile } = useSidebar();
return (
}
>
CN
{user.name}
{user.email}
CN
{user.name}
{user.email}
Upgrade to Pro
Account
Billing
Notifications
Log out
);
}
function AppSidebar({ ...props }: React.ComponentProps) {
return (
);
}
export default function Preview() {
return ;
}
```
A sidebar that collapses to icons.
Sidebars are one of the most complex components to build. They are central
to any application and often contain a lot of moving parts.
We now have a solid foundation to build on top of. Composable. Themeable.
Customizable.
[Browse the Blocks Library](https://ui.shadcn.com/blocks).
## Installation
```bash
bunx --bun shadcn@latest add @sui/sidebar
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers title="app/layout.tsx"
import { SidebarProvider, SidebarTrigger } from "@workspace/ui/components/sidebar"
import { AppSidebar } from "@/components/app-sidebar"
export default function Layout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
```tsx showLineNumbers title="components/app-sidebar.tsx"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
} from "@workspace/ui/components/sidebar"
export function AppSidebar() {
return (
)
}
```
## Composition
Use the following composition to build a `Sidebar` layout:
```text
SidebarProvider
├── Sidebar
│ ├── SidebarHeader
│ ├── SidebarContent
│ │ ├── SidebarGroup
│ │ │ ├── SidebarGroupLabel
│ │ │ ├── SidebarGroupAction
│ │ │ ├── SidebarGroupContent
│ │ │ └── SidebarMenu
│ │ │ ├── SidebarMenuItem
│ │ │ │ ├── SidebarMenuButton
│ │ │ │ ├── SidebarMenuAction
│ │ │ │ └── SidebarMenuBadge
│ │ │ └── SidebarMenuItem
│ │ │ ├── SidebarMenuButton
│ │ │ └── SidebarMenuSub
│ │ │ ├── SidebarMenuSubItem
│ │ │ └── SidebarMenuSubItem
│ │ └── SidebarGroup
│ │ └── SidebarMenu
│ │ ├── SidebarMenuItem
│ │ └── SidebarMenuItem
│ ├── SidebarFooter
│ └── SidebarRail
├── SidebarInset
└── SidebarTrigger
```
## Structure
- **SidebarProvider** — Handles collapsible state and provides sidebar context to child components.
- **Sidebar** — The main collapsible sidebar panel.
- **SidebarHeader** — Sticky at the top; use for branding, titles, or workspace switchers.
- **SidebarFooter** — Sticky at the bottom; use for user menus, settings, or actions.
- **SidebarContent** — Scrollable region between the header and footer.
- **SidebarGroup** — Groups related navigation with optional label, action, and content areas.
- **SidebarMenu** / **SidebarMenuItem** — Menu structure for links, badges, actions, and nested submenus.
- **SidebarRail** — Resize handle for adjusting sidebar width when applicable.
- **SidebarInset** — Wraps main content when using the `inset` variant.
- **SidebarTrigger** — Control that toggles the sidebar open or collapsed.


## SidebarProvider
The `SidebarProvider` component is used to provide the sidebar context to the `Sidebar` component. You should always wrap your application in a `SidebarProvider` component.
### Props
| Name | Type | Description |
| -------------- | ------------------------- | -------------------------------------------- |
| `defaultOpen` | `boolean` | Default open state of the sidebar. |
| `open` | `boolean` | Open state of the sidebar (controlled). |
| `onOpenChange` | `(open: boolean) => void` | Sets open state of the sidebar (controlled). |
### Width
If you have a single sidebar in your application, you can use the `SIDEBAR_WIDTH` and `SIDEBAR_WIDTH_MOBILE` variables in `sidebar.tsx` to set the width of the sidebar.
```tsx showLineNumbers title="packages/ui/src/components/sidebar.tsx"
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
```
For multiple sidebars in your application, you can use the `--sidebar-width` and `--sidebar-width-mobile` CSS variables in the `style` prop.
```tsx showLineNumbers
```
### Keyboard Shortcut
To trigger the sidebar, you use the `cmd+b` keyboard shortcut on Mac and `ctrl+b` on Windows.
```tsx showLineNumbers title="packages/ui/src/components/sidebar.tsx"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
```
## Sidebar
The main `Sidebar` component used to render a collapsible sidebar.
### Props
| Property | Type | Description |
| ------------- | --------------------------------- | --------------------------------- |
| `side` | `left` or `right` | The side of the sidebar. |
| `variant` | `sidebar`, `floating`, or `inset` | The variant of the sidebar. |
| `collapsible` | `offcanvas`, `icon`, or `none` | Collapsible state of the sidebar. |
| Prop | Description |
| ----------- | ------------------------------------------------------------ |
| `offcanvas` | A collapsible sidebar that slides in from the left or right. |
| `icon` | A sidebar that collapses to icons. |
| `none` | A non-collapsible sidebar. |
**Note:** If you use the `inset` variant, remember to wrap your main content
in a `SidebarInset` component.
```tsx showLineNumbers
{children}
```
## useSidebar
The `useSidebar` hook is used to control the sidebar.
```tsx showLineNumbers
import { useSidebar } from "@workspace/ui/components/sidebar"
export function AppSidebar() {
const {
state,
open,
setOpen,
openMobile,
setOpenMobile,
isMobile,
toggleSidebar,
} = useSidebar()
}
```
| Property | Type | Description |
| --------------- | ------------------------- | --------------------------------------------- |
| `state` | `expanded` or `collapsed` | The current state of the sidebar. |
| `open` | `boolean` | Whether the sidebar is open. |
| `setOpen` | `(open: boolean) => void` | Sets the open state of the sidebar. |
| `openMobile` | `boolean` | Whether the sidebar is open on mobile. |
| `setOpenMobile` | `(open: boolean) => void` | Sets the open state of the sidebar on mobile. |
| `isMobile` | `boolean` | Whether the sidebar is on mobile. |
| `toggleSidebar` | `() => void` | Toggles the sidebar. Desktop and mobile. |
## SidebarHeader
Use the `SidebarHeader` component to add a sticky header to the sidebar.
```tsx showLineNumbers title="components/app-sidebar.tsx"
}>
Select Workspace
Acme Inc
```
## SidebarFooter
Use the `SidebarFooter` component to add a sticky footer to the sidebar.
```tsx showLineNumbers
Username
```
## SidebarContent
The `SidebarContent` component is used to wrap the content of the sidebar. This is where you add your `SidebarGroup` components. It is scrollable.
```tsx showLineNumbers
```
## SidebarGroup
Use the `SidebarGroup` component to create a section within the sidebar.
A `SidebarGroup` has a `SidebarGroupLabel`, a `SidebarGroupContent` and an optional `SidebarGroupAction`.
```tsx showLineNumbers
Application
Add Project
```
To make a `SidebarGroup` collapsible, wrap it in a `Collapsible`.
```tsx showLineNumbers
}>
Help
```
## SidebarMenu
The `SidebarMenu` component is used for building a menu within a `SidebarGroup`.


```tsx showLineNumbers
{projects.map((project) => (
}>
{project.name}
))}
```
## SidebarMenuButton
The `SidebarMenuButton` component is used to render a menu button within a `SidebarMenuItem`.
By default, the `SidebarMenuButton` renders a button but you can use the `render` prop to render a different component such as a `Link` or an `a` tag.
Use the `isActive` prop to mark a menu item as active.
```tsx showLineNumbers
} isActive>
Home
```
## SidebarMenuAction
The `SidebarMenuAction` component is used to render a menu action within a `SidebarMenuItem`.
```tsx showLineNumbers
}>
Home
Add Project
```
## SidebarMenuSub
The `SidebarMenuSub` component is used to render a submenu within a `SidebarMenu`.
```tsx showLineNumbers
```
## SidebarMenuBadge
The `SidebarMenuBadge` component is used to render a badge within a `SidebarMenuItem`.
```tsx showLineNumbers
24
```
## SidebarMenuSkeleton
The `SidebarMenuSkeleton` component is used to render a skeleton for a `SidebarMenu`.
```tsx showLineNumbers
{Array.from({ length: 5 }).map((_, index) => (
))}
```
## SidebarTrigger
Use the `SidebarTrigger` component to render a button that toggles the sidebar.
```tsx showLineNumbers
import { useSidebar } from "@workspace/ui/components/sidebar"
export function CustomTrigger() {
const { toggleSidebar } = useSidebar()
return Toggle Sidebar
}
```
## SidebarRail
The `SidebarRail` component is used to render a rail within a `Sidebar`. This rail can be used to toggle the sidebar.
```tsx showLineNumbers
```
## Controlled Sidebar
Use the `open` and `onOpenChange` props to control the sidebar.
```tsx showLineNumbers
export function AppSidebar() {
const [open, setOpen] = React.useState(false)
return (
)
}
```
## Theming
We use the following CSS variables to theme the sidebar.
```css
@layer base {
:root {
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 0 0% 98%;
--sidebar-primary-foreground: 240 5.9% 10%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
```
## Styling
Here are some tips for styling the sidebar based on different states.
```tsx
```
```tsx
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
[View RTL Sidebar ↗](https://ui.shadcn.com/view/base-nova/sidebar-rtl)
## Changelog
### RTL Support
If you're upgrading from a previous version of the `Sidebar` component, you'll need to apply the following updates to add RTL support:
### Add `dir` prop to Sidebar component.
Add `dir` to the destructured props and pass it to `SheetContent` for mobile:
```diff
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
+ dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
```
Then pass it to `SheetContent` in the mobile view:
```diff
-
+
Toggle Sidebar
```
After applying these changes, you can use the `dir` prop to set the direction:
```tsx
{/* ... */}
```
The sidebar will correctly position itself and handle interactions in both LTR and RTL layouts.
---
# Skeleton
Use to show a placeholder while content is loading.
Page: https://sui.draco.dev/docs/components/skeleton
### Example: skeleton-demo
```tsx
import { Skeleton } from "@workspace/ui/components/skeleton";
export function SkeletonDemo() {
return (
);
}
export default SkeletonDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/skeleton
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Skeleton } from "@workspace/ui/components/skeleton"
```
```tsx
```
## Avatar
### Example: skeleton-avatar
```tsx
import { Skeleton } from "@workspace/ui/components/skeleton";
export function SkeletonAvatar() {
return (
);
}
export default SkeletonAvatar;
```
## Card
### Example: skeleton-card
```tsx
import { Card, CardContent, CardHeader } from "@workspace/ui/components/card";
import { Skeleton } from "@workspace/ui/components/skeleton";
export function SkeletonCard() {
return (
);
}
export default SkeletonCard;
```
## Text
### Example: skeleton-text
```tsx
import { Skeleton } from "@workspace/ui/components/skeleton";
export function SkeletonText() {
return (
);
}
export default SkeletonText;
```
## Form
### Example: skeleton-form
```tsx
import { Skeleton } from "@workspace/ui/components/skeleton";
export function SkeletonForm() {
return (
);
}
export default SkeletonForm;
```
## Table
### Example: skeleton-table
```tsx
// biome-ignore-all lint/suspicious/noArrayIndexKey: The upstream gallery uses fixed positional fixtures that never reorder.
import { Skeleton } from "@workspace/ui/components/skeleton";
export function SkeletonTable() {
return (
{Array.from({ length: 5 }).map((_, index) => (
))}
);
}
export default SkeletonTable;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: skeleton-rtl
```tsx
"use client";
import { Skeleton } from "@workspace/ui/components/skeleton";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {},
},
ar: {
dir: "rtl",
values: {},
},
he: {
dir: "rtl",
values: {},
},
};
export function SkeletonRtl() {
const { dir } = useTranslation(translations, "ar");
return (
);
}
export default SkeletonRtl;
```
---
# Slider
An input where the user selects a value from within a given range.
Page: https://sui.draco.dev/docs/components/slider
### Example: slider-demo
```tsx
import { Slider } from "@workspace/ui/components/slider";
export function SliderDemo() {
return (
);
}
export default SliderDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/slider
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Slider } from "@workspace/ui/components/slider"
```
```tsx
```
## Range
Use an array with two values for a range slider.
### Example: slider-range
```tsx
import { Slider } from "@workspace/ui/components/slider";
export function SliderRange() {
return (
);
}
export default SliderRange;
```
## Multiple Thumbs
Use an array with multiple values for multiple thumbs.
### Example: slider-multiple
```tsx
import { Slider } from "@workspace/ui/components/slider";
export function SliderMultiple() {
return (
);
}
export default SliderMultiple;
```
## Vertical
Use `orientation="vertical"` for a vertical slider.
### Example: slider-vertical
```tsx
import { Slider } from "@workspace/ui/components/slider";
export function SliderVertical() {
return (
);
}
export default SliderVertical;
```
## Controlled
### Example: slider-controlled
```tsx
"use client";
import { Label } from "@workspace/ui/components/label";
import { Slider } from "@workspace/ui/components/slider";
import * as React from "react";
import { useId as usePreviewId } from "react";
export function SliderControlled() {
const previewId = usePreviewId();
const [value, setValue] = React.useState([0.3, 0.7]);
return (
Temperature
{value.join(", ")}
setValue(value as number[])}
min={0}
max={1}
step={0.1}
/>
);
}
export default SliderControlled;
```
## Disabled
Use the `disabled` prop to disable the slider.
### Example: slider-disabled
```tsx
import { Slider } from "@workspace/ui/components/slider";
export function SliderDisabled() {
return (
);
}
export default SliderDisabled;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: slider-rtl
```tsx
"use client";
import { Slider } from "@workspace/ui/components/slider";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {},
},
ar: {
dir: "rtl",
values: {},
},
he: {
dir: "rtl",
values: {},
},
};
export function SliderRtl() {
const { dir } = useTranslation(translations, "ar");
return (
);
}
export default SliderRtl;
```
## API Reference
See the [Base UI Slider](https://base-ui.com/react/components/slider#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/slider)
- [API reference](https://base-ui.com/react/components/slider#api-reference)
---
# Switch
A control that allows the user to toggle between checked and not checked.
Page: https://sui.draco.dev/docs/components/switch
### Example: switch-demo
```tsx
import { Label } from "@workspace/ui/components/label";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export function SwitchDemo() {
const previewId = usePreviewId();
return (
Airplane Mode
);
}
export default SwitchDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/switch
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Switch } from "@workspace/ui/components/switch"
```
```tsx
```
## Description
### Example: switch-description
```tsx
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export function SwitchDescription() {
const previewId = usePreviewId();
return (
Share across devices
Focus is shared across devices, and turns off when you leave the app.
);
}
export default SwitchDescription;
```
## Choice Card
Card-style selection where `FieldLabel` wraps the entire `Field` for a clickable card pattern.
### Example: switch-choice-card
```tsx
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export function SwitchChoiceCard() {
const previewId = usePreviewId();
return (
Share across devices
Focus is shared across devices, and turns off when you leave the
app.
Enable notifications
Receive notifications when focus mode is enabled or disabled.
);
}
export default SwitchChoiceCard;
```
## Disabled
Add the `disabled` prop to the `Switch` component to disable the switch. Add the `data-disabled` prop to the `Field` component for styling.
### Example: switch-disabled
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export function SwitchDisabled() {
const previewId = usePreviewId();
return (
Disabled
);
}
export default SwitchDisabled;
```
## Invalid
Add the `aria-invalid` prop to the `Switch` component to indicate an invalid state. Add the `data-invalid` prop to the `Field` component for styling.
### Example: switch-invalid
```tsx
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export function SwitchInvalid() {
const previewId = usePreviewId();
return (
Accept terms and conditions
You must accept the terms and conditions to continue.
);
}
export default SwitchInvalid;
```
## Size
Use the `size` prop to change the size of the switch.
### Example: switch-sizes
```tsx
import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
export function SwitchSizes() {
const previewId = usePreviewId();
return (
Small
Default
);
}
export default SwitchSizes;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: switch-rtl
```tsx
"use client";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
label: "Share across devices",
description:
"Focus is shared across devices, and turns off when you leave the app.",
},
},
ar: {
dir: "rtl",
values: {
label: "المشاركة عبر الأجهزة",
description:
"يتم مشاركة التركيز عبر الأجهزة، ويتم إيقاف تشغيله عند مغادرة التطبيق.",
},
},
he: {
dir: "rtl",
values: {
label: "שיתוף בין מכשירים",
description: "המיקוד משותף בין מכשירים, וכבה כשאתה עוזב את האפליקציה.",
},
},
};
export function SwitchRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.label}
{t.description}
);
}
export default SwitchRtl;
```
## API Reference
See the [Base UI Switch](https://base-ui.com/react/components/switch#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/switch)
- [API reference](https://base-ui.com/react/components/switch#api-reference)
---
# TabBar
Controlled application navigation with press-and-slide selection and optional glass material.
Page: https://sui.draco.dev/docs/components/tab-bar
TabBar presents the primary destinations of an application with labels and optional icons. Its selected indicator follows the current UI style and uses CSS Gaussian blur in `glass` mode, while only the outer bar receives glass material; content and routing remain under application control.
### Example: tab-bar-demo
```tsx
"use client";
import { GlassProvider } from "@workspace/ui/components/glass";
import { TabBar } from "@workspace/ui/components/tab-bar";
import { BellIcon, HomeIcon, SearchIcon, SettingsIcon } from "lucide-react";
import { useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const scene = useRef(null);
const [value, setValue] = useState("home");
const items = [
{ value: "home", label: chinese ? "首页" : "Home", icon: },
{
value: "search",
label: chinese ? "搜索" : "Search",
icon: ,
},
{
value: "activity",
label: chinese ? "动态" : "Activity",
icon: ,
},
{
value: "settings",
label: chinese ? "设置" : "Settings",
icon: ,
},
];
const current = items.find((item) => item.value === value);
return (
{chinese ? "当前目的地:" : "Current destination: "}
{current?.label}
{chinese ? "默认外观" : "Default appearance"}
{chinese ? "玻璃模式" : "Glass mode"}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/tab-bar
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { TabBar } from "@workspace/ui/components/tab-bar";
import { useState } from "react";
export function ApplicationNavigation() {
const [value, setValue] = useState("home");
return (
);
}
```
## Navigation and selection
`value` identifies the current destination. `onValueChange` asks the application to select another destination, so it can update a router, preserve a query or anchor, or display its own view. Items must have unique values. A disabled item cannot be selected.
TabBar renders a navigation landmark with buttons and exposes the current destination through `aria-current`. Give the landmark an `aria-label` and each item a clear text label; decorative icons are optional. It does not render content panels. Use [Tabs](/docs/components/tabs) when selecting among panels within a page.
## Keyboard and layout
Arrow keys move among enabled destinations in the bar's orientation. Home and End focus the first and last enabled destinations. Moving focus does not change the current destination; Enter or Space activates the focused button. The current destination participates in the Tab sequence; keyboard activation follows the same change callback as pointer activation. Horizontal layout follows the document direction, including RTL.
The bar can scroll when destinations exceed its available space. Set `orientation="vertical"` to arrange destinations vertically. The moving selection background follows the selected button's current bounds after layout, resizing, or scrolling. Reduced-motion preferences disable animated movement while keeping selection visible.
## Material
TabBar defaults to the existing UI appearance with `glass={false}`. Pass `glass` to apply shared glass material to the outer bar, inheriting [GlassProvider](/docs/components/glass) rendering and material settings. Both modes use a theme-colored selection background; glass mode adds approximately `7px` of CSS Gaussian blur, with icons and labels in the theme primary color. A floating glass lens appears only while holding, reusing the shared material and background capture.
Keep the surrounding container ordinary to avoid nested glass. The example compares both modes using the same controlled destinations and background.
## Press and slide
Hold an enabled item for 250ms or drag beyond 6px to expand the selection into a floating lens and gently enlarge its icon and label. In glass mode the lens uses clear glass with very little tint and approximately `0.6px` blur; otherwise it keeps the theme selection background. Slide while holding to continuously move the lens and position the lens over other enabled items. The resting selection background is hidden while holding. The current item’s theme color and `aria-current` remain unchanged until releasing commits the new selection and restores its background. Releasing outside the bar, cancelling the pointer, or losing window focus restores the original selection. Short clicks and keyboard activation keep their usual behavior, and disabled items remain unavailable. Reduced-motion preferences disable scaling and animated movement.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `items` | `readonly TabBarItem[]` | Destination list. |
| Item `value` | `string` | Unique destination identifier. |
| Item `label` | `string` | Visible destination label. |
| Item `icon` | `ReactNode` | Optional decorative icon. |
| Item `disabled` | `boolean` | Prevents selection. |
| `value` | `string` | Controlled current destination. |
| `onValueChange` | `(value: string) => void` | Called when selecting an enabled destination. |
| `glass` | `boolean` | `false`; optional glass treatment. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"`. |
| `onItemActivate` | `(value: string) => void` | Optional activation notification. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"`. |
| Other props | Native nav props | Includes `aria-label`, `className`, `style`, and `ref`. |
The module exports `TabBar` and its public props and item types. Application routing remains outside the component.
---
# Table
A responsive table component.
Page: https://sui.draco.dev/docs/components/table
### Example: table-demo
```tsx
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
const invoices = [
{
invoice: "INV001",
paymentStatus: "Paid",
totalAmount: "$250.00",
paymentMethod: "Credit Card",
},
{
invoice: "INV002",
paymentStatus: "Pending",
totalAmount: "$150.00",
paymentMethod: "PayPal",
},
{
invoice: "INV003",
paymentStatus: "Unpaid",
totalAmount: "$350.00",
paymentMethod: "Bank Transfer",
},
{
invoice: "INV004",
paymentStatus: "Paid",
totalAmount: "$450.00",
paymentMethod: "Credit Card",
},
{
invoice: "INV005",
paymentStatus: "Paid",
totalAmount: "$550.00",
paymentMethod: "PayPal",
},
{
invoice: "INV006",
paymentStatus: "Pending",
totalAmount: "$200.00",
paymentMethod: "Bank Transfer",
},
{
invoice: "INV007",
paymentStatus: "Unpaid",
totalAmount: "$300.00",
paymentMethod: "Credit Card",
},
];
export function TableDemo() {
return (
A list of your recent invoices.
Invoice
Status
Method
Amount
{invoices.map((invoice) => (
{invoice.invoice}
{invoice.paymentStatus}
{invoice.paymentMethod}
{invoice.totalAmount}
))}
Total
$2,500.00
);
}
export default TableDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/table
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table"
```
```tsx showLineNumbers
A list of your recent invoices.
Invoice
Status
Method
Amount
INV001
Paid
Credit Card
$250.00
```
## Composition
Use the following composition to build a `Table`:
```text
Table
├── TableCaption
├── TableHeader
│ └── TableRow
│ ├── TableHead
│ ├── TableHead
│ ├── TableHead
│ └── TableHead
├── TableBody
│ ├── TableRow
│ │ ├── TableCell
│ │ ├── TableCell
│ │ ├── TableCell
│ │ └── TableCell
│ └── TableRow
│ ├── TableCell
│ ├── TableCell
│ ├── TableCell
│ └── TableCell
└── TableFooter
```
## Footer
Use the ` ` component to add a footer to the table.
### Example: table-footer
```tsx
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
const invoices = [
{
invoice: "INV001",
paymentStatus: "Paid",
totalAmount: "$250.00",
paymentMethod: "Credit Card",
},
{
invoice: "INV002",
paymentStatus: "Pending",
totalAmount: "$150.00",
paymentMethod: "PayPal",
},
{
invoice: "INV003",
paymentStatus: "Unpaid",
totalAmount: "$350.00",
paymentMethod: "Bank Transfer",
},
{
invoice: "INV004",
paymentStatus: "Paid",
totalAmount: "$450.00",
paymentMethod: "Credit Card",
},
{
invoice: "INV005",
paymentStatus: "Paid",
totalAmount: "$550.00",
paymentMethod: "PayPal",
},
{
invoice: "INV006",
paymentStatus: "Pending",
totalAmount: "$200.00",
paymentMethod: "Bank Transfer",
},
{
invoice: "INV007",
paymentStatus: "Unpaid",
totalAmount: "$300.00",
paymentMethod: "Credit Card",
},
];
export function TableFooterExample() {
return (
A list of your recent invoices.
Invoice
Status
Method
Amount
{invoices.slice(0, 3).map((invoice) => (
{invoice.invoice}
{invoice.paymentStatus}
{invoice.paymentMethod}
{invoice.totalAmount}
))}
Total
$2,500.00
);
}
export default TableFooterExample;
```
## Actions
A table showing actions for each row using a ` ` component.
### Example: table-actions
```tsx
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import { MoreHorizontalIcon } from "lucide-react";
export function TableActions() {
return (
Product
Price
Actions
Wireless Mouse
$29.99
}
>
Open menu
Edit
Duplicate
Delete
Mechanical Keyboard
$129.99
}
>
Open menu
Edit
Duplicate
Delete
USB-C Hub
$49.99
}
>
Open menu
Edit
Duplicate
Delete
);
}
export default TableActions;
```
## Data Table
Use the shared [Data Table block](/docs/blocks/data-table) for search, filters, sorting, pagination, selection, bulk actions, and drag ordering. Import it from `@workspace/ui/blocks/data-table`.
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: table-rtl
```tsx
"use client";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
caption: "A list of your recent invoices.",
invoice: "Invoice",
status: "Status",
method: "Method",
amount: "Amount",
paid: "Paid",
pending: "Pending",
unpaid: "Unpaid",
creditCard: "Credit Card",
paypal: "PayPal",
bankTransfer: "Bank Transfer",
total: "Total",
},
},
ar: {
dir: "rtl",
values: {
caption: "قائمة بفواتيرك الأخيرة.",
invoice: "الفاتورة",
status: "الحالة",
method: "الطريقة",
amount: "المبلغ",
paid: "مدفوع",
pending: "قيد الانتظار",
unpaid: "غير مدفوع",
creditCard: "بطاقة ائتمانية",
paypal: "PayPal",
bankTransfer: "تحويل بنكي",
total: "المجموع",
},
},
he: {
dir: "rtl",
values: {
caption: "רשימת החשבוניות האחרונות שלך.",
invoice: "חשבונית",
status: "סטטוס",
method: "שיטה",
amount: "סכום",
paid: "שולם",
pending: "ממתין",
unpaid: "לא שולם",
creditCard: "כרטיס אשראי",
paypal: "PayPal",
bankTransfer: "העברה בנקאית",
total: 'סה"כ',
},
},
};
const invoices = [
{
invoice: "INV001",
paymentStatus: "paid" as const,
totalAmount: "$250.00",
paymentMethod: "creditCard" as const,
},
{
invoice: "INV002",
paymentStatus: "pending" as const,
totalAmount: "$150.00",
paymentMethod: "paypal" as const,
},
{
invoice: "INV003",
paymentStatus: "unpaid" as const,
totalAmount: "$350.00",
paymentMethod: "bankTransfer" as const,
},
{
invoice: "INV004",
paymentStatus: "paid" as const,
totalAmount: "$450.00",
paymentMethod: "creditCard" as const,
},
{
invoice: "INV005",
paymentStatus: "paid" as const,
totalAmount: "$550.00",
paymentMethod: "paypal" as const,
},
{
invoice: "INV006",
paymentStatus: "pending" as const,
totalAmount: "$200.00",
paymentMethod: "bankTransfer" as const,
},
{
invoice: "INV007",
paymentStatus: "unpaid" as const,
totalAmount: "$300.00",
paymentMethod: "creditCard" as const,
},
];
export function TableRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.caption}
{t.invoice}
{t.status}
{t.method}
{t.amount}
{invoices.map((invoice) => (
{invoice.invoice}
{t[invoice.paymentStatus]}
{t[invoice.paymentMethod]}
{invoice.totalAmount}
))}
{t.total}
$2,500.00
);
}
export default TableRtl;
```
---
# Tabs
A set of layered sections of content—known as tab panels—that are displayed one at a time.
Page: https://sui.draco.dev/docs/components/tabs
### Example: tabs-demo
```tsx
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
export function TabsDemo() {
return (
Overview
Analytics
Reports
Settings
Overview
View your key metrics and recent project activity. Track progress
across all your active projects.
You have 12 active projects and 3 pending tasks.
Analytics
Track performance and user engagement metrics. Monitor trends and
identify growth opportunities.
Page views are up 25% compared to last month.
Reports
Generate and download your detailed reports. Export data in
multiple formats for analysis.
You have 5 reports ready and available to export.
Settings
Manage your account preferences and options. Customize your
experience to fit your needs.
Configure notifications, security, and themes.
);
}
export default TabsDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/tabs
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"
```
```tsx showLineNumbers
Account
Password
Make changes to your account here.
Change your password here.
```
## Composition
Use the following composition to build `Tabs`:
```text
Tabs
├── TabsList
│ ├── TabsTrigger
│ └── TabsTrigger
├── TabsContent
└── TabsContent
```
## Line
Use the `variant="line"` prop on `TabsList` for a line style.
### Example: tabs-line
```tsx
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
export function TabsLine() {
return (
Overview
Analytics
Reports
);
}
export default TabsLine;
```
## Segment
Use the `variant="segment"` prop on `TabsList` for a compact segmented control. Use `wrapperClassName="w-full"` to fill the available width.
### Example: tabs-segment
```tsx
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
export function TabsSegment() {
return (
Day
Week
Month
View today's activity.
View this week's activity.
View this month's activity.
);
}
export default function Preview() {
return (
);
}
```
## Overflow
Tab lists scroll automatically when they exceed the available space. Arrow controls and faded edges indicate more tabs. Selected and focused tabs scroll into view within the list, including vertical and RTL layouts.
### Example: tabs-overflow
```tsx
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
const tabs = [
"Overview",
"Activity",
"Analytics",
"Members",
"Billing",
"Settings",
];
export function TabsOverflow() {
return (
{tabs.map((tab) => (
{tab}
))}
{tabs.map((tab) => (
Manage your project's {tab.toLowerCase()}.
))}
);
}
export default function Preview() {
return (
);
}
```
## Vertical
Use `orientation="vertical"` for vertical tabs.
### Example: tabs-vertical
```tsx
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import type { ExampleProps } from "../types";
export function TabsVertical({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "账户" : "Account"}
{chinese ? "密码" : "Password"}
{chinese ? "通知" : "Notifications"}
{chinese ? "账户" : "Account"}
{chinese
? "在此查看你的个人资料与账户偏好"
: "View your profile and account preferences here."}
{chinese ? "密码" : "Password"}
{chinese
? "使用唯一密码并启用双重验证以保护账户"
: "Protect your account with a unique password and two-factor authentication."}
{chinese ? "通知" : "Notifications"}
{chinese
? "项目更新与账户提醒将发送到你的邮箱"
: "Project updates and account alerts are delivered to your inbox."}
);
}
export default TabsVertical;
```
## Disabled
### Example: tabs-disabled
```tsx
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
export function TabsDisabled() {
return (
Home
Disabled
);
}
export default TabsDisabled;
```
## Icons
### Example: tabs-icons
```tsx
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { AppWindowIcon, CodeIcon } from "lucide-react";
export function TabsIcons() {
return (
Preview
Code
);
}
export default TabsIcons;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: tabs-rtl
```tsx
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
overview: "Overview",
analytics: "Analytics",
reports: "Reports",
settings: "Settings",
overviewTitle: "Overview",
overviewDesc:
"View your key metrics and recent project activity. Track progress across all your active projects.",
overviewContent: "You have 12 active projects and 3 pending tasks.",
analyticsTitle: "Analytics",
analyticsDesc:
"Track performance and user engagement metrics. Monitor trends and identify growth opportunities.",
analyticsContent: "Page views are up 25% compared to last month.",
reportsTitle: "Reports",
reportsDesc:
"Generate and download your detailed reports. Export data in multiple formats for analysis.",
reportsContent: "You have 5 reports ready and available to export.",
settingsTitle: "Settings",
settingsDesc:
"Manage your account preferences and options. Customize your experience to fit your needs.",
settingsContent: "Configure notifications, security, and themes.",
},
},
ar: {
dir: "rtl",
values: {
overview: "نظرة عامة",
analytics: "التحليلات",
reports: "التقارير",
settings: "الإعدادات",
overviewTitle: "نظرة عامة",
overviewDesc:
"عرض مقاييسك الرئيسية وأنشطة المشروع الأخيرة. تتبع التقدم عبر جميع مشاريعك النشطة.",
overviewContent: "لديك ١٢ مشروعًا نشطًا و٣ مهام معلقة.",
analyticsTitle: "التحليلات",
analyticsDesc:
"تتبع مقاييس الأداء ومشاركة المستخدمين. راقب الاتجاهات وحدد فرص النمو.",
analyticsContent: "زادت مشاهدات الصفحة بنسبة ٢٥٪ مقارنة بالشهر الماضي.",
reportsTitle: "التقارير",
reportsDesc:
"إنشاء وتنزيل تقاريرك التفصيلية. تصدير البيانات بتنسيقات متعددة للتحليل.",
reportsContent: "لديك ٥ تقارير جاهزة ومتاحة للتصدير.",
settingsTitle: "الإعدادات",
settingsDesc:
"إدارة تفضيلات حسابك وخياراته. تخصيص تجربتك لتناسب احتياجاتك.",
settingsContent: "تكوين الإشعارات والأمان والسمات.",
},
},
he: {
dir: "rtl",
values: {
overview: "סקירה כללית",
analytics: "אנליטיקה",
reports: "דוחות",
settings: "הגדרות",
overviewTitle: "סקירה כללית",
overviewDesc:
"הצג את המדדים העיקריים שלך ופעילות הפרויקט האחרונה. עקוב אחר התקדמות בכל הפרויקטים הפעילים שלך.",
overviewContent: "יש לך 12 פרויקטים פעילים ו-3 משימות ממתינות.",
analyticsTitle: "אנליטיקה",
analyticsDesc:
"עקוב אחר ביצועים ומדדי מעורבות משתמשים. עקוב אחר מגמות וזהה הזדמנויות צמיחה.",
analyticsContent: "צפיות בדף עלו ב-25% בהשוואה לחודש שעבר.",
reportsTitle: "דוחות",
reportsDesc:
"צור והורד את הדוחות המפורטים שלך. ייצא נתונים בפורמטים מרובים לניתוח.",
reportsContent: "יש לך 5 דוחות מוכנים וזמינים לייצוא.",
settingsTitle: "הגדרות",
settingsDesc:
"נהל את העדפות החשבון והאפשרויות שלך. התאם אישית את החוויה שלך כך שתתאים לצרכים שלך.",
settingsContent: "הגדר התראות, אבטחה וערכות נושא.",
},
},
};
export function TabsRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.overview}
{t.analytics}
{t.reports}
{t.settings}
{t.overviewTitle}
{t.overviewDesc}
{t.overviewContent}
{t.analyticsTitle}
{t.analyticsDesc}
{t.analyticsContent}
{t.reportsTitle}
{t.reportsDesc}
{t.reportsContent}
{t.settingsTitle}
{t.settingsDesc}
{t.settingsContent}
);
}
export default TabsRtl;
```
## Animation
The selected indicator and panel use the same 250ms easing. Content fades in from an 8px offset on the side of the newly selected tab: left or right for horizontal tabs, up or down for vertical tabs. Base UI derives the direction from the actual tab positions, including RTL. The initial panel appears without an entrance animation; inactive panels leave the layout immediately and cannot receive focus. Reduced motion disables the transition and offset. Selected tabs use the theme's `primary` and `primary-foreground` colors; the line variant uses `primary` for its indicator and selected label.
Use `indicatorClassName` on `TabsList` to customize the indicator and `wrapperClassName` for the outer container's layout. `className`, `ref`, and `render` continue to apply to the Base UI list. `TabsContent` retains Base UI's `keepMounted` behavior.
## API Reference
See the [Base UI Tabs](https://base-ui.com/react/components/tabs#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/tabs)
- [API reference](https://base-ui.com/react/components/tabs#api-reference)
---
# Tag Input
A multi-value input with custom tags, suggestions, and keyboard-accessible chips.
Page: https://sui.draco.dev/docs/components/tag-input
### Example: tag-input-demo
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { TagInput } from "@workspace/ui/components/tag-input";
import { useId } from "react";
import type { ExampleProps } from "../types";
export default function TagInputDemo({ locale }: ExampleProps) {
const id = useId();
const chinese = locale === "zh-CN";
return (
{chinese ? "项目标签" : "Project tags"}
`添加“${value}”`,
removeValue: (value) => `移除 ${value}`,
empty: "没有建议选项",
}
: undefined
}
/>
{chinese
? "输入后按 Enter、逗号或 Tab 添加。支持中文输入法"
: "Add with Enter, comma, or Tab. IME composition stays in the input."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/tag-input
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { TagInput } from "@workspace/ui/components/tag-input";
;
```
Type a tag and press Enter, comma, or Tab. Tags are trimmed; empty values and exact duplicates are ignored. Comparison is case-sensitive. Pasting comma-separated values or multiple lines adds them in order.
The component uses Base UI Combobox's multi-select chips for keyboard navigation and removal. During IME composition, Enter and comma stay with the input method and do not create a tag. With an empty draft, Backspace removes the last tag; arrow keys navigate suggestions and chips.
## Suggestions and controlled values
Supply `suggestions` to offer existing values. Users may still create their own tags by default; set `allowCustom={false}` to restrict new values to the suggestions. A highlighted suggestion is selected with Enter.
Use `value` and `onValueChange` to control the selected array, or `defaultValue` for an uncontrolled input. The draft can also be controlled independently with `inputValue` and `onInputValueChange`.
### Example: tag-input-suggestions
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { TagInput } from "@workspace/ui/components/tag-input";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
const suggestions = [
"React",
"TypeScript",
"Fumadocs",
"TanStack Start",
"Base UI",
];
export default function TagInputSuggestions({ locale }: ExampleProps) {
const id = useId();
const [values, setValues] = useState(["React"]);
const chinese = locale === "zh-CN";
return (
{chinese ? "技术栈" : "Tech stack"}
`添加“${value}”`,
removeValue: (value) => `移除 ${value}`,
empty: "没有匹配的技术",
}
: undefined
}
/>
{chinese
? "用方向键选择建议,也可以创建自己的标签"
: "Choose a suggestion with the arrow keys or create your own tag."}
);
}
```
## Validation and disabled state
`maxValues` limits the number of tags. `validateValue` checks each new value and receives the values already accepted. Existing tags can always be removed, even when the limit has been reached.
Rejected input remains in the draft and its error is announced. Tab still moves focus out of the input when validation fails; Shift+Tab moves backward without committing the draft. In a batch paste, valid tags before the first rejected value are added, while the remaining values stay in the draft.
`disabled` and `readOnly` prevent changes and disable removal controls.
### Example: tag-input-validation
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { TagInput } from "@workspace/ui/components/tag-input";
import { useId } from "react";
import type { ExampleProps } from "../types";
export default function TagInputValidation({ locale }: ExampleProps) {
const id = useId();
const chinese = locale === "zh-CN";
const labels = chinese
? {
createValue: (value: string) => `添加“${value}”`,
removeValue: (value: string) => `移除 ${value}`,
invalidValue: (value: string) => `“${value}”只能包含字母或数字`,
maxValuesReached: (limit: number) => `最多添加 ${limit} 个标签`,
empty: "没有建议选项",
}
: undefined;
return (
{chinese ? "最多 3 个标签" : "Up to 3 tags"}
/^[a-z0-9]+$/i.test(value)}
labels={labels}
placeholder={chinese ? "添加字母或数字…" : "Letters or numbers…"}
/>
{chinese
? "错误会保留草稿,移除标签后可继续添加"
: "Invalid input stays in the draft. Remove a tag to make room."}
{chinese ? "禁用标签" : "Disabled tags"}
);
}
```
## Composition and forms
Tag Input composes Combobox's root, chips, input, list, and items. Use the standard `Field` components for labels and descriptions. `className` styles the chips container, `inputClassName` styles the draft input, and `ref` points to the native draft input.
Pass `name` to submit each selected tag as a separate form value with the same name. Draft text is not submitted. `required` validates the selected tags, so an existing tag satisfies the requirement even when the draft is empty. Disabled tags are omitted from form data.
Native form reset restores `defaultValue` and `defaultInputValue` in uncontrolled mode. Canceling the reset with `event.preventDefault()` preserves the selected tags and draft. Controlled values remain owned by the application.
### Example: tag-input-form
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { TagInput } from "@workspace/ui/components/tag-input";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
const defaultTags = ["design"];
export default function TagInputForm({ locale }: ExampleProps) {
const id = useId();
const [submitted, setSubmitted] = useState(null);
const [keepTags, setKeepTags] = useState(false);
const chinese = locale === "zh-CN";
const notSubmittedLabel = chinese ? "尚未提交" : "Not submitted";
return (
);
}
```
## Accessibility
Associate `FieldLabel` with the input's `id`, or provide `aria-label`. Without an `id` or a custom name, the default input name is “Add tag”. Removal buttons have names that include their tag. Validation messages use a status region and are associated with the input.
Localize `labels.input`, `labels.removeValue`, `labels.createValue`, `labels.empty`, `labels.invalidValue`, and `labels.maxValuesReached`. Prefer the same input name as the visible field label.
## API Reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string[]` | — | Controlled selected tags. |
| `defaultValue` | `string[]` | `[]` | Initial uncontrolled tags. |
| `onValueChange` | `(values: string[]) => void` | — | Called when selected tags change. |
| `suggestions` | `readonly string[]` | `[]` | Suggested tags. |
| `allowCustom` | `boolean` | `true` | Allow new values outside suggestions. |
| `maxValues` | `number` | — | Maximum tag count. |
| `validateValue` | `(value: string, values: string[]) => boolean` | — | Validate a new tag. |
| `inputValue` | `string` | — | Controlled draft text. |
| `defaultInputValue` | `string` | `""` | Initial uncontrolled draft text. |
| `onInputValueChange` | `(value: string) => void` | — | Called when the draft changes. |
| `labels` | `TagInputLabels` | English labels | Input, action, and validation text. |
| `inputClassName` | `string` | — | Additional draft input classes. |
| `...props` | Native input props, excluding `value`, `defaultValue`, `type`, and `size` | — | Includes `id`, `ref`, `name`, `form`, `required`, `disabled`, and `readOnly`. |
Keyboard and chip behavior follow the [Base UI Combobox API](https://base-ui.com/react/components/combobox#api-reference).
- [Documentation](https://base-ui.com/react/components/combobox)
- [API reference](https://base-ui.com/react/components/combobox#api-reference)
---
# Textarea
Displays a form textarea or a component that looks like a textarea.
Page: https://sui.draco.dev/docs/components/textarea
### Example: textarea-demo
```tsx
import { Textarea } from "@workspace/ui/components/textarea";
export default function TextareaDemo() {
return ;
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/textarea
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Textarea } from "@workspace/ui/components/textarea"
```
```tsx
```
## Field
Use `Field`, `FieldLabel`, and `FieldDescription` to create a textarea with a label and description.
### Example: textarea-field
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
export function TextareaField() {
const previewId = usePreviewId();
return (
Message
Enter your message below.
);
}
export default TextareaField;
```
## Disabled
Use the `disabled` prop to disable the textarea. To style the disabled state, add the `data-disabled` attribute to the `Field` component.
### Example: textarea-disabled
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
export function TextareaDisabled() {
const previewId = usePreviewId();
return (
Message
);
}
export default TextareaDisabled;
```
## Invalid
Use the `aria-invalid` prop to mark the textarea as invalid. To style the invalid state, add the `data-invalid` attribute to the `Field` component.
### Example: textarea-invalid
```tsx
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
export function TextareaInvalid() {
const previewId = usePreviewId();
return (
Message
Please enter a valid message.
);
}
export default TextareaInvalid;
```
## Button
Pair with `Button` to create a textarea with a submit button.
### Example: textarea-button
```tsx
import { Button } from "@workspace/ui/components/button";
import { Textarea } from "@workspace/ui/components/textarea";
export function TextareaButton() {
return (
Send message
);
}
export default TextareaButton;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: textarea-rtl
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import { Textarea } from "@workspace/ui/components/textarea";
import { useId as usePreviewId } from "react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
label: "Feedback",
placeholder: "Your feedback helps us improve...",
description: "Share your thoughts about our service.",
},
},
ar: {
dir: "rtl",
values: {
label: "التعليقات",
placeholder: "تعليقاتك تساعدنا على التحسين...",
description: "شاركنا أفكارك حول خدمتنا.",
},
},
he: {
dir: "rtl",
values: {
label: "משוב",
placeholder: "המשוב שלך עוזר לנו להשתפר...",
description: "שתף את מחשבותיך על השירות שלנו.",
},
},
};
export default function TextareaRtl() {
const previewId = usePreviewId();
const { dir, t } = useTranslation(translations, "ar");
return (
{t.label}
{t.description}
);
}
```
---
# Theme Toggle
A controlled light/dark switch with optional view-transition effects.
Page: https://sui.draco.dev/docs/components/theme-toggle
### Example: theme-toggle-demo
```tsx
"use client";
import { ThemeToggle } from "@workspace/ui/components/theme-toggle";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { applyAccent, normalizeHex, themePresets } from "../../lib/theme";
import type { ExampleProps } from "../types";
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const themeLabels = chinese
? { light: "浅色", dark: "深色" }
: { light: "Light", dark: "Dark" };
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const theme = mounted && resolvedTheme === "dark" ? "dark" : "light";
const changeTheme = (next: "light" | "dark") => {
const root = document.documentElement;
const accent =
root.dataset.color === "custom"
? normalizeHex(root.dataset.colorSeed ?? "")
: (themePresets.find((preset) => preset.id === root.dataset.color)
?.color ?? null);
root.classList.toggle("dark", next === "dark");
root.style.colorScheme = next;
applyAccent(accent, next === "dark");
setTheme(next);
};
return (
{chinese ? "文档站主题" : "Documentation appearance"}
{themeLabels[theme]}
{chinese
? "此示例切换文档站主题并保存偏好,可在顶栏选择跟随系统或调整配色"
: "This example switches the documentation theme and saves the preference. The header provides System mode and accent settings."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/theme-toggle
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { ThemeToggle } from "@workspace/ui/components/theme-toggle";
import { useState } from "react";
export function Appearance() {
const [theme, setTheme] = useState<"light" | "dark">("light");
return ;
}
```
## Controlled appearance
`theme` is the current resolved light or dark theme. `onThemeChange` requests the other theme. Apply the new theme to your application in this callback; the component does not set a provider, save preferences, or implement a System preference. The live examples use the documentation’s `next-themes` provider and change the whole site appearance, saving the selected mode while retaining its accent color. The header still offers System mode and accent settings.
For a System setting, resolve it in your application and pass the effective light or dark theme here. Keep your application’s preference controls separate when System must remain selectable.
## Transition variants
Choose `rectangle`, `circle`, `circle-blur`, or `blinds`. The `start` option selects an origin such as the clicked button, a corner, the center, or a bottom-up transition. These effects use the browser View Transition API. Without support, or with reduced motion, the component changes the theme directly.
### Example: theme-toggle-variants
```tsx
"use client";
import { ThemeToggle } from "@workspace/ui/components/theme-toggle";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { applyAccent, normalizeHex, themePresets } from "../../lib/theme";
import type { ExampleProps } from "../types";
const variants = ["rectangle", "circle", "circle-blur", "blinds"] as const;
export default function Example({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const theme = mounted && resolvedTheme === "dark" ? "dark" : "light";
const changeTheme = (next: "light" | "dark") => {
const root = document.documentElement;
const accent =
root.dataset.color === "custom"
? normalizeHex(root.dataset.colorSeed ?? "")
: (themePresets.find((preset) => preset.id === root.dataset.color)
?.color ?? null);
root.classList.toggle("dark", next === "dark");
root.style.colorScheme = next;
applyAccent(accent, next === "dark");
setTheme(next);
};
return (
{variants.map((variant) => (
{variant}
))}
);
}
```
## Labels and icons
`lightLabel` and `darkLabel` describe the action to switch to each theme and supply the tooltip text. The default ghost button uses a 16px Sun/Moon SVG with 2px strokes and a snappy spring geometry morph. Reduced motion swaps the shape directly. Customize `lightIcon`, `darkIcon`, and `iconClassName` when needed; arbitrary React icons switch directly rather than undergoing a path morph. Native button props, including `disabled`, `ref`, and `render`, are supported. The switch does not submit a surrounding form.
## API reference
| Prop | Type | Default / behavior |
| --- | --- | --- |
| `theme` | `"light" \| "dark"` | Required resolved theme. |
| `onThemeChange` | `(theme: "light" \| "dark") => void` | Required theme update callback. |
| `variant` | `"rectangle" \| "circle" \| "circle-blur" \| "blinds"` | `"rectangle"`. |
| `start` | `"button" \| "top-left" \| "top-right" \| "bottom-left" \| "bottom-right" \| "center" \| "bottom-up"` | `"bottom-up"`. |
| `lightLabel`, `darkLabel` | `string` | English switch-action labels. |
| `lightIcon`, `darkIcon` | `ReactNode` | Default theme icons. |
| `iconClassName` | `string` | Optional icon styling. |
| `glass` | `boolean` | `false`. |
The module exports `ThemeToggleProps`. See [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) for browser transition behavior.
---
# Toast
A succinct message that is displayed temporarily.
Page: https://sui.draco.dev/docs/components/toast
### Example: toast-demo
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { toast } from "@workspace/ui/components/toast";
export function ToastDemo() {
function showToast() {
const id = toast.add({
title: "Event created",
description: "Sunday, December 3 at 9:00 AM",
actionProps: {
children: "Undo",
onClick() {
toast.close(id);
},
},
});
}
return (
Show Toast
);
}
export default ToastDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/toast
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { toast } from "@workspace/ui/components/toast"
```
```tsx
toast.add({
title: "Event created",
description: "Sunday, December 3 at 9:00 AM",
})
```
## Types
Set the `type` option to render a status icon. The built-in renderer recognizes
`success`, `info`, `warning`, `error`, and `loading`.
### Example: toast-types
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { toast } from "@workspace/ui/components/toast";
export function ToastTypes() {
return (
toast.add({ description: "Event has been created." })}
>
Default
toast.add({
type: "success",
description: "Event has been created.",
})
}
>
Success
toast.add({
type: "info",
description: "Arrive 10 minutes before the event.",
})
}
>
Info
toast.add({
type: "warning",
description: "The event cannot start before 8:00 AM.",
})
}
>
Warning
toast.add({
type: "error",
description: "The event could not be created.",
priority: "high",
})
}
>
Error
);
}
export default ToastTypes;
```
## Action
Pass button props with `actionProps` to render an action.
```tsx
const id = toast.add({
title: "Event created",
actionProps: {
children: "Undo",
onClick() {
toast.close(id)
},
},
})
```
## Promise
Use `toast.promise` to update one toast as an asynchronous task moves through
loading, success, and error states.
### Example: toast-promise
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { toast } from "@workspace/ui/components/toast";
export function ToastPromise() {
function showToast() {
toast.promise(
new Promise<{ name: string }>((resolve) => {
window.setTimeout(() => resolve({ name: "Event" }), 2000);
}),
{
loading: "Creating event…",
success: (data) => `${data.name} created.`,
error: "Could not create event.",
},
);
}
return (
Create Event
);
}
export default ToastPromise;
```
## API Reference
See the [Base UI Toast documentation](https://base-ui.com/react/components/toast)
for details about manager options, stacking, swipe dismissal, and the primitive
API.
- [Documentation](https://base-ui.com/react/components/toast)
- [API reference](https://base-ui.com/react/components/toast#api-reference)
---
# Toggle
A two-state button that can be either on or off.
Page: https://sui.draco.dev/docs/components/toggle
### Example: toggle-demo
```tsx
import { Toggle } from "@workspace/ui/components/toggle";
import { BookmarkIcon } from "lucide-react";
export function ToggleDemo() {
return (
Bookmark
);
}
export default ToggleDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/toggle
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { Toggle } from "@workspace/ui/components/toggle"
```
```tsx
Toggle
```
## Outline
Use `variant="outline"` for an outline style.
### Example: toggle-outline
```tsx
import { Toggle } from "@workspace/ui/components/toggle";
import { BoldIcon, ItalicIcon } from "lucide-react";
export function ToggleOutline() {
return (
Italic
Bold
);
}
export default ToggleOutline;
```
## With Text
### Example: toggle-text
```tsx
import { Toggle } from "@workspace/ui/components/toggle";
import { ItalicIcon } from "lucide-react";
export function ToggleText() {
return (
Italic
);
}
export default ToggleText;
```
## Size
Use the `size` prop to change the size of the toggle.
### Example: toggle-sizes
```tsx
import { Toggle } from "@workspace/ui/components/toggle";
export function ToggleSizes() {
return (
Small
Default
Large
);
}
export default ToggleSizes;
```
## Disabled
### Example: toggle-disabled
```tsx
import { Toggle } from "@workspace/ui/components/toggle";
export function ToggleDisabled() {
return (
Disabled
Disabled
);
}
export default ToggleDisabled;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: toggle-rtl
```tsx
"use client";
import { Toggle } from "@workspace/ui/components/toggle";
import { BookmarkIcon } from "lucide-react";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
label: "Bookmark",
},
},
ar: {
dir: "rtl",
values: {
label: "إشارة مرجعية",
},
},
he: {
dir: "rtl",
values: {
label: "סימנייה",
},
},
};
export function ToggleRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.label}
);
}
export default ToggleRtl;
```
## API Reference
See the [Base UI Toggle](https://base-ui.com/react/components/toggle#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/toggle)
- [API reference](https://base-ui.com/react/components/toggle#api-reference)
---
# Toggle Group
A set of two-state buttons that can be toggled on or off.
Page: https://sui.draco.dev/docs/components/toggle-group
### Example: toggle-group-demo
```tsx
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import { Bold, Italic, Underline } from "lucide-react";
export function ToggleGroupDemo() {
return (
);
}
export default ToggleGroupDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/toggle-group
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx
import { ToggleGroup, ToggleGroupItem } from "@workspace/ui/components/toggle-group"
```
```tsx
A
B
C
```
## Composition
Use the following composition to build a `ToggleGroup`:
```text
ToggleGroup
├── ToggleGroupItem
└── ToggleGroupItem
```
## Outline
Use `variant="outline"` for an outline style.
### Example: toggle-group-outline
```tsx
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
export function ToggleGroupOutline() {
return (
All
Missed
);
}
export default ToggleGroupOutline;
```
## Size
Use the `size` prop to change the size of the toggle group.
### Example: toggle-group-sizes
```tsx
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
export function ToggleGroupSizes() {
return (
Top
Bottom
Left
Right
Top
Bottom
Left
Right
);
}
export default ToggleGroupSizes;
```
## Spacing
Use `spacing` to add spacing between toggle group items.
### Example: toggle-group-spacing
```tsx
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
export function ToggleGroupSpacing() {
return (
Top
Bottom
Left
Right
);
}
export default ToggleGroupSpacing;
```
## Vertical
Use `orientation="vertical"` for vertical toggle groups.
### Example: toggle-group-vertical
```tsx
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react";
export function ToggleGroupVertical() {
return (
);
}
export default ToggleGroupVertical;
```
## Disabled
### Example: toggle-group-disabled
```tsx
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import { Bold, Italic, Underline } from "lucide-react";
export function ToggleGroupDisabled() {
return (
);
}
export default ToggleGroupDisabled;
```
## Custom
A custom toggle group example.
### Example: toggle-group-font-weight-selector
```tsx
"use client";
import {
Field,
FieldDescription,
FieldLabel,
} from "@workspace/ui/components/field";
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import * as React from "react";
export function ToggleGroupFontWeightSelector() {
const [fontWeight, setFontWeight] = React.useState("normal");
return (
Font Weight
setFontWeight(value[0])}
variant="outline"
spacing={2}
size="lg"
>
Aa
Light
Aa
Normal
Aa
Medium
Aa
Bold
Use{" "}
font-{fontWeight}
{" "}
to set the font weight.
);
}
export default ToggleGroupFontWeightSelector;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: toggle-group-rtl
```tsx
"use client";
import {
ToggleGroup,
ToggleGroupItem,
} from "@workspace/ui/components/toggle-group";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
list: "List",
grid: "Grid",
cards: "Cards",
},
},
ar: {
dir: "rtl",
values: {
list: "قائمة",
grid: "شبكة",
cards: "بطاقات",
},
},
he: {
dir: "rtl",
values: {
list: "רשימה",
grid: "רשת",
cards: "כרטיסים",
},
},
};
export function ToggleGroupRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{t.list}
{t.grid}
{t.cards}
);
}
export default ToggleGroupRtl;
```
## API Reference
See the [Base UI Toggle Group](https://base-ui.com/react/components/toggle-group#api-reference) documentation.
## Changelog
### 2026-05-17 Default Spacing
Changed the default `spacing` from `0` to `2` so toggle groups render with space between items by default. Use `spacing={0}` for connected items.
- [Documentation](https://base-ui.com/react/components/toggle-group)
- [API reference](https://base-ui.com/react/components/toggle-group#api-reference)
---
# Toolbar
Groups related commands and controls with arrow-key focus navigation.
Page: https://sui.draco.dev/docs/components/toolbar
Toolbar gives a set of actions a shared keyboard navigation model. Compose buttons, toggle groups, links, and a final input while keeping each action's own accessible name.
### Example: toolbar-demo
```tsx
import { Toggle } from "@workspace/ui/components/toggle";
import { ToggleGroup } from "@workspace/ui/components/toggle-group";
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarLink,
ToolbarSeparator,
} from "@workspace/ui/components/toolbar";
import { cn } from "cn";
import {
BoldIcon,
ItalicIcon,
RotateCcwIcon,
UnderlineIcon,
} from "lucide-react";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function ToolbarDemo({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const [formats, setFormats] = useState([]);
return (
}
value="bold"
size="icon"
aria-label={chinese ? "加粗" : "Bold"}
>
}
value="italic"
size="icon"
aria-label={chinese ? "斜体" : "Italic"}
>
}
value="underline"
size="icon"
aria-label={chinese ? "下划线" : "Underline"}
>
setFormats([])}
>
{chinese ? "帮助" : "Help"}
{chinese
? "选中文字格式,预览会即时更新"
: "Choose a text format to update this preview."}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/toolbar
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarSeparator,
} from "@workspace/ui/components/toolbar"
Undo
Redo
Save
```
## Toggle and trigger composition
`ToolbarButton` supports the same `variant` and `size` values as `Button`, defaulting to `ghost` and `default`. Use `render` to compose it with `Toggle`, a menu trigger, or another Base UI control. `ToggleGroup` manages related format choices, while the toolbar coordinates focus among controls.
Use `ToolbarGroup` for related actions and `ToolbarSeparator` between groups. A separator automatically uses the orientation opposite to its toolbar.
## Search input
`ToolbarInput` participates in the toolbar's keyboard model. In a horizontal toolbar, place a single input last so text editing and horizontal focus movement stay predictable. The example filters real notes and moves between the matching results.
### Example: toolbar-search
```tsx
import {
Toolbar,
ToolbarButton,
ToolbarInput,
ToolbarSeparator,
} from "@workspace/ui/components/toolbar";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function ToolbarSearch({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const noMatchesLabel = chinese ? "没有匹配的笔记" : "No matching notes.";
const [query, setQuery] = useState("");
const [position, setPosition] = useState(0);
const notes = chinese
? ["检查键盘交互", "调整组件主题", "更新双语文档", "发布组件示例"]
: [
"Check keyboard interactions",
"Customize component themes",
"Update bilingual documentation",
"Publish component examples",
];
const filtered = notes.filter((note) =>
note.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
);
const selected = filtered.length ? position % filtered.length : 0;
return (
setPosition(
(current) => (current + filtered.length - 1) % filtered.length,
)
}
>
setPosition((current) => (current + 1) % filtered.length)
}
>
{
setQuery(event.target.value);
setPosition(0);
}}
/>
{filtered.length
? `${selected + 1} / ${filtered.length}: ${filtered[selected]}`
: noMatchesLabel}
);
}
```
## Vertical and disabled toolbars
Set `orientation="vertical"` to use a vertical layout and Up/Down focus navigation. `disabled` on `Toolbar` disables button and input controls; `disabled` on `ToolbarGroup` disables a set of actions. Buttons can also be disabled independently.
### Example: toolbar-disabled
```tsx
import { Field, FieldLabel } from "@workspace/ui/components/field";
import { Switch } from "@workspace/ui/components/switch";
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarSeparator,
} from "@workspace/ui/components/toolbar";
import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react";
import { useId, useState } from "react";
import type { ExampleProps } from "../types";
export default function ToolbarDisabled({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
const id = useId();
const [disabled, setDisabled] = useState(true);
const [zoom, setZoom] = useState(100);
return (
{chinese ? "禁用工具栏" : "Disable toolbar"}
setZoom((value) => Math.min(200, value + 10))}
disabled={zoom >= 200}
>
setZoom((value) => Math.max(50, value - 10))}
disabled={zoom <= 50}
>
setZoom(100)}
>
{chinese ? "缩放" : "Zoom"}: {zoom}%
);
}
```
## Accessibility and keyboard
Give the toolbar an `aria-label` describing its purpose. Label each icon button, and label groups when their purpose adds useful context. Tab enters the toolbar, arrow keys move between its controls, and Tab leaves it. Focus wraps by default; set `loopFocus={false}` to stop at the ends.
Base UI disabled buttons remain focusable by default. Use `focusableWhenDisabled={false}` when disabled controls should be skipped. Keep native event handlers and the `render` composition intact so keyboard activation follows the same path as pointer interaction.
## API reference
| Part | Purpose |
| --- | --- |
| `Toolbar` | Accepts `orientation` (`horizontal` by default), `disabled`, `loopFocus`, normal root props, and `ref`. |
| `ToolbarButton` | A toolbar action with `variant`, `size`, `disabled`, `focusableWhenDisabled`, and `render`. |
| `ToolbarLink` | A navigable link with Button `variant`/`size`, anchor props, and `render`. |
| `ToolbarInput` | A text input integrated with toolbar focus navigation, styled through `Input`. |
| `ToolbarGroup` | Groups related controls and accepts a group-level `disabled` state. |
| `ToolbarSeparator` | Separates groups; its direction follows the toolbar unless explicitly overridden. |
See the [Base UI Toolbar API](https://base-ui.com/react/components/toolbar#api-reference) for all primitive props and keyboard behavior.
---
# Tooltip
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
Page: https://sui.draco.dev/docs/components/tooltip
### Example: tooltip-demo
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
export function TooltipDemo() {
return (
}>
Hover
Add to library
);
}
export default TooltipDemo;
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/tooltip
```
Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.
## Usage
```tsx showLineNumbers
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
```
```tsx showLineNumbers
Hover
Add to library
```
## Composition
Use the following composition to build a `Tooltip`:
```text
Tooltip
├── TooltipTrigger
└── TooltipContent
```
## Side
Use the `side` prop to change the position of the tooltip.
### Example: tooltip-sides
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
export function TooltipSides() {
return (
{(["left", "top", "bottom", "right"] as const).map((side) => (
}
>
{side}
Add to library
))}
);
}
export default TooltipSides;
```
## With Keyboard Shortcut
### Example: tooltip-keyboard
```tsx
import { Button } from "@workspace/ui/components/button";
import { Kbd } from "@workspace/ui/components/kbd";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { SaveIcon } from "lucide-react";
export function TooltipKeyboard() {
return (
}>
Save Changes S
);
}
export default TooltipKeyboard;
```
## Disabled Button
Show a tooltip on a disabled button by wrapping it with a span.
### Example: tooltip-disabled
```tsx
import { Button } from "@workspace/ui/components/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
export function TooltipDisabled() {
return (
}>
Disabled
This feature is currently unavailable
);
}
export default TooltipDisabled;
```
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](https://ui.shadcn.com/docs/rtl).
### Example: tooltip-rtl
```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { type Translations, useTranslation } from "./support";
const translations: Translations = {
en: {
dir: "ltr",
values: {
content: "Add to library",
"inline-start": "Inline Start",
left: "Left",
top: "Top",
bottom: "Bottom",
right: "Right",
"inline-end": "Inline End",
},
},
ar: {
dir: "rtl",
values: {
content: "إضافة إلى المكتبة",
"inline-start": "بداية السطر",
left: "يسار",
top: "أعلى",
bottom: "أسفل",
right: "يمين",
"inline-end": "نهاية السطر",
},
},
he: {
dir: "rtl",
values: {
content: "הוסף לספרייה",
"inline-start": "תחילת השורה",
left: "שמאל",
top: "למעלה",
bottom: "למטה",
right: "ימין",
"inline-end": "סוף השורה",
},
},
};
const physicalSides = ["left", "top", "bottom", "right"] as const;
const logicalSides = ["inline-start", "inline-end"] as const;
export function TooltipRtl() {
const { dir, t } = useTranslation(translations, "ar");
return (
{physicalSides.map((side) => (
}>
{t[side]}
{t.content}
))}
{logicalSides.map((side) => (
}>
{t[side]}
{t.content}
))}
);
}
export default TooltipRtl;
```
## API Reference
See the [Base UI Tooltip](https://base-ui.com/react/components/tooltip#api-reference) documentation.
- [Documentation](https://base-ui.com/react/components/tooltip)
- [API reference](https://base-ui.com/react/components/tooltip#api-reference)
---
# DataTable
A shared table block with search, filters, column settings, selection, pagination, and drag sorting.
Page: https://sui.draco.dev/docs/blocks/data-table
DataTable composes SUI's [Table](/docs/components/table), Input, Combobox, Checkbox, Badge, Empty, Skeleton, and Pagination. Use the Table component directly for simple markup.
## Search, filters, sorting, and column controls
Search uses column metadata and the Search button or Enter. Column settings support visibility, pinning, and ordering. Density changes row spacing. Open a sortable header menu to choose ascending, descending, clear sorting, or hide the column.
### Example: block-table-demo
```tsx
import {
DataTable,
type DataTableColumnDef,
DataTableColumnSettings,
DataTableDensity,
DataTableSearch,
} from "@workspace/ui/blocks/data-table";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type RecordRow = { id: string; name: string; status: string; amount: number };
const rows: RecordRow[] = Array.from({ length: 24 }, (_, index) => ({
id: `R-${String(index + 1).padStart(3, "0")}`,
name: ["Orion", "Northwind", "Atlas", "Nimbus"][index % 4],
status: index % 3 === 0 ? "Draft" : "Active",
amount: (index + 1) * 125,
}));
export default function TableBlockDemo({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID", meta: { pinned: "start" } },
{
accessorKey: "name",
header: zh ? "名称" : "Name",
meta: { search: { placeholder: zh ? "搜索名称" : "Search names" } },
},
{
accessorKey: "status",
header: zh ? "状态" : "Status",
meta: {
filter: {
multiple: true,
placeholder: zh ? "筛选状态" : "Filter status",
options: [
{ label: zh ? "草稿" : "Draft", value: "Draft" },
{ label: zh ? "启用" : "Active", value: "Active" },
],
},
},
},
{
accessorKey: "amount",
header: zh ? "金额" : "Amount",
cell: ({ getValue }) => `$${Number(getValue()).toFixed(2)}`,
meta: { align: "end" },
},
];
return (
{({
content,
table,
tableSize,
onTableSizeChange,
labels,
loading,
defaultColumnOrder,
defaultColumnPinning,
}) => (
<>
{content}
>
)}
);
}
```
## Free composition and independent controls
Refresh, column settings, and density are independently exported block controls. The table has no business header slot. Compose `DataTableRefresh`, `DataTableColumnSettings`, `DataTableDensity`, and `DataTableSearch` in the `children` render callback. It provides the table, density and setter, refresh callback, loading state, labels, default column settings, and `content`. The content includes column headers, rows, loading states, pagination, and bulk actions; arrange your title, search, and controls around it.
### Example: block-table-header
```tsx
import {
DataTable,
type DataTableColumnDef,
DataTableColumnSettings,
DataTableDensity,
DataTableRefresh,
DataTableSearch,
type DataTableState,
} from "@workspace/ui/blocks/data-table";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type Project = { id: string; name: string; amount: number };
const records: Project[] = Array.from({ length: 18 }, (_, index) => ({
id: `P-${index + 1}`,
name: `Project ${index + 1}`,
amount: (index + 1) * 125,
}));
async function request(state: DataTableState) {
await new Promise((resolve) => setTimeout(resolve, 350));
const query = String(
state.columnFilters.find((filter) => filter.id === "name")?.value ?? "",
).toLowerCase();
const rows = records.filter((row) => row.name.toLowerCase().includes(query));
const sort = state.sorting[0];
if (sort)
rows.sort(
(a, b) =>
String(a[sort.id as keyof Project]).localeCompare(
String(b[sort.id as keyof Project]),
undefined,
{ numeric: true },
) * (sort.desc ? -1 : 1),
);
const start = state.pagination.pageIndex * state.pagination.pageSize;
return {
data: rows.slice(start, start + state.pagination.pageSize),
total: rows.length,
};
}
export default function TableHeader({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [message, setMessage] = useState("");
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID", meta: { pinned: "start" } },
{
accessorKey: "name",
header: zh ? "项目名称" : "Project name",
meta: {
search: { placeholder: zh ? "搜索项目名称" : "Search Project name" },
},
},
{
accessorKey: "amount",
header: zh ? "金额" : "Amount",
cell: ({ getValue }) => `$${Number(getValue()).toFixed(2)}`,
meta: { align: "end" },
},
];
return (
{({
content,
table,
tableSize,
onTableSizeChange,
refresh,
loading,
labels,
defaultColumnOrder,
defaultColumnPinning,
}) => (
<>
{zh ? "项目列表" : "Projects"}
{table.getRowCount()}
{refresh && (
)}
setMessage(
zh
? "新建操作由应用处理"
: "The app handles the create action",
)
}
>
{zh ? "新建项目" : "New project"}
{content}
>
)}
{message ||
(zh
? "刷新放在标题旁,搜索与操作按钮同一行,窄屏自动换行。"
: "Refresh sits next to the title; search and app actions share the row and wrap on narrow screens.")}
);
}
```
## Search placement and layout
`DataTableSearch` can share a row with a title or actions, occupy its own row, or appear after `content`. Use `className` for placement and width. `layout="inline"` keeps inputs, filters, and actions together with wrapping; the default `layout="stacked"` retains stacked controls on narrow screens and separates fields from actions on desktop.
The basic example uses a separate search row, and the composition example places it next to the title. Here search and status filters follow the table and pagination, aligned with `className="justify-end"`. Editing conditions changes the draft; Search or Enter applies it, and Reset clears it.
### Example: block-table-search
```tsx
import {
DataTable,
type DataTableColumnDef,
DataTableSearch,
} from "@workspace/ui/blocks/data-table";
import { Badge } from "@workspace/ui/components/badge";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type Project = { id: string; name: string; status: string };
const records: Project[] = Array.from({ length: 8 }, (_, index) => ({
id: `R-${index + 1}`,
name: ["Orion", "Northwind", "Atlas", "Nimbus"][index % 4],
status: index < 4 ? "Active" : "Draft",
}));
export default function TableSearch({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = {
active: zh ? "启用" : "Active",
draft: zh ? "草稿" : "Draft",
};
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID" },
{
accessorKey: "name",
header: zh ? "名称" : "Name",
meta: { search: { placeholder: zh ? "搜索名称" : "Search names" } },
},
{
accessorKey: "status",
header: zh ? "状态" : "Status",
cell: ({ getValue }) => (
{getValue() === "Active" ? stateLabels.active : stateLabels.draft}
),
meta: {
filter: {
multiple: true,
placeholder: zh ? "筛选状态" : "Filter status",
options: [
{ value: "Active", label: zh ? "启用" : "Active" },
{ value: "Draft", label: zh ? "草稿" : "Draft" },
],
},
},
},
];
return (
{({ content, table, loading, labels }) => (
<>
{content}
>
)}
);
}
```
## Selection, row actions, and bulk actions
Add a `select` column using SUI Checkbox. The `bulkToolbar` callback receives selected rows and the table instance. Utility columns `select` and `drag` pin to the start; `actions` and `operation` pin to the end.
### Example: block-table-selection
```tsx
import {
DataTable,
type DataTableColumnDef,
} from "@workspace/ui/blocks/data-table";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type Member = { id: string; name: string; email: string };
const members: Member[] = [
{ id: "1", name: "Alex Chen", email: "alex@example.com" },
{ id: "2", name: "Sam Rivera", email: "sam@example.com" },
{ id: "3", name: "Jordan Lee", email: "jordan@example.com" },
];
export default function TableSelection({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [message, setMessage] = useState("");
const columns: DataTableColumnDef[] = [
{
id: "select",
header: ({ table }) => (
table.toggleAllPageRowsSelected(value)}
/>
),
cell: ({ row }) => (
row.toggleSelected(value)}
/>
),
enableSorting: false,
},
{ accessorKey: "name", header: zh ? "姓名" : "Name" },
{ accessorKey: "email", header: zh ? "邮箱" : "Email" },
{
id: "actions",
header: zh ? "操作" : "Actions",
cell: ({ row }) => (
setMessage(`${zh ? "已查看" : "Viewed"} ${row.original.name}`)
}
>
{zh ? "查看" : "View"}
),
},
];
return (
(
{
setMessage(
`${zh ? "已导出" : "Exported"} ${selectedRows.length}`,
);
table.resetRowSelection();
}}
>
{zh ? "导出所选" : "Export selected"}
)}
labels={
zh
? {
...chineseTableLabels,
bulkClearSelection: "清除选择",
bulkAnnouncement: (count) => `已选择 ${count} 行`,
}
: undefined
}
/>
{message}
);
}
```
## Pinned columns, grouped headers, and alignment
Combine selection, long text, centered status, end-aligned amounts, and row actions. Try density settings and drag mode. `layout="full"` scrolls within a constrained height; grouped headers stick at their measured offsets and pinned cells share the row's hover and selection surface.
This example has two header levels: Project groups ID and Domain, while Details groups Status and Amount. The upper row names the groups and the lower row names the columns. Flat column definitions produce a single header row.
### Example: block-table-layout
```tsx
import {
DataTable,
type DataTableColumnDef,
DataTableColumnSettings,
DataTableDensity,
} from "@workspace/ui/blocks/data-table";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import { LongText } from "@workspace/ui/components/long-text";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type Project = { id: string; name: string; status: string; amount: number };
const initialRows: Project[] = Array.from({ length: 18 }, (_, index) => ({
id: String(index + 1),
name: `production-api-gateway-${index + 1}.asia-east-1.example.com`,
status: index % 3 === 0 ? "Draft" : "Active",
amount: (index + 1) * 125,
}));
export default function TableLayout({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
draft: "草稿",
active: "启用",
disableDrag: "关闭拖拽",
enableDrag: "启用拖拽",
}
: {
draft: "Draft",
active: "Active",
disableDrag: "Disable drag",
enableDrag: "Enable drag",
};
const [rows, setRows] = useState(initialRows);
const [drag, setDrag] = useState(false);
const [message, setMessage] = useState("");
const columns: DataTableColumnDef[] = [
{
id: "select",
header: ({ table }) => (
table.toggleAllPageRowsSelected(checked)
}
/>
),
cell: ({ row }) => (
row.toggleSelected(checked)}
/>
),
enableSorting: false,
},
{
id: "project",
header: zh ? "项目" : "Project",
meta: { align: "center" },
columns: [
{
accessorKey: "id",
header: "ID",
size: 64,
meta: { pinned: "start", align: "center" },
},
{
accessorKey: "name",
header: zh ? "域名" : "Domain",
cell: ({ getValue }) => (
{String(getValue())}
),
},
],
},
{
id: "details",
header: zh ? "详情" : "Details",
meta: { align: "center" },
columns: [
{
accessorKey: "status",
header: zh ? "状态" : "Status",
cell: ({ getValue }) => (
{getValue() === "Draft" ? stateLabels.draft : stateLabels.active}
),
meta: { align: "center" },
},
{
accessorKey: "amount",
header: zh ? "金额" : "Amount",
cell: ({ getValue }) => (
${Number(getValue()).toFixed(2)}
),
meta: { align: "end" },
},
],
},
{
id: "actions",
header: zh ? "操作" : "Actions",
size: 96,
cell: ({ row }) => (
setMessage(`${zh ? "已查看" : "Viewed"} ${row.original.id}`)
}
>
{zh ? "查看" : "View"}
),
},
];
return (
(
{
setMessage(
`${zh ? "已导出" : "Exported"} ${selectedRows.length}`,
);
table.resetRowSelection();
}}
>
{zh ? "导出所选" : "Export selected"}
)}
labels={
zh
? {
...chineseTableLabels,
bulkClearSelection: "清除选择",
}
: undefined
}
>
{({
content,
table,
tableSize,
onTableSizeChange,
labels,
loading,
defaultColumnOrder,
defaultColumnPinning,
}) => (
<>
setDrag((value) => !value)}
>
{drag ? stateLabels.disableDrag : stateLabels.enableDrag}
{content}
>
)}
{message ||
(zh
? "横向滚动检查固定列,纵向滚动检查分组表头。"
: "Scroll horizontally to check pinned columns and vertically to check grouped headers.")}
);
}
```
## Drag sorting and empty state
Supply stable row IDs and handle the reordered records. Drag sorting disables sorting and pagination to keep displayed order consistent. The handle also supports keyboard dragging: Space to pick up, arrow keys to move, and Space to drop.
### Example: block-table-drag
```tsx
import {
DataTable,
type DataTableColumnDef,
} from "@workspace/ui/blocks/data-table";
import { Button } from "@workspace/ui/components/button";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type Task = { id: string; name: string };
const initialRows: Task[] = [
{ id: "1", name: "Design tokens" },
{ id: "2", name: "Component library" },
{ id: "3", name: "Documentation" },
{ id: "4", name: "Release checklist" },
];
export default function TableDrag({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
empty: "显示空状态",
restore: "恢复数据",
}
: {
empty: "Show empty state",
restore: "Restore rows",
};
const [rows, setRows] = useState(initialRows);
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: zh ? "任务" : "Task" },
];
return (
setRows((current) => (current.length ? [] : initialRows))
}
>
{rows.length ? stateLabels.empty : stateLabels.restore}
{rows.map((row) => row.name).join(" → ")}
);
}
```
## Requested data
Provide a stable `request` callback returning `{ data, total }`. The callback receives pagination, sorting, and column filters; it applies those operations on the server. Late responses from previous requests are ignored. This example uses a local asynchronous adapter. Without `total`, pagination uses sequential controls and stops after a short page. Compose `DataTableRefresh` in the header to reload requested data. Request failures offer retry.
### Example: block-table-request
```tsx
import {
DataTable,
type DataTableColumnDef,
DataTableRefresh,
DataTableSearch,
type DataTableState,
} from "@workspace/ui/blocks/data-table";
import { Button } from "@workspace/ui/components/button";
import { useCallback, useState } from "react";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type RecordRow = { id: string; name: string };
const records: RecordRow[] = Array.from({ length: 28 }, (_, index) => ({
id: `R-${index + 1}`,
name: `Project ${index + 1}`,
}));
// Replace this local adapter with your API; pagination and sorting run in the request.
async function request(state: DataTableState) {
const query = String(
state.columnFilters.find((filter) => filter.id === "name")?.value ?? "",
).toLowerCase();
const rows = records.filter((row) => row.name.toLowerCase().includes(query));
const sort = state.sorting[0];
if (sort)
rows.sort(
(a, b) =>
String(a[sort.id as keyof RecordRow]).localeCompare(
String(b[sort.id as keyof RecordRow]),
undefined,
{ numeric: true },
) * (sort.desc ? -1 : 1),
);
const start = state.pagination.pageIndex * state.pagination.pageSize;
return {
data: rows.slice(start, start + state.pagination.pageSize),
total: rows.length,
};
}
export default function TableRequest({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const stateLabels = zh
? {
includeTotal: "返回总数",
omitTotal: "省略总数",
}
: {
includeTotal: "Include total",
omitTotal: "Omit total",
};
const [unknownTotal, setUnknownTotal] = useState(false);
const load = useCallback(
async (state: DataTableState) => {
await new Promise((resolve) => setTimeout(resolve, 250));
const result = await request(state);
return unknownTotal ? { data: result.data } : result;
},
[unknownTotal],
);
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID" },
{
accessorKey: "name",
header: zh ? "项目" : "Project",
meta: { search: { placeholder: zh ? "搜索项目" : "Search projects" } },
},
];
return (
setUnknownTotal((value) => !value)}
>
{unknownTotal ? stateLabels.includeTotal : stateLabels.omitTotal}
{({ content, table, refresh, loading, labels }) => (
<>
{content}
>
)}
);
}
```
## Loading, empty, and failed requests
First load uses SUI Skeleton. Existing rows remain under a centered loading overlay; empty and failed requests use SUI Empty. Override messages through `labels`.
### Example: block-table-states
```tsx
import {
DataTable,
type DataTableColumnDef,
} from "@workspace/ui/blocks/data-table";
import { Button } from "@workspace/ui/components/button";
import { useCallback, useRef, useState } from "react";
import type { ExampleProps } from "../types";
import { chineseTableLabels } from "./block-table-labels";
type RecordRow = { id: string; name: string };
export default function TableStates({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [fail, setFail] = useState(false);
const failOnce = useRef(true);
const request = useCallback(async () => {
if (failOnce.current) {
failOnce.current = false;
throw new Error("Demo request failed");
}
return { data: [{ id: "1", name: "Recovered project" }] };
}, []);
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: zh ? "名称" : "Name" },
];
return (
{
failOnce.current = true;
setFail((value) => !value);
}}
>
{zh ? "切换空状态 / 加载失败" : "Toggle empty / failed request"}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/data-table
```
Install this Block with the shadcn CLI command above. Follow the [installation guide](/docs/installation) for registry, dependency, and style setup. The examples below use workspace imports; registry-installed source uses the aliases configured in the consuming application's `components.json`.
## Usage
```tsx
import { DataTable, DataTableSearch, type DataTableColumnDef } from "@workspace/ui/blocks/data-table"
type Project = { id: string; name: string }
const columns: DataTableColumnDef[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Name", meta: { search: true } },
]
{({ table, loading, labels, content }) => (
<>
{content}
>
)}
```
## API
| Prop | Description |
| --- | --- |
| `columns`, `data` | TanStack Table v9 columns and local records. |
| `rowKey` | A record key or function returning a stable ID. Required for reliable selection and dragging. |
| `request` | Receives `DataTableState`, returns `{ data, total? }` synchronously or asynchronously. |
| `initialState`, `onChange` | Initial pagination, sorting, and column filters, and change notifications. |
| `children` | Render callback receiving table state and `content` for caller-owned layout. Omit it to render just the table content. |
| `bulkToolbar` | Content or render callbacks receiving `DataTableRenderContext` with table, rows, selection, density and setter, refresh, loading, labels, and default column settings. |
| `pagination` | Set `false` to show all local rows. |
| `dragSort` | `{ rowKey, onDragSortEnd }`; consumers store the resulting order. |
| `loading` | Boolean or `{ rows }` for first-load skeleton count; existing rows use a centered loading overlay. |
| `layout` | `auto` for content height; `full` for a constrained application container. |
| `table` | `{ stickyHeader, manual, pinning }`; `manual` skips local filtering and pagination, while sorting stays local. |
| `labels`, `label` | Partial `TableLabels` overrides and the accessible table name. Controls default to English; translations are supplied by your application. |
Column metadata supports `search`, `filter: { options, multiple, onFilter }`, `align`, and `pinned`. Use `useDataTableUrlState` to connect pagination, sorting, and filters to your router's search parameters without importing a router into the UI package.
## Composition
The shared block also exports `DataTableRefresh`, `DataTableColumnSettings`, `DataTableDensity`, `DataTableColumnHeader`, `DataTableFacetedFilter`, `DataTableSearch`, `DataTablePagination`, and `DataTableBulkActions`. They accept the typed table or column instance from `@workspace/ui/blocks/data-table/types`.
`DataTableDensity` uses controlled `value`/`onValueChange`; `DataTableRefresh` uses `onRefresh`/`loading`.
Their separation follows the [shadcn-admin data-table patterns](https://github.com/satnaing/shadcn-admin/tree/main/src/components/data-table), adapted to SUI Base UI components and TanStack Table v9.
### Application translations
The block ships English defaults only and has no locale or i18n dependency. Keep translations in your application and pass a partial `TableLabels` object through `labels`. Function labels such as `searchPlaceholder`, `paginationPage`, `paginationTotalRows`, and `bulkAnnouncement` let your application handle interpolation and plural rules. Pass the resolved `labels` from the render callback to composed controls so they use the same translations.
```tsx
t("table.page", { page }) }}
/>
```
---
# Delete Resource
Resource name confirmation with asynchronous deletion, pending state, and retryable errors.
Page: https://sui.draco.dev/docs/blocks/delete-resource
Inspired by [Kumo Delete Resource](https://kumo-ui.com/blocks/delete-resource/), this block composes SUI AlertDialog, Field, Input, and Button. It requires the resource name before invoking the deletion callback.
## Name confirmation
Open the dialog and type `sui-preview`. The destructive action enables only when the input matches. Deletion keeps controls disabled until the callback finishes. The resource name in the confirmation hint has an inline copy action. Copying does not populate the confirmation field or submit the form; it is disabled during deletion.
### Example: block-delete-resource
```tsx
import { DeleteResource } from "@workspace/ui/blocks/delete-resource";
import { Button } from "@workspace/ui/components/button";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function DeleteResourceDemo({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(false);
const [deleted, setDeleted] = useState(false);
return (
{
setDeleted(false);
setOpen(true);
}}
>
{zh ? "删除项目" : "Delete project"}
{
await new Promise((resolve) => setTimeout(resolve, 600));
setDeleted(true);
}}
labels={
zh
? {
title: "删除项目?",
description: "将永久删除 sui-preview,此操作无法撤销。",
confirmation: "项目名称",
copy: "复制资源名称",
copying: "正在复制…",
copied: "已复制",
copyFailed: "复制失败,请选择名称手动复制。",
hint: "输入 sui-preview 确认删除。",
delete: "删除项目",
cancel: "取消",
deleting: "正在删除…",
}
: undefined
}
/>
{deleted && (
{zh ? "项目已删除" : "Project deleted"}
)}
);
}
```
## Failure and retry
The first attempt fails in this example. The dialog preserves the confirmation, displays the error, and allows a second attempt. Reopening clears the previous confirmation and error.
### Example: block-delete-resource-error
```tsx
import { DeleteResource } from "@workspace/ui/blocks/delete-resource";
import { Button } from "@workspace/ui/components/button";
import { useRef, useState } from "react";
import type { ExampleProps } from "../types";
export default function DeleteResourceError({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(false);
const attempts = useRef(0);
return (
<>
{
attempts.current = 0;
setOpen(true);
}}
>
{zh ? "删除并重试" : "Delete with retry"}
{
await new Promise((resolve) => setTimeout(resolve, 600));
attempts.current += 1;
if (attempts.current === 1)
throw new Error(
zh
? "暂时无法删除。请重试。"
: "Unable to delete right now. Please try again.",
);
}}
labels={
zh
? {
title: "删除 Worker?",
description: "将永久删除 api-gateway,此操作无法撤销。",
confirmation: "Worker 名称",
copy: "复制资源名称",
copying: "正在复制…",
copied: "已复制",
copyFailed: "复制失败,请选择名称手动复制。",
hint: "输入 api-gateway 确认删除。",
delete: "删除 Worker",
cancel: "取消",
deleting: "正在删除…",
}
: undefined
}
/>
>
);
}
```
## Case-insensitive confirmation
Set `caseSensitive={false}` when confirmation should ignore letter case.
### Example: block-delete-resource-insensitive
```tsx
import { DeleteResource } from "@workspace/ui/blocks/delete-resource";
import { Button } from "@workspace/ui/components/button";
import { useState } from "react";
import type { ExampleProps } from "../types";
export default function DeleteResourceInsensitive({ locale }: ExampleProps) {
const zh = locale === "zh-CN";
const [open, setOpen] = useState(false);
return (
<>
setOpen(true)}>
{zh ? "删除域名" : "Delete domain"}
{}}
labels={
zh
? {
title: "删除域名?",
description: "将永久删除 Example.com,此操作无法撤销。",
confirmation: "域名",
copy: "复制资源名称",
copying: "正在复制…",
copied: "已复制",
copyFailed: "复制失败,请选择名称手动复制。",
hint: "输入 Example.com 确认,不区分大小写。",
delete: "删除域名",
cancel: "取消",
}
: { hint: "Type Example.com to confirm (case insensitive)." }
}
/>
>
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/delete-resource
```
Install this Block with the shadcn CLI command above. Follow the [installation guide](/docs/installation) for registry, dependency, and style setup. The examples below use workspace imports; registry-installed source uses the aliases configured in the consuming application's `components.json`.
## Usage
```tsx
import { DeleteResource } from "@workspace/ui/blocks/delete-resource"
{ await deleteProject("sui-preview") }}
/>
```
The block keeps English defaults. Resolve translations in your application and pass them through `labels`; translate any callback error messages there too.
## API
| Prop | Default | Description |
| --- | --- | --- |
| `open`, `onOpenChange` | Required | Controlled visibility. |
| `resourceType`, `resourceName` | Required | Resource identity and confirmation target. Empty names cannot be confirmed. |
| `onDelete` | Required | Sync or async deletion callback. Resolve on success; throw on failure. |
| `caseSensitive` | `true` | Whether confirmation distinguishes case. Whitespace must match exactly. |
| `isDeleting` | `false` | Optional external pending state; combined with internal submission state. |
| `errorMessage` | — | Optional error from the application. |
| `description` | Generated | Custom explanatory content. |
| `labels` | English | Override title, description, confirmation, hint, cancel, delete, deleting, failed, copy, copying, copied, and copyFailed text. |
| `className` | — | Dialog layout classes. |
On success the block calls `onOpenChange(false)`. On failure it stays open. Duplicate submission and closing are blocked while deletion is pending. The application owns the actual delete operation; these previews only simulate it.
---
# TanStack Form
Validated report and profile forms composed from SUI fields and TanStack Form.
Page: https://sui.draco.dev/docs/blocks/tanstack-form
These shared blocks follow the [shadcn TanStack Form guide](https://ui.shadcn.com/docs/forms/tanstack-form), using SUI FieldGroup, Field, Input, Select, Checkbox, and Zod schemas. Validation runs on blur and submit. Invalid controls expose `aria-invalid` and associate errors with their fields.
## Bug report
Try submitting an empty report, then enter a title of 5–80 characters and a description of 20–500 characters. Successful submission displays feedback. Reset restores the initial values.
### Example: block-form-report
```tsx
import { BugReportForm } from "@workspace/ui/blocks/tanstack-form";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseBugReportFormLabels } from "./block-form-labels";
export default function ReportForm({ locale }: ExampleProps) {
const [submitted, setSubmitted] = useState("");
return (
{
await new Promise((resolve) => setTimeout(resolve, 600));
setSubmitted(values.title);
}}
/>
{submitted && (
{submitted}
)}
);
}
```
## Profile, select, and checkbox
This composition adds email validation, a role selector, and notification preferences. Toggle the failure simulation to check retry behavior.
### Example: block-form-profile
```tsx
import { ProfileForm } from "@workspace/ui/blocks/tanstack-form";
import { Button } from "@workspace/ui/components/button";
import { useState } from "react";
import type { ExampleProps } from "../types";
import { chineseProfileFormLabels } from "./block-form-labels";
export default function ProfileFormDemo({ locale }: ExampleProps) {
const [fail, setFail] = useState(false);
const zh = locale === "zh-CN";
const stateLabels = zh
? {
disableError: "关闭失败模拟",
simulateError: "模拟提交失败",
}
: {
disableError: "Disable error simulation",
simulateError: "Simulate submission failure",
};
return (
{
await new Promise((resolve) => setTimeout(resolve, 600));
if (fail)
throw new Error(
zh ? "保存失败,请重试。" : "Could not save. Please try again.",
);
}}
/>
setFail((value) => !value)}
>
{fail ? stateLabels.disableError : stateLabels.simulateError}
);
}
```
## Installation
```bash
bunx --bun shadcn@latest add @sui/tanstack-form
```
Install this Block with the shadcn CLI command above. Follow the [installation guide](/docs/installation) for registry, dependency, and style setup. The examples below use workspace imports; registry-installed source uses the aliases configured in the consuming application's `components.json`.
## Usage
```tsx
import { BugReportForm, ProfileForm } from "@workspace/ui/blocks/tanstack-form"
{ await saveReport(values) }} />
{ await saveProfile(values) }}
/>
```
The blocks keep English defaults and do not select a language. Your application can resolve translations with its own i18n system and pass them through `labels`:
```tsx
```
## API
| Prop | Description |
| --- | --- |
| `onSubmit` | Required callback; accepts validated values and can return a promise. Throw an error to show failure feedback and allow retry. |
| `initialValues` | Initial field values; Reset restores them. |
| `labels` | Partial `BugReportFormLabels` or `ProfileFormLabels`. Defaults to English; provide translations from your application. |
| `className` | Card layout classes. |
`BugReportValues` contains `title` and `description`. `ProfileValues` contains `name`, `email`, `role`, and `notifications`. Buttons and controls are disabled during submission. Both forms generate independent IDs, so multiple copies can share a page.
Both label types include `formTitle`, `formDescription`, `submit`, `submitting`, `reset`, `failed`, `saved`, `savedDescription`, and `submitError` (fallback for non-Error submission failures). Error messages thrown by your callback are displayed directly, so translate them in your application too.
`BugReportFormLabels` also includes `title`, `titlePlaceholder`, `description`, `descriptionHint`, `titleMinLength`, `titleMaxLength`, `descriptionMinLength`, and `descriptionMaxLength`. `ProfileFormLabels` also includes `name`, `email`, `role`, `rolePlaceholder`, `designerRole`, `developerRole`, `managerRole`, `notifications`, `nameMinLength`, `nameMaxLength`, `invalidEmail`, and `invalidRole`. Provide all keys for a fully translated form, or override individual keys while keeping the remaining English defaults.
---
# Utilities
CSS effects for scroll containers and loading text, included in SUI’s shared styles.
Page: https://sui.draco.dev/docs/utils
SUI includes two CSS utilities from the shared shadcn styles. They use Tailwind CSS v4 classes and work with plain HTML or existing SUI components.
- [Scroll fade](/docs/utils/scroll-fade) masks overflowing content at the scrollable edges, with vertical, horizontal, and logical RTL variants.
- [Shimmer](/docs/utils/shimmer) adds a text highlight that follows the reading direction and respects reduced motion.
Both are loaded through `@workspace/ui/globals.css`. Each page includes runnable examples, copyable source, class references, accessibility guidance, and browser fallbacks.
---
# Scroll fade
Utilities for adding a fade effect to the edges of a scroll container.
Page: https://sui.draco.dev/docs/utils/scroll-fade
### Example: scroll-fade-demo
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
export default function ScrollFadeDemo({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{Array.from({ length: 12 }, (_, index) => index + 1).map(
(itemNumber) => (
{chinese ? "条目" : "Item"} {itemNumber}
),
)}
);
}
```
## Installation
Follow the [installation guide](/docs/installation). Both utilities are included when you import SUI’s global styles:
```css
@import "@workspace/ui/globals.css";
```
The utilities use the existing `shadcn/tailwind.css` import in `globals.css`; no additional stylesheet or React component is needed.
## Usage
| Class | Styles |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `scroll-fade` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));` `animation-timeline: scroll(self y);` |
| `scroll-fade-y` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));` `animation-timeline: scroll(self y);` |
| `scroll-fade-x` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));` `animation-timeline: scroll(self inline);` |
| `scroll-fade-t` | Fade mask on the top edge. `animation-timeline: scroll(self y);` |
| `scroll-fade-b` | Fade mask on the bottom edge. `animation-timeline: scroll(self y);` |
| `scroll-fade-l` | Fade mask on the left edge. `animation-timeline: scroll(self x);` |
| `scroll-fade-r` | Fade mask on the right edge. `animation-timeline: scroll(self x);` |
| `scroll-fade-s` | Fade mask on the start edge, mirrors in RTL. `animation-timeline: scroll(self inline);` |
| `scroll-fade-e` | Fade mask on the end edge, mirrors in RTL. `animation-timeline: scroll(self inline);` |
| `scroll-fade-` | `--scroll-fade-size: calc(var(--spacing) * );` |
| `scroll-fade-[]` | `--scroll-fade-size: ;` |
| `scroll-fade-{t,b,s,e}-` | `--scroll-fade-{t,b,s,e}-size: calc(var(--spacing) * );` |
| `scroll-fade-{t,b,s,e}-[]` | `--scroll-fade-{t,b,s,e}-size: ;` |
| `scroll-fade-none` | `--scroll-fade-mask: none;` |
Add `scroll-fade` or `scroll-fade-y` to the scroll container, i.e. the element that has `overflow-y-auto`.
```tsx
{/* ... */}
```
The fade is scroll-aware and tracks the scroll position:
- At rest, the top edge is crisp and the bottom edge fades to hint at more content.
- As you scroll, a fade appears at the top and both edges stay faded mid-scroll.
- At the end, the bottom edge sharpens to show you have reached the last item.
The fade is applied with `mask-image`, so it dissolves the content itself rather than overlaying a color. The mask uses a linear fade from transparent to black, so it adapts to any background without configuration. If your scroll area sits inside a card, put the background and border on a wrapper and `scroll-fade` on the inner scroller, so the fade dissolves the content and not the card.
The [`ScrollArea`](/docs/components/scroll-area) and [`MessageScroller`](/docs/components/message-scroller) components can use `scroll-fade` on their scrollable viewport.
## No overflow
In browsers with scroll-driven animation support, content that does not overflow has no fade. You can apply `scroll-fade` without checking the scroll size. The static fallback described below still fades the selected edges.
### Example: scroll-fade-overflow
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
export default function ScrollFadeOverflow({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{Array.from({ length: 3 }, (_, index) => index + 1).map(
(itemNumber) => (
{chinese ? "条目" : "Item"} {itemNumber}
),
)}
);
}
```
## Horizontal scrolling
Use `scroll-fade-x` on containers that scroll horizontally, i.e. the element that has `overflow-x-auto`.
### Example: scroll-fade-horizontal
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
const tags = [
["Design", "设计"],
["Engineering", "工程"],
["Marketing", "市场"],
["Product", "产品"],
["Research", "研究"],
["Sales", "销售"],
["Support", "支持"],
["Operations", "运营"],
["Finance", "财务"],
["Legal", "法务"],
["People", "人力"],
["Security", "安全"],
];
export default function ScrollFadeHorizontal({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{tags.map(([tag, chineseTag]) => (
{chinese ? chineseTag : tag}
))}
);
}
```
```tsx
{/* ... */}
```
The horizontal fade is direction-aware. In RTL layouts, the crisp edge and the fade follow the reading direction with no extra classes needed. `scroll-fade-` and `scroll-fade-none` work the same for both axes.
## Edge fades
Use edge utilities when only one edge should track the scroll position.
### Example: scroll-fade-edge
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
const items = [
["Inbox triage", "收件箱整理"],
["Design review", "设计评审"],
["API contract", "API 约定"],
["QA pass", "质量检查"],
["Launch notes", "发布说明"],
["Metrics follow-up", "指标回顾"],
];
const tags = [
["Design", "设计"],
["Engineering", "工程"],
["Marketing", "市场"],
["Product", "产品"],
["Research", "研究"],
["Sales", "销售"],
["Support", "支持"],
["Operations", "运营"],
];
export default function ScrollFadeEdge({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
);
}
function ScrollFadeEdgeItems({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
{items.map(([item, chineseItem]) => (
{chinese ? chineseItem : item}
))}
);
}
function ScrollFadeEdgeTags({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
{tags.map(([tag, chineseTag]) => (
{chinese ? chineseTag : tag}
))}
);
}
```
```tsx
{/* ... */}
```
The edge utilities are scroll-aware. Start edges fade in after you scroll away from the start, and end edges fade out when you reach the end. Use `scroll-fade-t`, `scroll-fade-b`, `scroll-fade-l`, and `scroll-fade-r` for physical edges. Use `scroll-fade-s` and `scroll-fade-e` for logical inline edges that mirror in RTL.
## Fade size
The fade depth defaults to `12%` of the container, capped at `40px` so tall scrollers stay subtle. Use `scroll-fade-` to set a fixed size on the spacing scale instead, the same way `scroll-mt-` works.
### Example: scroll-fade-size
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
export default function ScrollFadeSize({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
);
}
function ScrollFadeSizeItems({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
{Array.from({ length: 8 }, (_, index) => index + 1).map((itemNumber) => (
{chinese ? "条目" : "Item"} {itemNumber}
))}
);
}
```
```tsx
{/* ... */}
```
For one-off values, use an arbitrary length or percentage:
```tsx
{/* ... */}
```
To fade opposite edges by different amounts, use the per-edge modifiers `scroll-fade-t-`, `scroll-fade-b-`, `scroll-fade-s-`, and `scroll-fade-e-`. They override `scroll-fade-` on the edge they target and accept arbitrary values too.
```tsx
{/* ... */}
```
Use the logical `s`/`e` modifiers for horizontal scrollers so the sizes mirror in RTL.
The fade eases in and out over a fixed scroll distance rather than appearing instantly. That distance is the `--scroll-fade-reveal` variable, `96px` by default and independent of the fade depth. Lower it for a snappier reveal or raise it for a more gradual one:
```tsx
{/* ... */}
```
## Disabling the fade
Use `scroll-fade-none` to remove the fade. It works in any class order, so the typical use is responsive or stateful:
```tsx
{/* ... */}
```
### Example: scroll-fade-none
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
export default function ScrollFadeNone({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
scroll-fade scroll-fade-none
);
}
function ScrollFadeNoneItems({ locale }: ExampleProps) {
const chinese = locale === "zh-CN";
return (
{Array.from({ length: 8 }, (_, index) => index + 1).map((itemNumber) => (
{chinese ? "条目" : "Item"} {itemNumber}
))}
);
}
```
## Fallback
The scroll-aware behavior is implemented with [CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations), with no JavaScript and no scroll listeners. In browsers that do not support scroll-driven animations, `scroll-fade` falls back to a static fade on both edges, and edge utilities fall back to a static fade on the selected edge.
Since the mask is applied to the scroll container itself, a visible scrollbar fades with the content at the edges. Pair `scroll-fade` with `no-scrollbar`, which ships in the same package, if you want to hide the scrollbar entirely.
## RTL
Set `dir="rtl"` on the container or use SUI’s [DirectionProvider](/docs/components/direction).
`scroll-fade-x` follows the reading direction. At rest, the start edge is crisp and the end edge fades. In RTL layouts that means a crisp right edge and a fade on the left, mirrored from LTR.
### Example: scroll-fade-rtl
```tsx
// biome-ignore-all lint/a11y/noNoninteractiveTabindex: These named scroll viewports need keyboard access.
import type { ExampleProps } from "../types";
const tags = [
"تصميم",
"هندسة",
"تسويق",
"منتج",
"أبحاث",
"مبيعات",
"دعم",
"عمليات",
"مالية",
"قانوني",
];
export default function ScrollFadeRtl({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{tags.map((tag) => (
{tag}
))}
);
}
```
## Accessibility
Keep important controls outside the masked edges. Give a plain scrolling viewport `tabIndex={0}` and an accessible name when it needs keyboard access. The utility changes only the visual mask: it does not remove content from the accessibility tree or trap focus. Set the viewport’s background and frame on an outer wrapper.
---
# Shimmer
Utilities for adding a shimmer effect to text elements.
Page: https://sui.draco.dev/docs/utils/shimmer
### Example: shimmer-demo
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerDemo({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
);
}
```
## Installation
Follow the [installation guide](/docs/installation). Both utilities are included when you import SUI’s global styles:
```css
@import "@workspace/ui/globals.css";
```
The utilities use the existing `shadcn/tailwind.css` import in `globals.css`; no additional stylesheet or React component is needed.
## Usage
| Class | Styles |
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
| `shimmer` | `background-clip: text;` `animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;` |
| `shimmer-once` | `animation-iteration-count: 1;` |
| `shimmer-reverse` | `animation-direction: reverse;` |
| `shimmer-none` | `--shimmer-image: none;` `--shimmer-text-fill: currentColor;` |
| `shimmer-color-` | `--shimmer-color: ;` |
| `shimmer-color-[]` | `--shimmer-color: ;` |
| `shimmer-color-/` | `--shimmer-color: color-mix(in oklch, , transparent);` |
| `shimmer-duration-` | `--shimmer-duration: calc( * 1ms);` |
| `shimmer-spread-` | `--shimmer-spread: calc(var(--spacing) * );` |
| `shimmer-spread-[]` | `--shimmer-spread: ;` |
| `shimmer-angle-` | `--shimmer-angle: calc( * 1deg);` |
Add `shimmer` to a text element.
```tsx
Generating response…
```
The shimmer is built on `currentColor`, so it adapts to the element:
- The highlight is derived from the text color, with no configuration needed.
- It works on any color, from `text-muted-foreground` to brand colors.
- In dark mode, the highlight automatically brightens to stay visible.
The effect is pure CSS. The text is painted with `background-clip: text`, and the highlight sweeps across it in a seamless loop.
## With Marker
The shimmer composes with any component that renders text. A common pattern is a [Marker](/docs/components/marker) showing a live status while the assistant is working:
### Example: shimmer-marker
```tsx
import { Loader } from "@workspace/ui/components/loader";
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@workspace/ui/components/marker";
import type { ExampleProps } from "../types";
export default function ShimmerMarker({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在思考…" : "Thinking…"}
{chinese ? "正在读取 4 个文件" : "Reading 4 files"}
);
}
```
```tsx
Thinking…
```
## Color
Use `shimmer-color-` to set the highlight color explicitly. It accepts theme colors with an optional opacity modifier, or any arbitrary color value.
### Example: shimmer-color
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerColor({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
{chinese ? "正在生成回复…" : "Generating response…"}
);
}
```
```tsx
Generating response…
Generating response…
```
## Duration
Use `shimmer-duration-` to set the duration of one sweep in milliseconds. The default is `2000`, i.e. `2s`.
### Example: shimmer-duration
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerDuration({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer-duration-1000
);
}
```
```tsx
Generating response…
```
## Spread
Use `shimmer-spread-` to set the width of the highlight band using the spacing scale. The default is `calc(3ch + 40px)`: a fixed base plus a `3ch` term that scales with the font size.
### Example: shimmer-spread
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerSpread({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer-spread-4
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer-spread-24
);
}
```
```tsx
Generating response…
```
For one-off values, use an arbitrary length or percentage:
```tsx
Generating response…
```
## Angle
Use `shimmer-angle-` to set the tilt of the highlight band in degrees. The default is `20`.
### Example: shimmer-angle
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerAngle({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer-angle-45
);
}
```
```tsx
Generating response…
```
## Reverse
Use `shimmer-reverse` to sweep the highlight in the opposite direction. In RTL layouts the sweep already follows the reading direction. See [RTL](#rtl).
```tsx
Generating response…
```
## Play once
Use `shimmer-once` to play a single sweep instead of looping, useful as a reveal when streaming completes. Pair it with `shimmer-duration-` to control how long the sweep takes.
### Example: shimmer-once
```tsx
import { Button } from "@workspace/ui/components/button";
import * as React from "react";
import type { ExampleProps } from "../types";
export default function ShimmerOnce({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
const [key, setKey] = React.useState(0);
return (
{chinese ? "正在生成回复…" : "Generating response…"}
setKey((value) => value + 1)}
>
{chinese ? "重播" : "Replay"}
);
}
```
```tsx
Response generated.
```
## Disabling the shimmer
Use `shimmer-none` to turn the effect off and render the text normally. It works in any class order, so the typical use is responsive or stateful:
### Example: shimmer-none
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerNone({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
shimmer md:shimmer-none
);
}
```
```tsx
Generating response…
```
## Fallback
The shimmer is built on modern color features, [relative color syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors) and `color-mix()`, which are available in all current browsers. In older browsers without support, the highlight gradient is dropped and the text can render transparent. If you target older browsers, apply `shimmer` conditionally with a `supports-*` variant:
```tsx
Generating response…
```
## Reduced motion
When the user prefers reduced motion, the animation is disabled automatically and the text renders normally. There is nothing to configure.
## RTL
Set `dir="rtl"` on the container or use SUI’s [DirectionProvider](/docs/components/direction).
The sweep follows the reading direction, left to right in LTR and right to left in RTL, with no extra classes. Use `shimmer-reverse` to flip the direction manually.
### Example: shimmer-rtl
```tsx
import type { ExampleProps } from "../types";
export default function ShimmerRtl({ locale }: ExampleProps = {}) {
const chinese = locale === "zh-CN";
return (
{chinese ? "正在生成回复…" : "Generating response…"}
dir="ltr"
جارٍ إنشاء الرد…
dir="rtl"
);
}
```
## Accessibility
Shimmer is a visual effect, not a status announcement. Use a suitable `role="status"` for loading text and avoid repeatedly replacing the accessible label. Keep the underlying text readable; use `shimmer-none` after the operation completes. For a loading indicator with a label, see [Loader](/docs/components/loader).