# Stardust design system > Stardust is a dashboard design system built on shadcn/ui, with matching React and Vue component sets that share one token file. Components are installed as source with the shadcn CLI from the @stardust registry. ## Installation 1. Run `npx shadcn@latest init` (React) or `npx shadcn-vue@latest init` (Vue). 2. In components.json add `"registries": { "@stardust": "https:///r/{name}.json" }` (Vue: `/vue/r/{name}.json`). 3. Run `npx shadcn@latest add @stardust/theme` on its own, then add components, or `@stardust/all`. 4. Load the Geist font. Mount `` once if you use toasts. 5. Vue only: add `Icons({ compiler: "vue3" })` from unplugin-icons to vite.config.ts. ## Theme tokens (theme/stardust.css) ```css /* Stardust tokens. Single source for the React and Vue registries; edit here, then run `bun run registry`. */ @theme inline { --color-success: var(--success); --color-warning: var(--warning); /* Load Geist in the app (next/font or a ); the stack falls back to the system font. */ --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif; --font-heading: "Geist", ui-sans-serif, system-ui, sans-serif; /* Type scale: text-display, text-title, text-heading, text-body, text-caption. */ --text-display: 2.25rem; --text-display--line-height: 2.75rem; --text-display--letter-spacing: -0.025em; --text-display--font-weight: 600; --text-title: 1.5rem; --text-title--line-height: 2rem; --text-title--letter-spacing: -0.02em; --text-title--font-weight: 600; --text-heading: 1.0625rem; --text-heading--line-height: 1.5rem; --text-heading--letter-spacing: -0.01em; --text-heading--font-weight: 600; --text-body: 0.875rem; --text-body--line-height: 1.5rem; --text-caption: 0.75rem; --text-caption--line-height: 1rem; --text-caption--letter-spacing: 0.01em; } /* Light: rose-tinted neutrals, burgundy primary (#800020). White on primary is 10.8:1. Palette: burgundy #800020 (brightened from #621C36), crimson #DA2049, salmon #FB7A7F, coral #F25151. */ :root { --radius: 0.75rem; --background: oklch(0.995 0.002 15); --foreground: oklch(0.2 0.012 15); --card: oklch(1 0 15); --card-foreground: oklch(0.2 0.012 15); --popover: oklch(1 0 15); --popover-foreground: oklch(0.2 0.012 15); --primary: oklch(0.38 0.152 18.6); --primary-foreground: oklch(1 0 15); /* Top-edge highlight on primary surfaces (button inner shadow): the palette's salmon, a step lighter. */ --primary-highlight: oklch(0.8 0.12 19.4 / 50%); --secondary: oklch(0.965 0.008 15); --secondary-foreground: oklch(0.25 0.015 15); --muted: oklch(0.965 0.008 15); --muted-foreground: oklch(0.5 0.015 15); --accent: oklch(0.96 0.03 18); --accent-foreground: oklch(0.347 0.104 1.4); --destructive: oklch(0.55 0.22 27); --success: oklch(0.55 0.14 155); --warning: oklch(0.62 0.15 70); --border: oklch(0.915 0.008 15); --input: oklch(0.89 0.01 15); --ring: oklch(0.38 0.152 18.6); --chart-1: oklch(0.38 0.152 18.6); --chart-2: oklch(0.73 0.158 19.4); --chart-3: oklch(0.575 0.215 16.9); --chart-4: oklch(0.657 0.197 24.4); --chart-5: oklch(0.6 0.1 200); --sidebar: oklch(0.98 0.004 15); --sidebar-foreground: oklch(0.2 0.012 15); --sidebar-primary: oklch(0.38 0.152 18.6); --sidebar-primary-foreground: oklch(1 0 15); --sidebar-accent: oklch(0.955 0.012 15); --sidebar-accent-foreground: oklch(0.25 0.015 15); --sidebar-border: oklch(0.915 0.008 15); --sidebar-ring: oklch(0.38 0.152 18.6); } /* Dark: neutral surfaces on #0A0A0A (sidebar #0F0F0F) with a crimson-burgundy primary (#C20E35); white on it is 6.2:1. The focus ring uses a lighter red so it clears 3:1 against the background. */ .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.96 0 0); --card: oklch(0.2 0 0); --card-foreground: oklch(0.96 0 0); --popover: oklch(0.215 0 0); --popover-foreground: oklch(0.96 0 0); --primary: oklch(0.519 0.203 19.5); --primary-foreground: oklch(1 0 0); --primary-highlight: oklch(0.8 0.12 19.4 / 50%); --secondary: oklch(0.255 0 0); --secondary-foreground: oklch(0.96 0 0); --muted: oklch(0.255 0 0); --muted-foreground: oklch(0.7 0 0); --accent: oklch(0.29 0.085 4); --accent-foreground: oklch(0.84 0.1 19); --destructive: oklch(0.7 0.19 22); --success: oklch(0.74 0.15 155); --warning: oklch(0.8 0.15 75); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 14%); --ring: oklch(0.6 0.2 19.5); --chart-1: oklch(0.519 0.203 19.5); --chart-2: oklch(0.73 0.158 19.4); --chart-3: oklch(0.52 0.17 2); --chart-4: oklch(0.657 0.197 24.4); --chart-5: oklch(0.7 0.1 200); --sidebar: oklch(0.17 0 0); --sidebar-foreground: oklch(0.96 0 0); --sidebar-primary: oklch(0.519 0.203 19.5); --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.255 0 0); --sidebar-accent-foreground: oklch(0.96 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.6 0.2 19.5); } /* Tailwind v4 dropped the pointer cursor on buttons; Stardust restores it for every enabled button. Radix switches, checkboxes, tabs and select triggers render as ) } ``` #### Sizes and icons ```tsx import { ArrowRightIcon, PlusIcon, SettingsIcon } from "@keyline-icons/react/fill" import { Button } from "@/components/ui/button" export default function ButtonSizes() { return (
) } ``` #### Loading ```tsx "use client" import * as React from "react" import { Button } from "@/components/ui/button" export default function ButtonLoading() { const [saving, setSaving] = React.useState(false) async function save() { setSaving(true) await new Promise((r) => setTimeout(r, 1500)) // replace with your request setSaving(false) } return (
) } ``` ### Badge A short status or category label. Tinted pill with a color border and a heavier bottom edge. Install: ```bash npx shadcn@latest add @stardust/badge ``` Do: - Keep badge text to one or two words. - Pair color with a word: Healthy, Degraded, Down. Don't: - Don't make badges clickable without the [a] hover style. - Don't use the default (primary) badge for errors. Accessibility: - Color is never the only signal; the label carries the meaning. #### Status ```tsx import { Badge } from "@/components/ui/badge" export default function BadgeVariants() { return (
Default Secondary Outline Healthy Degraded Down
) } ``` #### With icons ```tsx import { BriefcaseIcon, OctagonXIcon, ShieldCheckIcon, ZapIcon } from "@keyline-icons/react/duotone" import { Badge } from "@/components/ui/badge" export default function BadgeIcons() { return (
Fast Finance Secure Blocked
) } ``` ### Input Single-line text entry, 40px tall, with label, hint and error states. Install: ```bash npx shadcn@latest add @stardust/input ``` Do: - Always pair an input with a visible Label. - Use placeholder text for examples, not instructions. Don't: - Don't rely on placeholder text as the label. - Don't show errors before someone has finished typing. Accessibility: - Connect Label and Input with htmlFor and id. - Set aria-invalid and describe the error in text. #### Field ```tsx import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" export default function InputField() { return (

Shown in invoices and emails.

Enter an email address like name@company.com.

) } ``` ### Select Pick one option from a short list. The trigger matches the Input. Install: ```bash npx shadcn@latest add @stardust/select ``` Do: - Use for 4 to 15 options. - Pick a sensible default. Don't: - Don't use a select for 2 or 3 options; use a radio group or toggle group. - Don't use it for actions; use a dropdown menu. Accessibility: - Give the trigger a label or aria-label. - Options support arrow keys and type-ahead. #### Basic ```tsx import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" export default function SelectBasic() { return (
) } ``` ### Checkbox and switch Checkbox for choices that apply on submit, switch for settings that apply right away. Install: ```bash npx shadcn@latest add @stardust/checkbox npx shadcn@latest add @stardust/switch ``` Do: - Use a switch when the change takes effect immediately. - Use checkboxes inside forms that need a Save button. Don't: - Don't use a switch inside a form that needs submitting. - Don't write negative labels like 'Don't send emails'. Accessibility: - Wrap or link every control to a label so the label is clickable. #### Checkbox and switch ```tsx import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" export default function CheckboxSwitch() { return (

Checkbox (applies on save)

{["Email reports", "Slack alerts", "SMS alerts"].map((label, i) => (
))}

Switch (applies now)

) } ``` ### Card Groups related content. 24px padding, hairline ring, optional inset image. Install: ```bash npx shadcn@latest add @stardust/card ``` Do: - Use CardHeader, CardContent and CardFooter so spacing stays consistent. - Put the card's main action in the footer or CardAction. Don't: - Don't nest cards inside cards. - Don't add custom padding; use the size prop for compact cards. Accessibility: - Give the card title a heading level that fits the page outline when it is a section. #### Stat card ```tsx import { TrendingUpIcon } from "@keyline-icons/react/fill" import { Badge } from "@/components/ui/badge" import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" export default function CardStat() { return ( Monthly revenue $48,210 +12.4% Compared with $42,890 last month. ) } ``` #### Image card ```tsx import { SparklesIcon } from "@keyline-icons/react/duotone" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" export default function CardImage() { return ( {/* A first-child image sits inset with an 8px frame and concentric corners. */} Rising revenue chart at sunset Template Revenue dashboard MRR, churn and cohorts on one page.
SD 2.4k uses
) } ``` ### Dialog Interrupts the flow for a focused task or a confirmation. Install: ```bash npx shadcn@latest add @stardust/dialog npx shadcn@latest add @stardust/alert-dialog ``` Do: - Keep dialogs short: one task, one primary action. - Name the action in the button: Delete project, not OK. Don't: - Don't open a dialog from a dialog. - Don't use a dialog for information that could sit on the page. Accessibility: - Focus moves into the dialog and returns to the trigger on close. - Escape closes it; AlertDialog requires an explicit choice. #### Form dialog ```tsx import { Button } from "@/components/ui/button" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" export default function DialogForm() { return ( Edit profile Changes apply to every workspace you belong to.
) } ``` #### Confirm a destructive action ```tsx import { BinIcon } from "@keyline-icons/react/fill" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" export default function DialogConfirm() { return ( Delete “Q4 revenue”? This deletes the project, its 12 reports and every scheduled export. You can't undo this. Cancel Delete project ) } ``` ### Dropdown menu A list of actions behind a trigger. 36px items with 2px gaps. Install: ```bash npx shadcn@latest add @stardust/dropdown-menu ``` Do: - Group related items and separate groups. - Put destructive items last, with the destructive variant. Don't: - Don't use a dropdown for choosing a value; use a select. Accessibility: - Full keyboard support: arrows, Home, End, type-ahead, Escape. #### Account menu ```tsx import { CreditCardIcon, SettingsIcon, UserIcon } from "@keyline-icons/react/fill" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" export default function DropdownBasic() { return ( aswin@acme.com Profile ⇧⌘P Billing ⌘B Settings Log out ) } ``` ### Toast Brief, non-blocking feedback after an action. Built on Sonner (vue-sonner in Vue). Install: ```bash npx shadcn@latest add @stardust/sonner ``` Do: - Confirm actions that finished out of view. - Offer Undo for reversible deletions. Don't: - Don't put critical errors only in a toast. - Don't stack more than three at once. Accessibility: - Toasts are announced to screen readers and pause on hover. #### Toasts ```tsx "use client" import { toast } from "sonner" import { Button } from "@/components/ui/button" export default function ToastBasic() { return (
) } ``` ### Chatbot An assistant that lives in a corner: an idle pill, a composer bar with a soft glow, and a conversation panel. Install: ```bash npx shadcn@latest add @stardust/chatbot ``` Do: - Mount one Chatbot per app, floating in the bottom corner (bottom-right by default). - Keep the trigger label short and specific: Ask anything, Ask about your revenue. - Show ChatbotTyping while the reply is generating, and stream text into the last message. - Give assistant replies structure when it helps: short paragraphs, lists, key numbers and one or two follow-up actions. Don't: - Don't place it over primary actions or the toast stack; move it with side="left" if they collide. - Don't open the conversation panel on page load; let people start from the pill. - Don't use the glow anywhere else; it marks the assistant. Accessibility: - The composer input has a label, Enter sends and Shift+Enter adds a line. Messages keep those line breaks. - Escape on an empty composer collapses it back to the pill. - The message list is a polite live region, so new replies are announced. - Icon buttons (attach, select on page, voice, send, minimize) all have aria-labels. #### Pill, composer and conversation ```tsx "use client" import * as React from "react" import { Chatbot, ChatbotComposer, ChatbotMessage, ChatbotPanel, ChatbotTrigger, ChatbotTyping } from "@/components/ui/chatbot" type Message = { id: number; role: "user" | "assistant"; text: string } const replies = [ "Revenue is $48,210 this month, up 12.4% on last month. Most of the growth came from annual plans.", "Churn rose from 2.1% to 2.4%. Three accounts on the Starter plan cancelled after the price change.", "I can export that as a CSV or schedule it as a weekly report. Which would you like?", ] export default function ChatbotDemo({ floating = false }: { floating?: boolean }) { const [messages, setMessages] = React.useState([]) const [typing, setTyping] = React.useState(false) function send(text: string) { setMessages((m) => [...m, { id: Date.now(), role: "user", text }]) setTyping(true) // Replace with your model call; stream tokens into the last assistant message. setTimeout(() => { setTyping(false) setMessages((m) => [...m, { id: Date.now() + 1, role: "assistant", text: replies[m.length % replies.length] }]) }, 900) } return ( {messages.map((m) => ( {m.text} ))} {typing && } ) } ``` #### Open conversation ```tsx "use client" import * as React from "react" import { Chatbot, ChatbotComposer, ChatbotMessage, ChatbotPanel, ChatbotTyping } from "@/components/ui/chatbot" type Message = { id: number; role: "user" | "assistant"; text: string } const seed: Message[] = [ { id: 1, role: "user", text: "Why did churn go up this month?" }, { id: 2, role: "assistant", text: "Churn rose from 2.1% to 2.4%. Most of it came from three Starter accounts that cancelled in the week after the price change on November 3.", }, { id: 3, role: "user", text: "Did any of them leave feedback?" }, { id: 4, role: "assistant", text: "Two did. Northwind Labs said the new price was over budget, and Orbit Studio moved to an in-house tool. Helios Health didn't leave a reason.", }, { id: 5, role: "user", text: "Draft a win-back email for Northwind Labs.\nKeep it short and friendly.\nOffer the old price for 3 months.", }, { id: 6, role: "assistant", text: "Here's a draft:\n\nHi Maya, we're sorry to see Northwind go. If budget was the blocker, we'd like to offer your old price for the next 3 months. Want to pick up where you left off?", }, ] export default function ChatbotOpen() { const [messages, setMessages] = React.useState(seed) const [typing, setTyping] = React.useState(false) function send(text: string) { setMessages((m) => [...m, { id: Date.now(), role: "user", text }]) setTyping(true) setTimeout(() => { setTyping(false) setMessages((m) => [...m, { id: Date.now() + 1, role: "assistant", text: "I've drafted a win-back email for Northwind Labs. Want me to send it?" }]) }, 900) } return ( // defaultState="open" starts with the conversation showing. {messages.map((m) => ( {m.text} ))} {typing && } ) } ``` #### Rich answers ```tsx import { DownloadIcon, CalendarIcon } from "@keyline-icons/react/fill" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Chatbot, ChatbotComposer, ChatbotMessage, ChatbotPanel } from "@/components/ui/chatbot" export default function ChatbotRich() { return ( Give me a quick summary of November. {/* Assistant replies have no bubble, so they can hold lists, numbers and actions. */}

November was your best month this year.

{[ ["Revenue", "$48,210", "+12.4%", "success"], ["New subs", "142", "+8.1%", "success"], ["Churn", "2.4%", "+0.3%", "destructive"], ].map(([label, value, delta, variant]) => (
{label} {value} {delta}
))}
  • Annual plans drove 60% of new revenue.
  • Three Starter accounts churned after the price change.
  • Two invoices are still overdue.
{"Who are the overdue invoices from?\nAnd how late is each one?"} Orbit Studio ($720, 30 days late) and Helios Health ($1,850, due last Friday). I can send both a reminder.
) } ``` ### Tabs Switch between views of the same subject. Install: ```bash npx shadcn@latest add @stardust/tabs ``` Do: - Keep tab labels short and parallel. - Use for 2 to 6 views. Don't: - Don't use tabs for steps in a process. Accessibility: - Arrow keys move between tabs. #### Basic ```tsx import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" export default function TabsBasic() { return ( Overview Analytics Reports Revenue is up 12% this month. 3,912 daily active users. 4 reports are scheduled. ) } ``` ### Table Rows of comparable records. Numbers align right with tabular figures. Install: ```bash npx shadcn@latest add @stardust/table ``` Do: - Right-align numbers and use tabular-nums. - Use badges for status columns. Don't: - Don't center-align text columns. Accessibility: - Use real table markup so screen readers can announce headers. #### Basic ```tsx import { Badge } from "@/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" const invoices = [ { id: "INV-2041", customer: "Northwind Labs", status: "Paid", amount: "$4,200.00" }, { id: "INV-2040", customer: "Helios Health", status: "Pending", amount: "$1,850.00" }, { id: "INV-2038", customer: "Orbit Studio", status: "Overdue", amount: "$720.00" }, ] as const const variant = { Paid: "success", Pending: "warning", Overdue: "destructive" } as const export default function TableBasic() { return ( Invoice Customer Status Amount {invoices.map((inv) => ( {inv.id} {inv.customer} {inv.status} {inv.amount} ))}
) } ``` ## Patterns ### Forms Settings and sign-up forms with consistent field spacing and clear actions. - Stack fields vertically with 20px between them (gap-5). Label to input is 8px (gap-2). - Put the primary action at the bottom right, with Cancel to its left as a ghost button. - Group long forms into cards with a heading per group. - Validate on blur or submit, and show errors under the field. #### Settings form ```tsx import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" export default function FormSettings() { return ( Workspace settings These details appear on invoices and in emails.