# Parlemen UI > Soft clay React components you copy into your app. Built on Radix, Tailwind v4 and motion, installable with the shadcn CLI. Semantic colour roles, light and dark, Phosphor icons. ## Rules for assistants - Install with the shadcn CLI. Add the registry once: `npx shadcn@latest registry add "@parlemen=https://ui.parlemen.dev/r/{name}.json"`, then `npx shadcn@latest add @parlemen/`. Projects must be initialised with `npx shadcn@latest init -b radix`. - Install `@parlemen/theme @parlemen/providers` first. Theme ships the tokens (tonal scales, semantic roles, light + dark, clay shadows) as app/parlemen.css. In app/layout.tsx import "./parlemen.css" instead of globals.css, load Bricolage_Grotesque (--font-display) and JetBrains_Mono (--font-code) from next/font/google, add suppressHydrationWarning to , and wrap children in from @/components/ui/providers. - Use semantic colour classes only: `bg-primary text-on-primary`, `bg-primary-container text-on-primary-container`, `bg-surface`, `bg-surface-sunken`, `text-on-surface`, `text-on-surface-muted`. Roles: primary, secondary, tertiary, accent, success, warning, danger, info. Never raw hex or palette classes. - Clay look: raised = `bg-surface shadow-clay-sm`, sunken = `bg-surface-sunken shadow-well`. No hard borders. Rounded everything. - Icons: Phosphor only (`@phosphor-icons/react`, `XxxIcon`). weight="bold" by default, "fill" for active, "duotone" for big decorative icons. Never hand-draw SVG icons. - Motion: `motion/react` with presets from `@/lib/motion` (springs.snappy | squish | bouncy | gentle). - Components take `color` (role) and `variant` (solid | soft | outline | ghost) props, e.g. ` Yakin mau bubar? Semua agenda dan notulen fraksi akan hilang selamanya. Tidak bisa dibatalkan. ); } ``` ## Source (components/ui/alert-dialog.tsx) ```tsx "use client"; import * as React from "react"; import * as A from "@radix-ui/react-alert-dialog"; import { AnimatePresence, motion } from "motion/react"; import { springs } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; const AlertCtx = React.createContext(false); /** Same API as Radix AlertDialog. Tracks open state so content can animate out. */ export function AlertDialog({ open, defaultOpen, onOpenChange, ...p }: React.ComponentProps) { const [inner, setInner] = React.useState(defaultOpen ?? false); const current = open ?? inner; return ( { setInner(v); onOpenChange?.(v); }} {...p} /> ); } export const AlertDialogTrigger = A.Trigger; export function AlertDialogContent({ className, children, ...p }: React.ComponentProps) { const open = React.useContext(AlertCtx); return ( {open && (
{children}
)}
); } export function AlertDialogHeader({ className, ...p }: React.ComponentProps<"div">) { return
; } export function AlertDialogFooter({ className, ...p }: React.ComponentProps<"div">) { return
; } export function AlertDialogTitle({ className, ...p }: React.ComponentProps) { return ; } export function AlertDialogDescription({ className, ...p }: React.ComponentProps) { return ; } /** Plain Radix Action. Use `asChild` with a Button to pick a colour, or it gets the default primary button style. */ export function AlertDialogAction({ className, asChild, ...p }: React.ComponentProps) { return ; } export function AlertDialogCancel({ className, asChild, ...p }: React.ComponentProps) { return ; } ``` # Aspect Ratio > Keep media at a fixed ratio inside a clay frame. Docs: https://ui.parlemen.dev/docs/aspect-ratio ## Installation ```bash npx shadcn@latest add @parlemen/aspect-ratio ``` ## Usage ```tsx import { AspectRatio } from "@/components/ui/aspect-ratio"; export default function AspectRatioDemo() { return (
16 / 9
{/* eslint-disable-next-line @next/next/no-img-element */} Parlemen mascot
); } ``` ## Source (components/ui/aspect-ratio.tsx) ```tsx "use client"; import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; export function AspectRatio(props: React.ComponentProps) { return ; } ``` # Avatar > Clay circles with initials, plus a group that fans out. Docs: https://ui.parlemen.dev/docs/avatar ## Installation ```bash npx shadcn@latest add @parlemen/avatar ``` ## Usage ```tsx import { Avatar, AvatarFallback, AvatarGroup, AvatarImage } from "@/components/ui/avatar"; export default function AvatarDemo() { return (
RW AF SK {/* When there is no photo (or it fails to load) the fallback shows initials on a role colour. */} DM PA
BU DE RA NA +8

Hover the committee to fan them out

); } ``` ## Source (components/ui/avatar.tsx) ```tsx "use client"; import * as React from "react"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; import { motion } from "motion/react"; import { springs } from "@/lib/motion"; import { cn } from "@/lib/utils"; type Color = "primary" | "secondary" | "tertiary" | "accent" | "success" | "warning" | "danger" | "info"; const container: Record = { primary: "bg-primary-container text-on-primary-container", secondary: "bg-secondary-container text-on-secondary-container", tertiary: "bg-tertiary-container text-on-tertiary-container", accent: "bg-accent-container text-on-accent-container", success: "bg-success-container text-on-success-container", warning: "bg-warning-container text-on-warning-container", danger: "bg-danger-container text-on-danger-container", info: "bg-info-container text-on-info-container", }; const sizes = { sm: "size-8 text-xs", default: "size-11 text-sm", lg: "size-16 text-lg" } as const; export function Avatar({ className, size = "default", ...p }: React.ComponentProps & { size?: keyof typeof sizes }) { return ( ); } export function AvatarImage({ className, ...p }: React.ComponentProps) { return ; } export function AvatarFallback({ className, color = "primary", ...p }: Omit, "color"> & { color?: Color }) { return ( ); } /** Stacked avatars that fan out on hover. */ export function AvatarGroup({ className, children, ...p }: React.HTMLAttributes) { const [hover, setHover] = React.useState(false); const items = React.Children.toArray(children); return (
setHover(true)} onPointerLeave={() => setHover(false)} {...p} > {items.map((child, i) => ( {child} ))}
); } ``` # Badge > Small pill for status and labels. Docs: https://ui.parlemen.dev/docs/badge ## Installation ```bash npx shadcn@latest add @parlemen/badge ``` ## Usage ```tsx import { CheckCircleIcon, ClockIcon, SparkleIcon, XCircleIcon } from "@phosphor-icons/react/dist/ssr"; import { Badge } from "@/components/ui/badge"; const colors = ["primary", "secondary", "tertiary", "accent", "success", "warning", "danger", "info", "neutral"] as const; export default function BadgeDemo() { return (
Live Pending New Ditolak
{(["solid", "soft"] as const).map((variant) => (
{colors.map((color) => ( {color} ))}
))}
); } ``` ## Source (components/ui/badge.tsx) ```tsx import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const colors = ["primary", "secondary", "tertiary", "accent", "success", "warning", "danger", "info", "neutral"] as const; type Color = (typeof colors)[number]; const solid: Record = { primary: "bg-primary text-on-primary", secondary: "bg-secondary text-on-secondary", tertiary: "bg-tertiary text-on-tertiary", accent: "bg-accent text-on-accent", success: "bg-success text-on-success", warning: "bg-warning text-on-warning", danger: "bg-danger text-on-danger", info: "bg-info text-on-info", neutral: "bg-surface text-on-surface", }; const soft: Record = { primary: "bg-primary-container text-on-primary-container", secondary: "bg-secondary-container text-on-secondary-container", tertiary: "bg-tertiary-container text-on-tertiary-container", accent: "bg-accent-container text-on-accent-container", success: "bg-success-container text-on-success-container", warning: "bg-warning-container text-on-warning-container", danger: "bg-danger-container text-on-danger-container", info: "bg-info-container text-on-info-container", neutral: "bg-surface-sunken text-on-surface", }; const badgeVariants = cva("inline-flex items-center gap-1 rounded-full px-3 py-0.5 text-xs font-bold [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0", { variants: { variant: { solid: "shadow-clay-sm", soft: "" }, color: Object.fromEntries(colors.map((c) => [c, ""])) as Record, }, compoundVariants: colors.flatMap((color) => [ { variant: "solid" as const, color, className: solid[color] }, { variant: "soft" as const, color, className: soft[color] }, ]), defaultVariants: { variant: "solid", color: "primary" }, }); export function Badge({ className, variant, color, ...p }: Omit, "color"> & VariantProps) { return ; } export { badgeVariants }; ``` # Brand > Live mascot and rolling wordmark from the company site. Docs: https://ui.parlemen.dev/docs/brand ## Installation ```bash npx shadcn@latest add @parlemen/brand ``` ## Usage ```tsx "use client"; import { BrandMark, LiveLogo, RollWordmark } from "@/components/ui/brand"; export default function BrandDemo() { return (
); } ``` ## Source (components/ui/brand.tsx) ```tsx "use client"; import * as React from "react"; import { motion, useAnimationControls } from "motion/react"; import { springs } from "@/lib/motion"; import { cn } from "@/lib/utils"; /* Ported from the company profile site (web/components/Accents.tsx) so the brand behaves the same everywhere. */ const BODY = "M280.0 46.0C287.4 46.2 311.9 47.1 323.3 48.3C334.8 49.4 340.1 50.8 348.4 52.8C356.8 54.9 365.9 57.8 373.6 60.8C381.2 63.9 386.9 66.5 394.1 71.1C401.3 75.7 410.8 82.9 416.9 88.2C423.0 93.6 426.2 97.5 430.6 103.1C435.0 108.6 439.8 115.6 443.2 121.3C446.6 127.0 448.5 130.6 451.2 137.3C453.8 144.0 457.1 152.7 459.2 161.3C461.2 169.8 463.0 180.5 463.7 188.7C464.5 196.8 464.3 202.7 463.7 210.3C463.1 218.0 462.0 226.3 460.3 234.3C458.6 242.3 456.5 250.3 453.4 258.3C450.4 266.3 446.2 275.0 442.0 282.3C437.8 289.5 433.5 295.6 428.3 301.7C423.2 307.7 416.9 313.8 411.2 318.8C405.5 323.7 400.6 327.3 394.1 331.3C387.6 335.3 379.6 339.5 372.4 342.7C365.2 346.0 358.1 348.4 350.7 350.7C343.3 353.0 335.7 354.9 327.9 356.4C320.1 358.0 315.2 359.3 303.9 359.9C292.7 360.4 270.8 360.2 260.6 359.9C250.3 359.5 245.5 352.4 242.3 357.6C239.1 362.7 242.5 378.7 241.2 390.7C239.8 402.7 236.8 420.3 234.3 429.5C231.8 438.6 228.8 441.7 226.3 445.5C223.9 449.3 222.3 450.2 219.5 452.3C216.6 454.4 214.7 456.1 209.2 458.0C203.7 459.9 193.6 462.4 186.4 463.7C179.2 465.0 177.1 465.8 165.8 466.0C154.6 466.2 128.7 465.4 119.0 464.9C109.3 464.3 112.8 464.3 107.6 462.6C102.5 460.9 92.8 456.9 88.2 454.6C83.7 452.3 82.9 451.4 80.2 448.9C77.6 446.4 74.5 443.6 72.3 439.8C70.0 435.9 67.7 431.2 66.5 426.1C65.4 420.9 65.0 416.2 65.4 408.9C65.8 401.7 66.5 395.6 68.8 382.7C71.1 369.8 77.4 344.1 79.1 331.3C80.8 318.6 79.9 313.8 79.1 306.2C78.3 298.6 78.3 297.3 74.5 285.7C70.7 274.1 60.6 250.5 56.3 236.6C51.9 222.7 49.4 214.5 48.3 202.4C47.1 190.2 48.1 173.8 49.4 163.6C50.8 153.3 54.4 146.1 56.3 140.7C58.2 135.4 58.6 135.4 60.8 131.6C63.1 127.8 64.3 124.0 70.0 117.9C75.7 111.8 86.7 101.2 95.1 95.1C103.4 89.0 111.8 85.4 120.2 81.4C128.6 77.4 136.2 74.3 145.3 71.1C154.4 67.9 164.3 64.8 175.0 62.0C185.6 59.1 195.3 56.5 209.2 54.0C223.1 51.5 246.7 48.3 258.3 47.1C269.9 46.0 275.2 47.3 278.8 47.1C282.4 47.0 272.5 45.8 280.0 46.0Z"; /** The P mascot. Pupils follow the cursor, blinks every few seconds, wiggles on hover. */ export function LiveLogo({ size = 40, className, upsideDown = false, }: { size?: number; className?: string; /** Hanging upside down (e.g. from the footer's cloud edge). Eye tracking is inverted to match. */ upsideDown?: boolean; }) { const pupils = React.useRef(null); const ref = React.useRef(null); const lids = useAnimationControls(); const clip = React.useId().replace(/:/g, ""); React.useEffect(() => { const move = (e: PointerEvent) => { const r = ref.current?.getBoundingClientRect(); if (!r || !pupils.current) return; const flip = upsideDown ? -1 : 1; const dx = flip * (e.clientX - (r.left + r.width / 2)); const dy = flip * (e.clientY - (r.top + r.height / 2)); const d = Math.hypot(dx, dy) || 1; const k = Math.min(1, d / 200); pupils.current.setAttribute("transform", `translate(${(dx / d) * 16 * k} ${(dy / d) * 14 * k})`); }; window.addEventListener("pointermove", move); const t = setInterval(() => lids.start({ scaleY: [0, 1, 0], transition: { duration: 0.22 } }), 3800); return () => { window.removeEventListener("pointermove", move); clearInterval(t); }; }, [lids, upsideDown]); return ( {/* Flip inside the SVG so motion's hover rotation on the element doesn't cancel it. */} ); } const rollColors = ["var(--coral)", "var(--blue)", "var(--lime)", "var(--grape)"]; /** * Letters roll up in a staggered wave on hover and are replaced by brand-colored copies. * Pass `hover` to drive it from a parent (e.g. hovering the whole logo link). */ export function RollWordmark({ text, hover, className }: { text: string; hover?: boolean; className?: string }) { const controlled = hover !== undefined; return ( {text.split("").map((ch, i) => ( {ch} {ch} ))} ); } /** Logo + wordmark lockup. Hovering anywhere on it rolls the wordmark. */ export function BrandMark({ text = "Parlemen UI", size = 36, className }: { text?: string; size?: number; className?: string }) { const [hover, setHover] = React.useState(false); return ( setHover(true)} onPointerLeave={() => setHover(false)} > ); } ``` # Breadcrumb > Shows where you are, with the current page as a soft pill. Docs: https://ui.parlemen.dev/docs/breadcrumb ## Installation ```bash npx shadcn@latest add @parlemen/breadcrumb ``` ## Usage ```tsx import { HouseIcon, FolderIcon } from "@phosphor-icons/react/dist/ssr"; import { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; export default function BreadcrumbDemo() { return ( Parlemen Komisi I RUU Perlindungan Data ); } ``` ## Source (components/ui/breadcrumb.tsx) ```tsx import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { CaretRightIcon, DotsThreeIcon } from "@phosphor-icons/react/dist/ssr"; import { cn } from "@/lib/utils"; export function Breadcrumb(p: React.ComponentProps<"nav">) { return