A complete, self-contained reference for AI agents working with the People's Party design system. Every export is documented with a copy-paste example.
Target audience: AI coding agents writing React + TypeScript apps that consume
@pplethai/components. Examples assume React 18+, Tailwind v3, and a Vite/Next.js style bundler.
- Install & setup
- Conventions
- Utility —
cn() - Layout primitives — Stack, Inline, Container
- Icon, Logo, Navbar
- Form inputs — Button, Input, Label, Textarea, Checkbox, RadioGroup, Switch, Select, MultiSelect, Autocomplete, Slider
- Overlays — Dialog, Sheet, Popover, DropdownMenu
- Navigation — Tabs, Accordion, Breadcrumb, NavigationMenu, Stepper
- Feedback — Alert, Badge, Progress, Spinner, Skeleton, Toast (Sonner)
- Data display — Card, Separator
- Forms with react-hook-form + zod
- Design tokens
- Composed patterns
- Anti-patterns / things NOT to do
pnpm add @pplethai/components react react-dom react-hook-form zodPeer deps: react >=18, react-dom >=18, react-hook-form >=7, zod >=3. Forms work only if both are present.
// src/main.tsx (or _app.tsx)
import "@pplethai/components/styles.css";// tailwind.config.ts
import type { Config } from "tailwindcss";
import preset from "@pplethai/components/tailwind-preset";
export default {
presets: [preset],
content: [
"./src/**/*.{ts,tsx}",
// include compiled library so utility classes survive purge
"./node_modules/@pplethai/components/dist/**/*.js",
],
} satisfies Config;import { Toaster } from "@pplethai/components";
export default function App() {
return (
<>
{/* your routes */}
<Toaster />
</>
);
}| Import path | What's exported |
|---|---|
@pplethai/components |
All UI + layout + utilities (Button, Card, Stack, cn, …) |
@pplethai/components/form |
Form, FormField, FormTextField, FormSelectField, FormCheckboxField, useFormField (needs react-hook-form + zod) |
@pplethai/components/styles.css |
Compiled CSS — import once |
@pplethai/components/tailwind-preset |
Tailwind preset re-exporting theme + plugins |
Headings use Anakotmai (from party CDN, medium only). Body uses IBM Plex Sans Thai Looped via @fontsource (auto-bundled). Apply with font-heading / font-body (preset provides). All h1–h6 get font-heading automatically via @layer base.
- Compose className with
cn()— combinesclsx+tailwind-mergeso later classes override earlier ones. - CVA variants: components with variants export both the component and a
*VariantsCVA factory (e.g.buttonVariants,badgeVariants,alertVariants). Use these to extend. - Compound APIs: multi-part components are flat named exports (
Card,CardHeader,CardTitle, …) not dot-notation. Same forDialog*,Sheet*,Tabs*, etc. asChildpattern: components built on Radix Slot (Button, BreadcrumbLink, all Triggers) acceptasChildto render-as-child instead of the default element. Use this to wrap routingLinkcomponents.- Brand tokens via CSS variables — never hardcode hex. All colors are HSL channels in CSS custom properties (
--primary,--secondary,--destructive, …). - Icons: use
lucide-react. Either directly (<Search className="h-4 w-4" />) or via the<Icon>wrapper for typed size/color variants. - Dark mode: class-based (
<html class="dark">). All tokens have dark counterparts instyles.css. - Thai language: UI strings in this codebase are Thai. Use
lang="th"at the root for proper rendering.
import { cn } from "@pplethai/components";
cn("px-4", condition && "bg-primary", "px-2"); // → "bg-primary px-2" (tailwind-merge wins)Always use cn() when composing classNames in components — bare string concatenation breaks tailwind-merge.
The library ships three layout primitives. Use them instead of raw <div className="flex …"> so spacing stays on-grid.
import { Stack } from "@pplethai/components";
<Stack gap="md" align="stretch" justify="start">
<div>item 1</div>
<div>item 2</div>
</Stack>
// Polymorphic via `as`
<Stack as="main" gap="lg" className="min-h-screen">…</Stack>| Prop | Type | Default |
|---|---|---|
gap |
"none" | "xs" | "sm" | "md" | "lg" |
"md" |
align |
"start" | "center" | "end" | "stretch" |
"stretch" |
justify |
"start" | "center" | "end" | "between" |
"start" |
as |
React.ElementType |
"div" |
Gap scale: xs=4px, sm=8px, md=16px, lg=24px.
import { Inline } from "@pplethai/components";
<Inline gap="sm" align="center" justify="between">
<Button>Save</Button>
<Button variant="outline">Cancel</Button>
</Inline>Same props as Stack but flex-row + wrap. Default align="center".
import { Container } from "@pplethai/components";
<Container size="lg">…</Container>size |
max-width |
|---|---|
sm |
max-w-screen-sm (640px) |
md |
max-w-screen-md (768px) |
lg (default) |
max-w-screen-xl (1280px) |
full |
max-w-full |
Includes responsive padding (px-4 md:px-6) and mx-auto.
Typed wrapper around any LucideIcon with size + color variants.
import { Icon } from "@pplethai/components";
import { Search } from "lucide-react";
<Icon icon={Search} size="md" color="primary" aria-label="ค้นหา" />| Prop | Values |
|---|---|
size |
"sm" (16px) | "md" (20px, default) | "lg" (24px) |
color |
"default" | "muted" | "primary" | "destructive" |
aria-label |
string — when provided, aria-hidden becomes false automatically |
For inline icons in Button etc., just import from lucide-react directly — Button auto-sizes them.
People's Party logo as an SVG component that inherits currentColor.
import { Logo } from "@pplethai/components";
<Logo size="md" className="text-primary" />size: "sm" | "md" | "lg" | "xl". Color via className="text-..." (uses currentColor).
Top navigation bar with built-in mobile hamburger menu. Supports custom link rendering for any router.
import { Navbar, type NavbarItem } from "@pplethai/components";
import { NavLink, useLocation } from "react-router-dom";
const items: NavbarItem[] = [
{ href: "/", label: "หน้าแรก", end: true },
{ href: "/components", label: "คอมโพเนนต์" },
{ href: "/guidelines", label: "แนวทาง" },
];
function AppLayout() {
const { pathname } = useLocation();
return (
<Navbar
title="ระบบดีไซน์"
items={items}
pathname={pathname}
renderHomeLink={({ home, className, children, onNavigate }) => (
<NavLink
to={home.href}
end={home.end}
onClick={onNavigate}
className={({ isActive }) => className(isActive)}
>
{children}
</NavLink>
)}
renderLink={({ item, className, onNavigate }) => (
<NavLink
to={item.href}
end={item.end}
onClick={onNavigate}
className={({ isActive }) => className(isActive)}
>
{item.label}
</NavLink>
)}
/>
);
}Without router — omit renderLink to use plain <a href>:
<Navbar
title="ระบบ"
items={[{ href: "/", label: "Home", end: true }]}
pathname={typeof window !== "undefined" ? window.location.pathname : ""}
/>| Prop | Type | Required | Notes |
|---|---|---|---|
title |
string |
✓ | Shown beside logo (clickable with logo when home link is enabled) |
items |
NavbarItem[] |
✓ | { href, label, end? } |
home |
NavbarHome | false |
— | Logo + title link; default { href: "/", end: true }; false disables |
pathname |
string |
— | Drives active state + closes mobile menu on nav |
renderHomeLink |
(props: NavbarHomeLinkRenderProps) => ReactNode |
— | Custom home/brand link renderer (router) |
renderLink |
(props: NavbarLinkRenderProps) => ReactNode |
— | Custom menu link renderer (router) |
logo |
ReactNode |
— | Defaults to <Logo size="sm" className="text-primary" /> |
children |
ReactNode |
— | Arbitrary content rendered to the right of the links on desktop; rendered at the top of the mobile dropdown panel (above the items). Use for login buttons, notification bells, theme toggles, etc. When the navbar variant is dark, children are wrapped in a .dark scope so design tokens (bg-primary, text-foreground, border-border, etc.) automatically flip to their dark-mode values. |
mobileMenuAriaLabel |
{ open: string; close: string } |
— | Defaults to Thai labels |
navAriaLabel |
string |
— | Defaults to "เมนูหลัก" |
navLinkClassName(isActive) is also exported for custom integrations.
Trailing slot example — login button to the right of nav links:
import { Navbar, Button, Inline } from "@pplethai/components";
import { Bell, LogIn } from "lucide-react";
<Navbar title="ระบบ" items={items} pathname={pathname}>
<Inline gap="xs" align="center">
<Button variant="ghost" size="icon" aria-label="แจ้งเตือน"><Bell /></Button>
<Button variant="outline" size="sm"><LogIn />เข้าสู่ระบบ</Button>
</Inline>
</Navbar>import { Button } from "@pplethai/components";
import { Plus, Search } from "lucide-react";
<Button variant="default">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="destructive">Delete</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon" aria-label="Search"><Search /></Button>
// With icon (auto-sized)
<Button><Plus />Add item</Button>
// As link (Radix Slot)
<Button asChild>
<a href="/somewhere">Go</a>
</Button>| Prop | Type | Default |
|---|---|---|
variant |
"default" | "secondary" | "outline" | "destructive" | "ghost" | "link" |
"default" |
size |
"default" | "sm" | "lg" | "icon" |
"default" |
asChild |
boolean |
false |
disabled |
boolean |
false |
buttonVariants is exported as a CVA factory for extending.
import { Input, Label } from "@pplethai/components";
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" />Accepts all InputHTMLAttributes. For controlled forms, prefer FormTextField (§11).
import { Label } from "@pplethai/components";
<Label htmlFor="name">Name</Label>Built on @radix-ui/react-label — automatically dims when the paired field is peer-disabled.
import { Textarea } from "@pplethai/components";
<Textarea rows={4} placeholder="Bio" />Min-height 80px. All TextareaHTMLAttributes accepted.
import { Checkbox, Label } from "@pplethai/components";
<div className="flex items-center gap-2">
<Checkbox id="terms" />
<Label htmlFor="terms">Accept terms</Label>
</div>
// Controlled
<Checkbox checked={value} onCheckedChange={setValue} />Animated draw-in/out (auto-disabled when prefers-reduced-motion). Supports "indeterminate" for tri-state.
import { RadioGroup, RadioGroupItem, Label } from "@pplethai/components";
<RadioGroup defaultValue="email">
<div className="flex items-center gap-2">
<RadioGroupItem value="email" id="r-email" />
<Label htmlFor="r-email">Email</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="sms" id="r-sms" />
<Label htmlFor="r-sms">SMS</Label>
</div>
</RadioGroup>Dot slides in/out with animation. Keyboard: ↑↓ navigate, Space selects.
import { Switch, Label } from "@pplethai/components";
<div className="flex items-center gap-2">
<Switch id="notify" />
<Label htmlFor="notify">Notifications</Label>
</div>Use Switch for settings that apply immediately. Use Checkbox for form fields submitted later.
import {
Select, SelectContent, SelectGroup, SelectItem,
SelectLabel, SelectSeparator, SelectTrigger, SelectValue,
} from "@pplethai/components";
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Pick one" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Group A</SelectLabel>
<SelectItem value="a1">Option A1</SelectItem>
<SelectItem value="a2">Option A2</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Group B</SelectLabel>
<SelectItem value="b1">Option B1</SelectItem>
</SelectGroup>
</SelectContent>
</Select>Best for ~10 or fewer options. For more, use Autocomplete.
import { MultiSelect } from "@pplethai/components";
const options = [
{ value: "bkk", label: "Bangkok" },
{ value: "cnx", label: "Chiang Mai" },
];
const [value, setValue] = useState<string[]>([]);
<MultiSelect
options={options}
value={value}
onValueChange={setValue}
placeholder="Pick provinces"
/>Selected items render as removable chips. Controlled only — value and onValueChange are required.
import { Autocomplete } from "@pplethai/components";
// Single
const [single, setSingle] = useState("");
<Autocomplete
options={options}
value={single}
onValueChange={setSingle}
placeholder="Type to search"
/>
// Multiple — set `multiple` to true and value becomes string[]
const [multi, setMulti] = useState<string[]>([]);
<Autocomplete
multiple
options={options}
value={multi}
onValueChange={setMulti}
searchPlaceholder="Search..."
emptyMessage="No results"
/>Use for long option lists (>10) or when search aids discovery. The single-value variant uses one <Input> for both display and search; multi-value uses chips + a separate search input in the popover.
import { Slider } from "@pplethai/components";
// Single value (always pass array)
const [v, setV] = useState([50]);
<Slider value={v} onValueChange={setV} max={100} step={1} />
// Range (two thumbs)
const [range, setRange] = useState([20, 80]);
<Slider value={range} onValueChange={setRange} min={0} max={100} step={5} />value is always number[]. Keyboard: ←→/↑↓ step, PageUp/PageDown big step, Home/End to bounds.
import {
Dialog, DialogTrigger, DialogContent, DialogHeader,
DialogTitle, DialogDescription, DialogFooter, DialogClose,
Button, Input, Label,
} from "@pplethai/components";
<Dialog>
<DialogTrigger asChild>
<Button>Edit profile</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Update your details and save.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="d-name">Name</Label>
<Input id="d-name" defaultValue="…" className="mt-1" />
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>DialogTitle is required for accessibility — Radix logs a console warning if missing. Close via Esc, overlay click, or the auto-rendered X button.
import {
Sheet, SheetTrigger, SheetContent, SheetHeader,
SheetTitle, SheetDescription, SheetFooter, SheetClose,
Button,
} from "@pplethai/components";
<Sheet>
<SheetTrigger asChild>
<Button>Open panel</Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>Adjust search criteria.</SheetDescription>
</SheetHeader>
{/* body */}
<SheetFooter className="mt-6">
<SheetClose asChild><Button>Done</Button></SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>SheetContent accepts side: "top" | "right" | "bottom" | "left" (default "right"). Left/right are 3/4 of screen width on mobile, capped at sm:max-w-sm.
import {
Popover, PopoverTrigger, PopoverContent,
Button, Input, Label,
} from "@pplethai/components";
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Dimensions</Button>
</PopoverTrigger>
<PopoverContent align="start" side="bottom">
<div className="space-y-2">
<Label htmlFor="w">Width</Label>
<Input id="w" defaultValue="100%" />
</div>
</PopoverContent>
</Popover>PopoverContent props: align: "start" | "center" | "end" (default "center"), side: "top" | "right" | "bottom" | "left" (default "bottom"), sideOffset: number (default 4).
PopoverAnchor is exported for cases where the trigger is separate from the anchor.
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel,
DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
DropdownMenuShortcut, DropdownMenuGroup,
Button,
} from "@pplethai/components";
const [showBar, setShowBar] = useState(true);
const [pos, setPos] = useState("bottom");
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Invite team</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Email</DropdownMenuItem>
<DropdownMenuItem>Copy link</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={showBar} onCheckedChange={setShowBar}>
Status bar
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={pos} onValueChange={setPos}>
<DropdownMenuRadioItem value="top">Top</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bottom">Bottom</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>Overlay decision tree:
- Need to interrupt the user (confirm, modal form)? → Dialog
- Side panel with substantial content? → Sheet
- Small float anchored to a trigger? → Popover (form) or DropdownMenu (commands)
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@pplethai/components";
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="account">Account panel</TabsContent>
<TabsContent value="security">Security panel</TabsContent>
</Tabs>Active trigger gets the primary gradient. Each TabsContent value must match a TabsTrigger value. Keyboard: ←→/Home/End.
import {
Accordion, AccordionItem, AccordionTrigger, AccordionContent,
} from "@pplethai/components";
<Accordion type="single" collapsible>
<AccordionItem value="q1">
<AccordionTrigger>What is X?</AccordionTrigger>
<AccordionContent>Answer.</AccordionContent>
</AccordionItem>
<AccordionItem value="q2">
<AccordionTrigger>What is Y?</AccordionTrigger>
<AccordionContent>Answer.</AccordionContent>
</AccordionItem>
</Accordion>type: "single" | "multiple" (required). With "single", collapsible enables closing the currently-open item.
import {
Breadcrumb, BreadcrumbList, BreadcrumbItem,
BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage,
BreadcrumbEllipsis,
} from "@pplethai/components";
import { Slash } from "lucide-react";
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator><Slash /></BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage>Current</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>Use BreadcrumbPage (not BreadcrumbLink) for the current location — it sets aria-current="page". For React Router integration:
import { Link } from "react-router-dom";
<BreadcrumbLink asChild>
<Link to="/products">Products</Link>
</BreadcrumbLink>import {
NavigationMenu, NavigationMenuList, NavigationMenuItem,
NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink,
} from "@pplethai/components";
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[420px] gap-2 p-4 md:grid-cols-2">
<li>
<NavigationMenuLink href="/a" className="block rounded-md p-3 hover:bg-muted">
<div className="font-heading text-sm font-medium">A</div>
<p className="mt-1 text-xs text-muted-foreground">Description</p>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>For simple navbars use Navbar (§5.3). NavigationMenu is for desktop mega-menus.
Step indicator for multi-step forms / wizards. Compound API — render one <StepperItem> per step. value is the 0-indexed current step; earlier indices render as completed (checkmark + primary fill), the matching index is current, later indices are upcoming (muted).
import { Stepper, StepperItem } from "@pplethai/components";
// Horizontal (default)
<Stepper value={1}>
<StepperItem title="Account" description="Email & password" />
<StepperItem title="Profile" description="Name & avatar" />
<StepperItem title="Confirm" />
</Stepper>
// Vertical
<Stepper value={0} orientation="vertical">
<StepperItem title="Step one" description="…" />
<StepperItem title="Step two" />
</Stepper>
// All steps completed — set value to step count
<Stepper value={3}>
<StepperItem title="A" />
<StepperItem title="B" />
<StepperItem title="C" />
</Stepper>Stepper prop |
Type | Default |
|---|---|---|
value |
number (0-indexed current step) |
— (required) |
orientation |
"horizontal" | "vertical" |
"horizontal" |
StepperItem prop |
Type |
|---|---|
title |
ReactNode |
description? |
ReactNode |
The current step receives aria-current="step". Stepper is presentational — wire next/back buttons and step state in your own component.
Multi-step form pattern:
import { useState } from "react";
import {
Button, Card, CardContent, Inline, Stack, Stepper, StepperItem,
} from "@pplethai/components";
const STEPS = [
{ title: "Account", description: "Email & password" },
{ title: "Profile", description: "Name & avatar" },
{ title: "Confirm" },
];
function MultiStepForm() {
const [step, setStep] = useState(0);
const isLast = step === STEPS.length - 1;
return (
<Stack gap="lg">
<Stepper value={step}>
{STEPS.map((s) => (
<StepperItem key={s.title} title={s.title} description={s.description} />
))}
</Stepper>
<Card><CardContent className="pt-6">{/* fields per step */}</CardContent></Card>
<Inline justify="between">
<Button
variant="outline"
onClick={() => setStep((s) => Math.max(0, s - 1))}
disabled={step === 0}
>
Back
</Button>
<Button onClick={() => setStep((s) => s + 1)}>
{isLast ? "Finish" : "Next"}
</Button>
</Inline>
</Stack>
);
}import { Alert, AlertTitle, AlertDescription } from "@pplethai/components";
import { Info, AlertCircle, Terminal } from "lucide-react";
<Alert>
<Terminal />
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>default variant uses muted background.</AlertDescription>
</Alert>
<Alert variant="primary">
<Info />
<AlertTitle>Info</AlertTitle>
<AlertDescription>primary variant uses brand orange.</AlertDescription>
</Alert>
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong.</AlertDescription>
</Alert>variant: "default" | "primary" | "destructive". Sets role="alert" — screen readers announce when it appears. For ephemeral notifications use Toast instead.
import { Badge } from "@pplethai/components";
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Error</Badge>
<Badge variant="outline">Outline</Badge>badgeVariants is exported as a CVA factory.
import { Progress } from "@pplethai/components";
<Progress value={66} /> {/* determinate */}
<Progress value={100} /> {/* complete (no shimmer) */}
<Progress /> {/* indeterminate — animated shimmer */}When value < max or value omitted, the bar runs an animated shimmer (auto-disabled under prefers-reduced-motion).
Circular loader. Omit value for an infinite spin (indeterminate); pass value (0..max) for a determinate arc.
import { Spinner } from "@pplethai/components";
<Spinner /> {/* indeterminate, default size */}
<Spinner size="lg" /> {/* indeterminate, large */}
<Spinner value={66} /> {/* determinate (arc proportional to value) */}
<Spinner className="text-secondary" /> {/* recolor via text-* (uses currentColor) */}
// In a button while submitting
<Button disabled>
<Spinner size="sm" className="text-primary-foreground" />
Saving…
</Button>| Prop | Type | Default | Notes |
|---|---|---|---|
value |
number | null |
undefined |
Omit → indeterminate spin; 0..max → determinate arc |
max |
number |
100 |
Maximum when determinate |
size |
"sm" | "default" | "lg" | "xl" |
"default" |
16 / 24 / 32 / 48 px |
strokeWidth |
number |
2.5 |
Stroke thickness inside a 24×24 viewBox |
label |
string |
"Loading" |
aria-label for screen readers |
Indeterminate uses Tailwind's animate-spin (respects prefers-reduced-motion). Determinate switches role to progressbar with aria-valuemin/max/now; indeterminate uses role="status" + aria-live="polite". Color inherits currentColor — tint with text-* classes.
import { Skeleton } from "@pplethai/components";
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-32 w-full" />Style with Tailwind to match the real content's shape.
Step 1 — once, at app root:
import { Toaster } from "@pplethai/components";
// in your root component:
<Toaster />Step 2 — anywhere:
import { showToast } from "@pplethai/components";
showToast({
variant: "success", // "default" | "success" | "error" | "warning" | "info" | "loading"
title: "Saved",
description: "Your changes were saved.",
});ShowToastOptions:
| Field | Type | Required |
|---|---|---|
title |
string |
✓ |
description |
string |
— |
variant |
ToastVariant |
— (default "default") |
The raw toast from sonner is also re-exported for advanced cases (promise toasts, custom JSX): import { toast } from "@pplethai/components".
import {
Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter, Button,
} from "@pplethai/components";
<Card>
<CardHeader>
<CardTitle>Account settings</CardTitle>
<CardDescription>Manage your profile and security.</CardDescription>
</CardHeader>
<CardContent>
{/* body */}
</CardContent>
<CardFooter className="justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</CardFooter>
</Card>CardTitle is a div styled with font-heading text-2xl. Wrap in an <h2> if you need semantic heading order — or render as a heading via asChild-style composition (Card sub-components don't support asChild; use a plain heading + Tailwind if semantics matter).
import { Separator, Inline } from "@pplethai/components";
<Separator /> {/* horizontal */}
<Separator orientation="vertical" className="h-6" /> {/* vertical (set height!) */}
<Inline gap="md" align="center">
<span>Left</span>
<Separator orientation="vertical" className="h-6" />
<span>Right</span>
</Inline>decorative defaults to true (adds aria-hidden). Set to false for semantic separator.
The /form entry point provides controlled field components that wire up RHF + Radix + error display. Use these for any form — manual wiring of <Input> + <Label> + <FormMessage> is rarely necessary.
import { Button, Card, CardContent, CardHeader, CardTitle, Stack } from "@pplethai/components";
import {
Form,
FormTextField,
FormSelectField,
FormCheckboxField,
type SelectOption,
} from "@pplethai/components/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const provinces: SelectOption[] = [
{ value: "bkk", label: "Bangkok" },
{ value: "cnx", label: "Chiang Mai" },
];
const schema = z.object({
name: z.string().min(2, "Name must be ≥ 2 chars"),
email: z.string().email("Invalid email"),
province: z.string().min(1, "Required"),
subscribe: z.boolean().default(false),
});
type Values = z.infer<typeof schema>;
export function SignupForm() {
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "", province: "", subscribe: false },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => console.log(data))} className="space-y-4">
<FormTextField
control={form.control}
name="name"
label="Name"
placeholder="Your name"
/>
<FormTextField
control={form.control}
name="email"
label="Email"
type="email"
description="We'll only use this to verify your account."
/>
<FormSelectField
control={form.control}
name="province"
label="Province"
placeholder="Choose…"
options={provinces}
/>
<FormCheckboxField
control={form.control}
name="subscribe"
label="Subscribe to newsletter"
description="Monthly digest."
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}All accept control, name, label, description? plus their type-specific props.
| Component | Extra props |
|---|---|
FormTextField |
All Input props (type, placeholder, …) |
FormSelectField |
options: SelectOption[], placeholder?: string |
FormCheckboxField |
(none) |
Errors appear automatically under each field via <FormMessage> (built into each shorthand).
For inputs not covered by the shorthands (e.g. RadioGroup, Switch, Slider, MultiSelect), compose with the lower-level primitives:
import {
Form, FormControl, FormDescription, FormField,
FormItem, FormLabel, FormMessage,
} from "@pplethai/components/form";
import { Switch } from "@pplethai/components";
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Enable feature</FormLabel>
<FormDescription>Turn on the new behavior.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>useFormField() is also exported for cases where you need to read field state inside a custom render.
All colors are HSL CSS variables. Reference them as bg-primary, text-secondary, border-destructive, etc. — Tailwind config maps primary → hsl(var(--primary)).
| Token | Tailwind class | Hex (light) | Use |
|---|---|---|---|
--primary |
bg-primary text-primary |
#FF6A13 |
CTAs, focus rings |
--secondary |
bg-secondary |
#002B49 |
Navigation, structural UI |
--destructive |
bg-destructive |
#C13200 |
Errors, destructive actions |
--foreground |
text-foreground |
#212121 |
Primary text |
--muted-foreground |
text-muted-foreground |
#464646 |
Secondary text |
--background |
bg-background |
#FFFFFF |
Page background |
--muted |
bg-muted |
#F5F5F5 |
Inactive backgrounds |
--border |
border-border |
#E6E6E6 |
Borders |
--ring |
ring-ring |
#FF6A13 |
Focus rings |
Each has a *-foreground companion for contrast. Dark mode (html.dark) flips these — never hardcode hex.
<div className="bg-gradient-primary">…</div> {/* orange gradient */}
<div className="bg-gradient-secondary">…</div> {/* navy gradient */}
<div className="bg-gradient-brand">…</div> {/* navy → orange */}
<div className="bg-gradient-destructive">…</div> {/* red gradient */}Button-specific variants (bg-gradient-primary-button, bg-gradient-secondary-button) include extra-bright tones and pair with .gradient-hover-animate.
The gradients object is also exported for programmatic access:
import { gradients, type GradientToken } from "@pplethai/components";
const css = gradients.primary; // CSS variable reference| Class | Font | Use |
|---|---|---|
font-heading |
Anakotmai (medium) | Headings, button labels |
font-body |
IBM Plex Sans Thai Looped | Body text (applied to <body> automatically) |
Type scale is multiplied by 1.2× (Thai-friendly sizes). Body line-height defaults to 1.65.
- Border radius:
rounded-lg=var(--radius)(0.5rem).mdandsmare derived. - Gap tokens:
gap-1(4),gap-2(8),gap-4(16),gap-6(24). UseStack/Inlinegapprops instead of rawgap-*. - Container breakpoints:
sm640,md768,lg1280.
| Factory | From | Notes |
|---|---|---|
buttonVariants |
@pplethai/components |
extend Button styles |
badgeVariants |
@pplethai/components |
extend Badge |
alertVariants |
@pplethai/components |
extend Alert |
iconVariants |
@pplethai/components |
extend Icon |
logoVariants |
@pplethai/components |
extend Logo |
gapVariants |
@pplethai/components |
shared gap scale (none/xs/sm/md/lg) |
containerVariants |
@pplethai/components |
shared container sizes |
import { buttonVariants, cn } from "@pplethai/components";
// Style an <a> like a button
<a href="/x" className={cn(buttonVariants({ variant: "outline" }), "no-underline")}>
Go
</a>These are recipes — not exported components. Build them in your app from primitives.
import { Container, Inline, Stack, Button, Input, Label, Separator } from "@pplethai/components";
import { NavLink } from "react-router-dom";
<Container size="lg" className="py-8">
<Inline gap="lg" align="start" className="flex-nowrap">
<aside className="hidden w-56 shrink-0 md:block">
<Stack gap="xs" as="nav">
<NavLink to="/settings/profile" className="rounded-md px-2 py-1 hover:bg-muted">
Profile
</NavLink>
<NavLink to="/settings/security" className="rounded-md px-2 py-1 hover:bg-muted">
Security
</NavLink>
</Stack>
</aside>
<main className="min-w-0 flex-1">
<Stack gap="lg">
<header>
<h1 className="font-heading text-2xl">Profile</h1>
<p className="text-muted-foreground">Manage your public details.</p>
</header>
<Separator />
{/* fields */}
</Stack>
</main>
</Inline>
</Container>Use Tailwind grid (neither Stack nor Inline enforces column count).
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<Card key={item.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">{item.title}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<CardDescription>{item.desc}</CardDescription>
</CardContent>
</Card>
))}
</div>function FormSection({
title,
description,
children,
}: { title: string; description: string; children: React.ReactNode }) {
return (
<Stack gap="md">
<div>
<h3 className="font-heading text-base font-medium">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Stack gap="sm">{children}</Stack>
</Stack>
);
}<Container size="md" className="py-6">
<Stack gap="lg">
<header>
<h1 className="font-heading text-xl">Settings</h1>
<p className="text-sm text-muted-foreground">Account and privacy.</p>
</header>
<Separator />
<Inline justify="between" align="start">
<Stack gap="xs">
<p className="font-medium">Public profile</p>
<p className="text-sm text-muted-foreground">Let others find you.</p>
</Stack>
<Switch defaultChecked />
</Inline>
<Separator />
{/* more rows */}
</Stack>
</Container><div className="min-h-screen bg-gradient-brand">
<Container size="sm" className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader>
<Inline gap="sm" align="center">
<Logo size="sm" className="text-primary" />
<CardTitle>Sign in</CardTitle>
</Inline>
</CardHeader>
<CardContent>
<Stack gap="md">
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" className="mt-1" />
</div>
<div>
<Label htmlFor="pwd">Password</Label>
<Input id="pwd" type="password" className="mt-1" />
</div>
</Stack>
</CardContent>
<CardFooter className="flex-col items-stretch gap-2">
<Button className="w-full"><Lock />Sign in</Button>
<p className="text-center text-xs text-muted-foreground">
No account? <a href="/signup" className="text-primary">Sign up</a>
</p>
</CardFooter>
</Card>
</Container>
</div><Stack gap="lg">
<Inline justify="between" align="center">
<Stack gap="xs">
<h1 className="font-heading text-2xl">Overview</h1>
<p className="text-sm text-muted-foreground">Updated today</p>
</Stack>
<Button><Plus />New</Button>
</Inline>
<div className="grid gap-3 sm:grid-cols-3">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">{s.label}</p>
<Inline gap="sm" align="end" className="mt-1">
<p className="font-heading text-2xl">{s.value}</p>
<Badge variant={s.trend.startsWith("-") ? "destructive" : "default"}>
{s.trend}
</Badge>
</Inline>
</CardContent>
</Card>
))}
</div>
</Stack>- ❌ Don't hardcode brand colors as hex (
bg-[#FF6A13]). Always use tokens (bg-primary). - ❌ Don't reach past
cn(). Don't doclassName={"px-4 " + extra}—tailwind-mergewon't dedupe. - ❌ Don't omit
DialogTitle/SheetTitle. Radix logs a console warning; screen readers need it. - ❌ Don't use raw
<button>for triggers when the component expectsasChild— wrap your<Button>/<a>and passasChild. - ❌ Don't import from
sonnerdirectly for app code — useshowToastandToasterfrom@pplethai/componentsso styling is consistent.toastis re-exported for advanced cases only. - ❌ Don't nest
ContainerinsideContainer— you'll get doubled horizontal padding. - ❌ Don't forget
pathnameon<Navbar>if you want active states or mobile-menu-on-route-change to work. - ❌ Don't pass a number to
<Slider value=…>— it's alwaysnumber[](single-value sliders use a one-element array). - ❌ Don't try to make
MultiSelectuncontrolled.value+onValueChangeare both required. - ❌ Don't wrap
<Label>with bothhtmlForand a child input simultaneously. Pick one. - ❌ Don't use
Switchfor fields that submit later — that'sCheckbox. Switch is for "applies on toggle" settings. - ❌ Don't render
<Toaster />more than once. It's a singleton; multiple instances duplicate toasts.
From @pplethai/components:
// Utilities
cn, gapVariants, containerVariants, gradients
type GapVariants, type ContainerVariants, type GradientToken
// Icon & branding
Icon, iconVariants, type IconProps
Logo, logoVariants, type LogoProps
Navbar, navLinkClassName, type NavbarHome, type NavbarHomeLinkRenderProps,
type NavbarItem, type NavbarLinkRenderProps, type NavbarProps
// Layout
Stack, type StackProps
Inline, type InlineProps
Container, type ContainerProps
// UI components
Button, buttonVariants, type ButtonProps
Input, type InputProps
Label
Textarea, type TextareaProps
Checkbox
RadioGroup, RadioGroupItem
Switch
Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
SelectScrollDownButton, SelectScrollUpButton, SelectSeparator,
SelectTrigger, SelectValue
MultiSelect, type MultiSelectOption, type MultiSelectProps
Autocomplete, type AutocompleteOption, type AutocompleteProps,
type AutocompleteSingleProps, type AutocompleteMultipleProps
Popover, PopoverAnchor, PopoverContent, PopoverTrigger
Slider
Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
Separator
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter,
SheetHeader, SheetTitle, SheetTrigger, type SheetContentProps
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel,
DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
Tabs, TabsContent, TabsList, TabsTrigger
Accordion, AccordionContent, AccordionItem, AccordionTrigger
Alert, AlertDescription, AlertTitle, alertVariants
Badge, badgeVariants, type BadgeProps
Toaster, showToast, toast, type ShowToastOptions, type ToastVariant
Skeleton
Progress
Spinner, spinnerVariants, type SpinnerProps
Stepper, StepperItem, type StepperProps, type StepperItemProps
Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink,
BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator
NavigationMenu, NavigationMenuContent, NavigationMenuIndicator,
NavigationMenuItem, NavigationMenuLink, NavigationMenuList,
NavigationMenuTrigger, NavigationMenuViewport
From @pplethai/components/form:
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
useFormField
FormCheckboxField, FormSelectField, FormTextField, type SelectOption