feat(web): bootstrap cadence ui foundation

This commit is contained in:
2026-03-20 11:22:42 +08:00
parent a7ef1e0154
commit ce9061ca54
35 changed files with 2945 additions and 7 deletions
@@ -0,0 +1,31 @@
{
"items": [
"alert",
"badge",
"button",
"card",
"dialog",
"form",
"input",
"tabs",
"textarea",
"tokens"
],
"packageDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0",
"react-hook-form": "^7.71.2",
"tailwind-merge": "^3.5.0"
},
"registry": "cadence-ui",
"sourcePackages": {
"@ai-ui/tokens": "0.0.0",
"@ai-ui/ui": "0.0.0"
},
"targetDir": "src/cadence-ui"
}
@@ -0,0 +1,77 @@
import { forwardRef } from "react";
import {
alertDescriptionVariants,
alertIconVariants,
alertTitleVariants,
alertVariants
} from "./alert.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type AlertProps = React.ComponentPropsWithoutRef<"div"> &
VariantProps<typeof alertVariants> & {
icon?: React.ReactNode;
};
export const Alert = forwardRef<HTMLDivElement, AlertProps>(function Alert(
{
children,
className,
icon,
variant = "default",
...props
},
ref
) {
const hasIcon = Boolean(icon);
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
"has-icon": hasIcon,
variant
})}
className={cn(alertVariants({ hasIcon, variant }), className)}
ref={ref}
role={props.role ?? "alert"}
>
{hasIcon ? <span {...createSlot("icon")} className={alertIconVariants()}>{icon}</span> : null}
{children}
</div>
);
});
export type AlertTitleProps = React.ComponentPropsWithoutRef<"h4">;
export const AlertTitle = forwardRef<HTMLHeadingElement, AlertTitleProps>(function AlertTitle(
{ className, ...props },
ref
) {
return (
<h4
{...props}
{...createSlot("label")}
className={cn(alertTitleVariants(), className)}
ref={ref}
/>
);
});
export type AlertDescriptionProps = React.ComponentPropsWithoutRef<"div">;
export const AlertDescription = forwardRef<HTMLDivElement, AlertDescriptionProps>(
function AlertDescription({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("description")}
className={cn(alertDescriptionVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,44 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const alertVariants = cva(
[
"relative grid gap-x-3 gap-y-1 rounded-[var(--radius-lg)] border p-4 shadow-[var(--shadow-xs)]",
"text-[var(--color-foreground)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
variant: {
default:
"border-[var(--color-border)] bg-[var(--color-card)]",
success:
"border-[color-mix(in_oklch,var(--color-success)_34%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--color-card))]",
warning:
"border-[color-mix(in_oklch,var(--color-warning)_34%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--color-card))]",
destructive:
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--color-card))]"
},
hasIcon: {
false: "",
true: "grid-cols-[auto_1fr]"
}
},
defaultVariants: {
variant: "default",
hasIcon: false
}
}
);
export const alertIconVariants = cva(
"row-span-2 mt-0.5 inline-flex size-5 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)]"
);
export const alertTitleVariants = cva(
"text-sm font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const alertDescriptionVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
@@ -0,0 +1,42 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef } from "react";
import { badgeVariants } from "./badge.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot, type AsChildProp } from "../lib/contracts";
export type BadgeProps = React.ComponentPropsWithoutRef<"span"> &
AsChildProp &
VariantProps<typeof badgeVariants>;
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
{
asChild = false,
children,
className,
size,
tone,
variant,
...props
},
ref
) {
const Component = asChild ? Slot : "span";
return (
<Component
{...props}
{...createSlot("root")}
{...createDataAttributes({
size,
tone,
variant
})}
className={cn(badgeVariants({ size, tone, variant }), className)}
ref={ref}
>
{asChild ? <Slottable>{children}</Slottable> : <span {...createSlot("label")}>{children}</span>}
</Component>
);
});
@@ -0,0 +1,42 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const badgeVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-[var(--radius-full)] border font-medium",
"outline-none select-none",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
size: {
sm: "min-h-6 px-2 py-0.5 text-[0.7rem]",
md: "min-h-7 px-2.5 py-1 text-xs"
},
variant: {
subtle:
"border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)]",
solid:
"border-transparent bg-[var(--color-foreground)] text-[var(--color-background)]",
outline:
"border-[var(--color-border-strong)] bg-transparent text-[var(--color-foreground)]"
},
tone: {
neutral: "",
primary:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-card))] data-[variant=subtle]:text-[var(--color-primary)] data-[variant=solid]:bg-[var(--color-primary)] data-[variant=solid]:text-[var(--color-primary-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-primary)_38%,var(--color-border-strong))] data-[variant=outline]:text-[var(--color-primary)]",
success:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-success)_14%,var(--color-card))] data-[variant=subtle]:text-[color-mix(in_oklch,var(--color-success)_78%,var(--color-foreground))] data-[variant=solid]:bg-[var(--color-success)] data-[variant=solid]:text-[var(--color-success-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-success)_38%,var(--color-border-strong))] data-[variant=outline]:text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]",
warning:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-warning)_18%,var(--color-card))] data-[variant=subtle]:text-[color-mix(in_oklch,var(--color-warning)_70%,var(--color-foreground))] data-[variant=solid]:bg-[var(--color-warning)] data-[variant=solid]:text-[var(--color-warning-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-warning)_40%,var(--color-border-strong))] data-[variant=outline]:text-[color-mix(in_oklch,var(--color-warning)_70%,var(--color-foreground))]",
destructive:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-destructive)_12%,var(--color-card))] data-[variant=subtle]:text-[var(--color-destructive)] data-[variant=solid]:bg-[var(--color-destructive)] data-[variant=solid]:text-[var(--color-destructive-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--color-border-strong))] data-[variant=outline]:text-[var(--color-destructive)]"
}
},
defaultVariants: {
size: "md",
tone: "neutral",
variant: "subtle"
}
}
);
@@ -0,0 +1,120 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { buttonVariants } from "./button.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { ButtonLikeElementProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type ButtonProps = Omit<
ButtonLikeElementProps,
"onDrag" | "onDragEnd" | "onDragStart"
> &
VariantProps<typeof buttonVariants>;
function Spinner() {
return (
<motion.span
{...createSlot("icon")}
aria-hidden="true"
animate={{ opacity: 1, rotate: 0, scale: 1 }}
className="size-4 rounded-full border-2 border-current border-r-transparent animate-spin"
exit={{ opacity: 0, rotate: 90, scale: 0.7 }}
initial={{ opacity: 0, rotate: -90, scale: 0.7 }}
transition={{
duration: 0.18,
ease: [0.22, 1, 0.36, 1]
}}
/>
);
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
asChild = false,
children,
className,
disabled,
loading = false,
onMouseEnter,
onMouseLeave,
size,
type,
variant,
...props
},
ref
) {
const prefersReducedMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState(false);
const isDisabled = disabled || loading;
const Component = asChild ? Slot : "button";
const baseClassName = cn(buttonVariants({ loading, size, variant }), className);
const label = asChild ? (
<Slottable>{children}</Slottable>
) : (
<motion.span
{...createSlot("label")}
animate={{
opacity: loading ? 0.94 : 1,
x: loading ? 1.5 : 0
}}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.18,
ease: [0.22, 1, 0.36, 1]
}}
>
{children}
</motion.span>
);
const sheen = !asChild ? (
<motion.span
animate={
prefersReducedMotion || isDisabled
? { opacity: 0, x: "-120%" }
: isHovered
? { opacity: 0.75, x: "115%" }
: { opacity: 0, x: "-120%" }
}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-1/2 rounded-[inherit] bg-[linear-gradient(120deg,transparent_0%,rgba(255,255,255,0.32)_45%,transparent_100%)] mix-blend-screen"
initial={false}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.55,
ease: [0.16, 1, 0.3, 1]
}}
/>
) : null;
return (
<Component
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled: isDisabled,
loading,
size,
variant
})}
className={baseClassName}
disabled={asChild ? undefined : isDisabled}
onMouseEnter={(event) => {
setIsHovered(true);
onMouseEnter?.(event as never);
}}
onMouseLeave={(event) => {
setIsHovered(false);
onMouseLeave?.(event as never);
}}
ref={ref}
type={asChild ? undefined : type ?? "button"}
>
{sheen}
<AnimatePresence initial={false}>{loading ? <Spinner /> : null}</AnimatePresence>
{label}
</Component>
);
});
@@ -0,0 +1,43 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const buttonVariants = cva(
[
"relative isolate inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden whitespace-nowrap",
"rounded-[var(--radius-sm)] border font-medium select-none",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
"focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:pointer-events-none disabled:opacity-55",
getMotionRecipeClassNames("pressable", "ring")
],
{
variants: {
size: {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-5 text-base",
icon: "h-10 w-10 text-sm"
},
variant: {
primary:
"border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[var(--shadow-xs)]",
secondary:
"border-[var(--color-border-strong)] bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)]",
ghost:
"border-transparent bg-transparent text-[var(--color-foreground)] hover:bg-[var(--color-surface)]",
subtle:
"border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)] shadow-[var(--shadow-xs)]",
destructive:
"border-transparent bg-[var(--color-destructive)] text-[var(--color-destructive-foreground)] shadow-[var(--shadow-xs)]"
},
loading: {
false: "",
true: "cursor-wait"
}
},
defaultVariants: {
size: "md",
variant: "primary"
}
}
);
+119
View File
@@ -0,0 +1,119 @@
import { forwardRef } from "react";
import {
cardContentVariants,
cardDescriptionVariants,
cardFooterVariants,
cardHeaderVariants,
cardTitleVariants,
cardVariants
} from "./card.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type CardProps = React.ComponentPropsWithoutRef<"div"> &
VariantProps<typeof cardVariants>;
export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
{
className,
interactive,
tone,
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
interactive,
tone
})}
className={cn(cardVariants({ interactive, tone }), className)}
ref={ref}
/>
);
});
export type CardHeaderProps = React.ComponentPropsWithoutRef<"div">;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(function CardHeader(
{ className, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(cardHeaderVariants(), className)}
ref={ref}
/>
);
});
export type CardTitleProps = React.ComponentPropsWithoutRef<"h3">;
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(function CardTitle(
{ className, ...props },
ref
) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(cardTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type CardDescriptionProps = React.ComponentPropsWithoutRef<"p">;
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
function CardDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(cardDescriptionVariants(), className)}
ref={ref}
/>
);
}
);
export type CardContentProps = React.ComponentPropsWithoutRef<"div">;
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(function CardContent(
{ className, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("content")}
className={cn(cardContentVariants(), className)}
ref={ref}
/>
);
});
export type CardFooterProps = React.ComponentPropsWithoutRef<"div">;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(function CardFooter(
{ className, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(cardFooterVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,43 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const cardVariants = cva(
[
"rounded-[var(--radius-lg)] border text-[var(--color-card-foreground)]",
"bg-[var(--color-card)] shadow-[var(--shadow-sm)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default: "border-[var(--color-border)]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_86%,transparent)] bg-[var(--color-surface)] shadow-[var(--shadow-xs)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_26%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
},
interactive: {
false: "",
true: "hover:-translate-y-[2px] hover:shadow-[var(--shadow-md)]"
}
},
defaultVariants: {
tone: "default",
interactive: false
}
}
);
export const cardHeaderVariants = cva("grid gap-2 p-6 pb-0");
export const cardTitleVariants = cva(
"text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const cardDescriptionVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const cardContentVariants = cva("p-6");
export const cardFooterVariants = cva("flex flex-wrap items-center gap-3 p-6 pt-0");
@@ -0,0 +1,111 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { dialogContentVariants, dialogFooterVariants, dialogHeaderVariants, dialogOverlayVariants } from "./dialog.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
{...props}
{...createSlot("overlay")}
className={cn(dialogOverlayVariants(), className)}
ref={ref}
/>
);
});
export type DialogContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
VariantProps<typeof dialogContentVariants>;
export const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(function DialogContent({ children, className, size, ...props }, ref) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
{...props}
{...createSlot("content")}
{...createDataAttributes({ size })}
className={cn(dialogContentVariants({ size }), className)}
ref={ref}
>
{children}
<DialogPrimitive.Close
aria-label="Close dialog"
className="absolute right-4 top-4 inline-flex size-9 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)] outline-none transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--color-surface)] hover:text-[var(--color-foreground)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-card)]"
>
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
export function DialogHeader({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(dialogHeaderVariants(), className)}
/>
);
}
export function DialogFooter({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(dialogFooterVariants(), className)}
/>
);
}
export const DialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(function DialogTitle({ className, ...props }, ref) {
return (
<DialogPrimitive.Title
{...props}
{...createSlot("label")}
className={cn("pr-10 text-xl font-semibold tracking-tight", className)}
ref={ref}
/>
);
});
export const DialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(function DialogDescription({ className, ...props }, ref) {
return (
<DialogPrimitive.Description
{...props}
{...createSlot("description")}
className={cn("text-sm leading-6 text-[var(--color-muted-foreground)]", className)}
ref={ref}
/>
);
});
@@ -0,0 +1,33 @@
import { cva } from "../lib/cva";
export const dialogOverlayVariants = cva([
"fixed inset-0 z-50 bg-[var(--color-overlay)] backdrop-blur-[2px]",
"data-[state=open]:motion-overlay-enter data-[state=closed]:motion-overlay-exit"
]);
export const dialogContentVariants = cva(
[
"fixed left-1/2 top-1/2 z-50 grid -translate-x-1/2 -translate-y-1/2 gap-5",
"w-[min(calc(100vw-2rem),40rem)] max-h-[calc(100vh-2rem)] overflow-y-auto",
"rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
],
{
variants: {
size: {
sm: "w-[min(calc(100vw-2rem),30rem)]",
md: "w-[min(calc(100vw-2rem),40rem)]",
lg: "w-[min(calc(100vw-2rem),52rem)]"
}
},
defaultVariants: {
size: "md"
}
}
);
export const dialogHeaderVariants = cva(["flex flex-col gap-2 text-left"]);
export const dialogFooterVariants = cva([
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
]);
@@ -0,0 +1,174 @@
import {
createContext,
forwardRef,
useContext,
useId,
type ComponentPropsWithoutRef
} from "react";
import { Label } from "./label";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot, type FieldStateProps } from "../lib/contracts";
type FieldContextValue = {
descriptionId: string;
disabled: boolean;
errorId: string;
inputId: string;
invalid: boolean;
readOnly: boolean;
required: boolean;
};
export type FieldRenderProps = FieldContextValue;
const FieldContext = createContext<FieldContextValue | null>(null);
export function useFieldContext() {
return useContext(FieldContext);
}
export type FieldProps = ComponentPropsWithoutRef<"div"> &
FieldStateProps & {
orientation?: "horizontal" | "vertical";
};
export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field(
{
children,
className,
disabled = false,
id,
invalid = false,
orientation = "vertical",
readOnly = false,
required = false,
...props
},
ref
) {
const reactId = useId();
const baseId = id ?? `field-${reactId.replace(/:/g, "")}`;
const value: FieldContextValue = {
descriptionId: `${baseId}-description`,
disabled,
errorId: `${baseId}-error`,
inputId: `${baseId}-control`,
invalid,
readOnly,
required
};
return (
<FieldContext.Provider value={value}>
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled,
invalid,
orientation,
readonly: readOnly,
required
})}
className={cn(
"grid gap-2.5",
orientation === "horizontal" && "gap-3 sm:grid-cols-[minmax(10rem,12rem)_minmax(0,1fr)] sm:items-start",
className
)}
id={id}
ref={ref}
>
{children}
</div>
</FieldContext.Provider>
);
});
export const FormItem = Field;
export const FieldLabel = Label;
export type FieldDescriptionProps = ComponentPropsWithoutRef<"p">;
export const FieldDescription = forwardRef<
HTMLParagraphElement,
FieldDescriptionProps
>(function FieldDescription({ className, id, ...props }, ref) {
const field = useFieldContext();
return (
<p
{...props}
{...createSlot("description")}
className={cn(
"text-sm leading-6 text-[var(--color-muted-foreground)]",
className
)}
id={id ?? field?.descriptionId}
ref={ref}
/>
);
});
export type FieldErrorProps = ComponentPropsWithoutRef<"p">;
export const FieldError = forwardRef<
HTMLParagraphElement,
FieldErrorProps
>(function FieldError({ children, className, id, ...props }, ref) {
const field = useFieldContext();
if (!children) {
return null;
}
return (
<p
{...props}
{...createSlot("description")}
className={cn(
"text-sm font-medium leading-6 text-[var(--color-destructive)]",
className
)}
id={id ?? field?.errorId}
ref={ref}
role="alert"
>
{children}
</p>
);
});
export type FieldControlProps = ComponentPropsWithoutRef<"div">;
export const FieldControl = forwardRef<
HTMLDivElement,
FieldControlProps
>(function FieldControl({ className, ...props }, ref) {
const field = useFieldContext();
return (
<div
{...props}
{...createSlot("control")}
{...createDataAttributes({
disabled: field?.disabled,
invalid: field?.invalid,
readonly: field?.readOnly,
required: field?.required
})}
className={cn("grid gap-2", className)}
ref={ref}
/>
);
});
export function useFieldIds() {
const field = useFieldContext();
if (!field) {
throw new Error("useFieldIds must be used within <Field>.");
}
return field;
}
+210
View File
@@ -0,0 +1,210 @@
import { Slot } from "@radix-ui/react-slot";
import {
createContext,
forwardRef,
useContext,
useId,
type ComponentPropsWithoutRef,
type ReactNode
} from "react";
import {
FormProvider,
useFormContext,
type FieldError,
type FieldValues,
type FormProviderProps,
type UseFormReturn
} from "react-hook-form";
import {
Field,
FieldDescription,
FieldError as BaseFieldError,
FieldLabel,
useFieldContext,
type FieldDescriptionProps,
type FieldErrorProps,
type FieldProps
} from "./field";
import { cn } from "../lib/cn";
import { createDataAttributes } from "../lib/contracts";
type FormItemContextValue = {
name?: string;
};
const FormItemContext = createContext<FormItemContextValue | null>(null);
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
function useSafeFormContext() {
try {
return useFormContext();
} catch {
return null;
}
}
function useFormFieldState(name?: string) {
const form = useSafeFormContext();
if (!form || !name) {
return {
error: undefined as FieldError | undefined,
invalid: false
};
}
// Accessing concrete formState branches subscribes this hook to RHF updates.
void form.formState.errors;
void form.formState.touchedFields;
void form.formState.dirtyFields;
const fieldState = form.getFieldState(name, form.formState);
return {
error: fieldState.error,
invalid: fieldState.invalid
};
}
function getErrorMessage(error?: FieldError) {
if (!error) {
return undefined;
}
if (typeof error.message === "string" && error.message.length > 0) {
return error.message;
}
if (typeof error.type === "string" && error.type.length > 0) {
return error.type;
}
return "Invalid value.";
}
export const Form = FormProvider;
export type FormProps<
TFieldValues extends FieldValues = FieldValues,
TContext = unknown,
TTransformedValues extends FieldValues | undefined = undefined
> = FormProviderProps<TFieldValues, TContext, TTransformedValues>;
export type FormItemProps = Omit<FieldProps, "id" | "invalid"> & {
name?: string;
invalid?: boolean;
};
export const FormItem = forwardRef<HTMLDivElement, FormItemProps>(function FormItem(
{ name, invalid, ...props },
ref
) {
const reactId = useId();
const { invalid: formInvalid } = useFormFieldState(name);
const resolvedInvalid = invalid ?? formInvalid;
return (
<FormItemContext.Provider value={{ name }}>
<Field
{...props}
id={`form-item-${reactId.replace(/:/g, "")}`}
invalid={resolvedInvalid}
ref={ref}
/>
</FormItemContext.Provider>
);
});
export type FormLabelProps = ComponentPropsWithoutRef<typeof FieldLabel>;
export const FormLabel = forwardRef<HTMLLabelElement, FormLabelProps>(function FormLabel(
props,
ref
) {
const { name } = useContext(FormItemContext) ?? {};
const { invalid: formInvalid } = useFormFieldState(name);
return (
<FieldLabel
{...props}
aria-invalid={props["aria-invalid"] ?? (formInvalid || undefined)}
ref={ref}
/>
);
});
export type FormControlProps = ComponentPropsWithoutRef<typeof Slot> & {
className?: string;
};
export const FormControl = forwardRef<HTMLDivElement, FormControlProps>(function FormControl(
{ children, className, ...props },
ref
) {
const { name } = useContext(FormItemContext) ?? {};
const field = useFieldContext();
const { invalid } = useFormFieldState(name);
const describedBy = mergeIds(
typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined,
field?.descriptionId,
invalid ? field?.errorId : undefined
);
const controlId = typeof props.id === "string" ? props.id : field?.inputId;
return (
<div
{...createDataAttributes({
invalid
})}
className={cn("grid gap-2", className)}
data-slot="control"
ref={ref}
>
<Slot
{...props}
aria-describedby={describedBy}
aria-invalid={props["aria-invalid"] ?? (invalid || undefined)}
id={controlId}
>
{children}
</Slot>
</div>
);
});
export type FormDescriptionProps = FieldDescriptionProps;
export const FormDescription = forwardRef<HTMLParagraphElement, FormDescriptionProps>(
function FormDescription(props, ref) {
return <FieldDescription {...props} ref={ref} />;
}
);
export type FormMessageProps = Omit<FieldErrorProps, "children"> & {
children?: ReactNode;
};
export const FormMessage = forwardRef<HTMLParagraphElement, FormMessageProps>(
function FormMessage({ children, ...props }, ref) {
const { name } = useContext(FormItemContext) ?? {};
const { error } = useFormFieldState(name);
const message = getErrorMessage(error) ?? children;
if (!message) {
return null;
}
return (
<BaseFieldError {...props} ref={ref}>
{message}
</BaseFieldError>
);
}
);
export type FormMethods<TFieldValues extends FieldValues = FieldValues> = UseFormReturn<TFieldValues>;
@@ -0,0 +1,66 @@
import {
forwardRef,
type ComponentPropsWithoutRef
} from "react";
import { inputVariants } from "./input.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { FieldStateProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
export type InputProps = Omit<ComponentPropsWithoutRef<"input">, "size"> &
FieldStateProps &
VariantProps<typeof inputVariants>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
disabled,
id,
invalid,
readOnly,
required,
size,
...props
},
ref
) {
const field = useFieldContext();
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
const resolvedRequired = required ?? field?.required ?? false;
return (
<input
{...props}
{...createSlot("input")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size
})}
aria-describedby={mergeIds(
props["aria-describedby"],
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
)}
aria-invalid={resolvedInvalid || undefined}
className={cn(inputVariants({ size }), className)}
disabled={resolvedDisabled}
id={id ?? field?.inputId}
readOnly={resolvedReadOnly}
ref={ref}
required={resolvedRequired}
/>
);
});
@@ -0,0 +1,28 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const inputVariants = cva(
[
"flex w-full min-w-0 rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)]",
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
"placeholder:text-[var(--color-muted-foreground)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:cursor-not-allowed disabled:bg-[var(--color-surface)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:bg-[var(--color-surface)] read-only:text-[var(--color-muted-foreground)]",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"aria-[invalid=true]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
],
{
variants: {
size: {
sm: "h-10 px-3 text-sm",
md: "h-11 px-4 text-sm",
lg: "h-12 px-4 text-base"
}
},
defaultVariants: {
size: "md"
}
}
);
@@ -0,0 +1,56 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
export type LabelProps = ComponentPropsWithoutRef<"label"> & {
requiredIndicator?: boolean;
};
export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
{
children,
className,
htmlFor,
requiredIndicator = false,
...props
},
ref
) {
const field = useFieldContext();
const disabled = props["aria-disabled"] === true || field?.disabled === true;
const invalid = props["aria-invalid"] === true || field?.invalid === true;
const required = props["aria-required"] === true || field?.required === true;
return (
<label
{...props}
{...createSlot("label")}
{...createDataAttributes({
disabled,
invalid,
required
})}
className={cn(
"inline-flex items-center gap-2 text-sm font-medium leading-none text-[var(--color-foreground)]",
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[disabled]:cursor-not-allowed data-[disabled]:text-[var(--color-muted-foreground)]",
"data-[invalid]:text-[color-mix(in_oklch,var(--color-destructive)_82%,var(--color-foreground))]",
className
)}
htmlFor={htmlFor ?? field?.inputId}
ref={ref}
>
<span>{children}</span>
{requiredIndicator && required ? (
<span
aria-hidden="true"
className="text-[var(--color-destructive)]"
>
*
</span>
) : null}
</label>
);
});
@@ -0,0 +1,66 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { tabsContentVariants, tabsListVariants, tabsTriggerVariants } from "./tabs.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
export function Tabs({
className,
orientation = "horizontal",
...props
}: ComponentPropsWithoutRef<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({ orientation })}
className={cn("flex flex-col", className)}
orientation={orientation}
/>
);
}
export const TabsList = forwardRef<
ElementRef<typeof TabsPrimitive.List>,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(function TabsList({ className, ...props }, ref) {
return (
<TabsPrimitive.List
{...props}
{...createSlot("list")}
className={cn(tabsListVariants(), className)}
ref={ref}
/>
);
});
export const TabsTrigger = forwardRef<
ElementRef<typeof TabsPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(function TabsTrigger({ className, disabled, ...props }, ref) {
return (
<TabsPrimitive.Trigger
{...props}
{...createSlot("trigger")}
{...createDataAttributes({ disabled })}
className={cn(tabsTriggerVariants(), className)}
disabled={disabled}
ref={ref}
/>
);
});
export const TabsContent = forwardRef<
ElementRef<typeof TabsPrimitive.Content>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(function TabsContent({ className, ...props }, ref) {
return (
<TabsPrimitive.Content
{...props}
{...createSlot("content")}
className={cn(tabsContentVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,20 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const tabsListVariants = cva([
"inline-flex h-12 items-center gap-1 rounded-[var(--radius-full)] border border-[var(--color-border)] bg-[var(--color-surface)] p-1 shadow-[var(--shadow-xs)]"
]);
export const tabsTriggerVariants = cva([
"inline-flex min-w-[7rem] items-center justify-center rounded-[var(--radius-full)] px-4 py-2.5 text-sm font-medium outline-none",
"text-[var(--color-muted-foreground)] transition-[color,background-color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
"data-[state=active]:bg-[var(--color-card)] data-[state=active]:text-[var(--color-foreground)] data-[state=active]:shadow-[var(--shadow-xs)]",
getMotionRecipeClassNames("ring")
]);
export const tabsContentVariants = cva([
"mt-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-card-foreground)] shadow-[var(--shadow-sm)] outline-none",
"data-[state=active]:motion-enter-rise"
]);
@@ -0,0 +1,66 @@
import {
forwardRef,
type ComponentPropsWithoutRef
} from "react";
import { textareaVariants } from "./textarea.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { FieldStateProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
export type TextareaProps = ComponentPropsWithoutRef<"textarea"> &
FieldStateProps &
VariantProps<typeof textareaVariants>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{
className,
disabled,
id,
invalid,
readOnly,
required,
size,
...props
},
ref
) {
const field = useFieldContext();
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
const resolvedRequired = required ?? field?.required ?? false;
return (
<textarea
{...props}
{...createSlot("input")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size
})}
aria-describedby={mergeIds(
props["aria-describedby"],
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
)}
aria-invalid={resolvedInvalid || undefined}
className={cn(textareaVariants({ size }), className)}
disabled={resolvedDisabled}
id={id ?? field?.inputId}
readOnly={resolvedReadOnly}
ref={ref}
required={resolvedRequired}
/>
);
});
@@ -0,0 +1,28 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const textareaVariants = cva(
[
"flex min-h-[8.75rem] w-full min-w-0 resize-y rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)] px-4 py-3",
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
"placeholder:text-[var(--color-muted-foreground)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:cursor-not-allowed disabled:bg-[var(--color-surface)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:bg-[var(--color-surface)] read-only:text-[var(--color-muted-foreground)]",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"aria-[invalid=true]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
],
{
variants: {
size: {
sm: "min-h-[7.5rem] px-3 py-2.5 text-sm",
md: "min-h-[8.75rem] px-4 py-3 text-sm",
lg: "min-h-[10.5rem] px-4 py-3.5 text-base"
}
},
defaultVariants: {
size: "md"
}
}
);
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+185
View File
@@ -0,0 +1,185 @@
import type { ComponentPropsWithoutRef } from "react";
export type CommonComponentProps = {
className?: string;
};
export type AsChildProp = {
asChild?: boolean;
};
export type DisableableProps = {
disabled?: boolean;
};
export type InvalidatableProps = {
invalid?: boolean;
};
export type LoadingProps = {
loading?: boolean;
};
export type ReadonlyProps = {
readOnly?: boolean;
};
export type RequiredProps = {
required?: boolean;
};
export type PressableStateProps = CommonComponentProps &
DisableableProps &
LoadingProps &
AsChildProp;
export type FieldStateProps = CommonComponentProps &
DisableableProps &
InvalidatableProps &
ReadonlyProps &
RequiredProps;
export type ControllableStateProps<T> = {
defaultValue?: T;
onValueChange?: (value: T) => void;
value?: T;
};
export type DataAttributes = Partial<Record<`data-${string}`, string | undefined>>;
export type SlotAttributes<Name extends string> = {
"data-slot": Name;
};
export const commonSlotNames = [
{
slot: "root",
guidance: "The outermost element rendered by the component."
},
{
slot: "label",
guidance: "Primary visible label or title within the component."
},
{
slot: "description",
guidance: "Secondary supporting copy connected to the component."
},
{
slot: "control",
guidance: "Focusable or interactive control surface."
},
{
slot: "input",
guidance: "Typed value entry element such as input or textarea."
},
{
slot: "trigger",
guidance: "Element that opens, closes, or toggles related content."
},
{
slot: "content",
guidance: "Popover, drawer, menu, dialog, or expandable content region."
},
{
slot: "icon",
guidance: "Decorative or stateful icon container."
}
] as const;
export const commonStateNames = [
{
state: "state",
guidance: "Use for finite machine-like values such as open, closed, active, or inactive."
},
{
state: "disabled",
guidance: "Set when interaction is blocked."
},
{
state: "invalid",
guidance: "Set when validation fails or the field is in an error state."
},
{
state: "loading",
guidance: "Set when the component is waiting on async work."
},
{
state: "readonly",
guidance: "Set when the value can be viewed but not edited."
},
{
state: "required",
guidance: "Set when the field requires a value."
},
{
state: "orientation",
guidance: "Use for horizontal or vertical layout state when styling depends on it."
}
] as const;
export const authoringChecklist = [
"Expose `className` on every styled public component.",
"Forward `ref` on every focusable or measurable public component.",
"Use `asChild` only for components whose root element is meant to be polymorphic.",
"Represent boolean UI states with empty-string `data-*` attributes.",
"Represent finite machine states with `data-state=\"...\"` and keep the values stable.",
"Name internal stylable parts with `data-slot` so variants and docs can target them consistently.",
"Prefer controlled and uncontrolled APIs together when the component manages user state.",
"Consume tokens and motion recipes instead of raw visual values."
] as const;
export const cvaConventions = [
"Put shared layout and focus primitives in the CVA base string, not in individual variants.",
"Reserve `variant` for semantic appearance changes and `size` only when spacing or density genuinely changes.",
"Keep default variants explicit so stories and tests do not depend on implicit visual fallbacks.",
"Prefer a small stable variant surface over one-off booleans that fragment the API."
] as const;
export function dataAttr(active?: boolean) {
return active ? "" : undefined;
}
export function createDataAttributes(
states: Record<string, boolean | number | string | null | undefined>
): DataAttributes {
const attributes: DataAttributes = {};
for (const [name, value] of Object.entries(states)) {
const key = `data-${name}` as const;
if (typeof value === "boolean") {
attributes[key] = dataAttr(value);
continue;
}
if (value !== null && value !== undefined) {
attributes[key] = String(value);
}
}
return attributes;
}
export function createSlot<Name extends string>(name: Name): SlotAttributes<Name> {
return { "data-slot": name };
}
export function withRootProps<T extends object>(
props: T,
options: {
slot?: string;
states?: Record<string, boolean | number | string | null | undefined>;
} = {}
) {
return {
...props,
...(options.slot ? createSlot(options.slot) : {}),
...(options.states ? createDataAttributes(options.states) : {})
};
}
export type ButtonLikeElementProps = ComponentPropsWithoutRef<"button"> &
PressableStateProps;
export type InputLikeElementProps = ComponentPropsWithoutRef<"input"> &
FieldStateProps;
+2
View File
@@ -0,0 +1,2 @@
export { cva, cx, type VariantProps } from "class-variance-authority";
+44
View File
@@ -0,0 +1,44 @@
export const motionDurations = {
instant: "var(--dur-instant)",
fast: "var(--dur-fast)",
base: "var(--dur-base)",
slow: "var(--dur-slow)",
deliberate: "var(--dur-deliberate)"
} as const;
export const motionEasings = {
standard: "var(--ease-standard)",
emphasized: "var(--ease-emphasized)",
exit: "var(--ease-exit)"
} as const;
export const motionDistances = {
xs: "var(--distance-xs)",
sm: "var(--distance-sm)",
md: "var(--distance-md)",
lg: "var(--distance-lg)"
} as const;
export const motionScales = {
press: "var(--scale-press)",
hover: "var(--scale-hover)",
pop: "var(--scale-pop)"
} as const;
export const motionRecipes = {
transition: "motion-transition",
pressable: "motion-pressable",
enterFade: "motion-enter-fade",
enterRise: "motion-enter-rise",
overlayEnter: "motion-overlay-enter",
overlayExit: "motion-overlay-exit",
exitFade: "motion-exit-fade",
exitDrop: "motion-exit-drop",
ring: "motion-ring"
} as const;
export type MotionRecipeName = keyof typeof motionRecipes;
export function getMotionRecipeClassNames(...recipes: MotionRecipeName[]) {
return recipes.map((recipe) => motionRecipes[recipe]).join(" ");
}
+36
View File
@@ -0,0 +1,36 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
color-scheme: light;
background: var(--color-background);
}
body {
margin: 0;
min-height: 100vh;
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
text-rendering: optimizeLegibility;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
}
::selection {
background: color-mix(in oklch, var(--color-primary) 24%, transparent);
}
+176
View File
@@ -0,0 +1,176 @@
export const themeNames = ["light", "dark", "brand"] as const;
export type ThemeName = (typeof themeNames)[number];
export const defaultTheme: ThemeName = "light";
export const themeDetails = {
light: {
label: "Light",
note: "Warm editorial default"
},
dark: {
label: "Dark",
note: "Warm charcoal default"
},
brand: {
label: "Brand",
note: "Verdant accent scaffold"
}
} as const satisfies Record<ThemeName, { label: string; note: string }>;
export const motionModeNames = ["system", "reduced"] as const;
export type MotionModeName = (typeof motionModeNames)[number];
export const defaultMotionMode: MotionModeName = "system";
export const motionScale = {
instant: "var(--dur-instant)",
fast: "var(--dur-fast)",
base: "var(--dur-base)",
slow: "var(--dur-slow)",
deliberate: "var(--dur-deliberate)"
} as const;
export const colorTokens = [
{ name: "background", cssVar: "--color-background", role: "Application canvas" },
{ name: "foreground", cssVar: "--color-foreground", role: "Primary text and icons" },
{ name: "surface", cssVar: "--color-surface", role: "Secondary surface backgrounds" },
{
name: "surface-strong",
cssVar: "--color-surface-strong",
role: "Elevated surface emphasis"
},
{ name: "card", cssVar: "--color-card", role: "Cards and floating panels" },
{ name: "border", cssVar: "--color-border", role: "Default dividers and input borders" },
{
name: "border-strong",
cssVar: "--color-border-strong",
role: "Higher emphasis dividers"
},
{ name: "primary", cssVar: "--color-primary", role: "Primary actions and highlights" },
{
name: "secondary",
cssVar: "--color-secondary",
role: "Secondary fills and supporting actions"
},
{ name: "muted", cssVar: "--color-muted", role: "Subtle supporting surfaces" },
{
name: "muted-foreground",
cssVar: "--color-muted-foreground",
role: "Secondary text and captions"
},
{ name: "accent", cssVar: "--color-accent", role: "Moments of emphasis or delight" },
{ name: "success", cssVar: "--color-success", role: "Success feedback" },
{ name: "warning", cssVar: "--color-warning", role: "Warning feedback" },
{ name: "destructive", cssVar: "--color-destructive", role: "Destructive actions" }
] as const;
export const typographyTokens = [
{
name: "caption",
fontVar: "--text-xs",
lineHeightVar: "--leading-normal",
familyVar: "--font-sans",
sample: "Small labels, metadata, and supporting notes."
},
{
name: "body",
fontVar: "--text-base",
lineHeightVar: "--leading-normal",
familyVar: "--font-sans",
sample: "Body copy stays warm, readable, and stable across themes."
},
{
name: "lead",
fontVar: "--text-xl",
lineHeightVar: "--leading-loose",
familyVar: "--font-sans",
sample: "Lead text introduces a surface without becoming display copy."
},
{
name: "display",
fontVar: "--text-4xl",
lineHeightVar: "--leading-tight",
familyVar: "--font-display",
sample: "Display text carries the editorial voice of the system."
}
] as const;
export const radiusTokens = [
{ name: "xs", cssVar: "--radius-xs" },
{ name: "sm", cssVar: "--radius-sm" },
{ name: "md", cssVar: "--radius-md" },
{ name: "lg", cssVar: "--radius-lg" },
{ name: "xl", cssVar: "--radius-xl" },
{ name: "full", cssVar: "--radius-full" }
] as const;
export const shadowTokens = [
{ name: "xs", cssVar: "--shadow-xs" },
{ name: "sm", cssVar: "--shadow-sm" },
{ name: "md", cssVar: "--shadow-md" },
{ name: "lg", cssVar: "--shadow-lg" }
] as const;
export const motionTokens = {
durations: [
{ name: "instant", cssVar: "--dur-instant" },
{ name: "fast", cssVar: "--dur-fast" },
{ name: "base", cssVar: "--dur-base" },
{ name: "slow", cssVar: "--dur-slow" },
{ name: "deliberate", cssVar: "--dur-deliberate" }
],
easings: [
{ name: "standard", cssVar: "--ease-standard" },
{ name: "emphasized", cssVar: "--ease-emphasized" },
{ name: "exit", cssVar: "--ease-exit" }
],
distances: [
{ name: "xs", cssVar: "--distance-xs" },
{ name: "sm", cssVar: "--distance-sm" },
{ name: "md", cssVar: "--distance-md" },
{ name: "lg", cssVar: "--distance-lg" }
],
scales: [
{ name: "press", cssVar: "--scale-press" },
{ name: "hover", cssVar: "--scale-hover" },
{ name: "pop", cssVar: "--scale-pop" }
]
} as const;
function getTargetElement(root?: HTMLElement) {
if (root) {
return root;
}
if (typeof document === "undefined") {
return undefined;
}
return document.documentElement;
}
export function setTheme(theme: ThemeName, root?: HTMLElement) {
const target = getTargetElement(root);
if (!target) {
return;
}
target.dataset.theme = theme;
}
export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
const target = getTargetElement(root);
if (!target) {
return;
}
if (mode === "system") {
delete target.dataset.motion;
return;
}
target.dataset.motion = mode;
}
+171
View File
@@ -0,0 +1,171 @@
:root {
--dur-instant: 1ms;
--dur-fast: 120ms;
--dur-base: 200ms;
--dur-slow: 320ms;
--dur-deliberate: 460ms;
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--distance-xs: 4px;
--distance-sm: 8px;
--distance-md: 16px;
--distance-lg: 24px;
--scale-press: 0.98;
--scale-hover: 1.01;
--scale-pop: 1.02;
}
:root[data-motion="reduced"] {
--dur-instant: 1ms;
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
--dur-deliberate: 1ms;
--distance-xs: 0px;
--distance-sm: 0px;
--distance-md: 0px;
--distance-lg: 0px;
--scale-press: 1;
--scale-hover: 1;
--scale-pop: 1;
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion="full"]) {
--dur-instant: 1ms;
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
--dur-deliberate: 1ms;
--distance-xs: 0px;
--distance-sm: 0px;
--distance-md: 0px;
--distance-lg: 0px;
--scale-press: 1;
--scale-hover: 1;
--scale-pop: 1;
}
:root {
scroll-behavior: auto;
}
:root:not([data-motion="full"]) *,
:root:not([data-motion="full"]) *::before,
:root:not([data-motion="full"]) *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}
@keyframes aiui-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes aiui-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes aiui-slide-up-sm {
from {
opacity: 0;
transform: translateY(var(--distance-sm));
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes aiui-slide-down-sm {
from {
opacity: 0;
transform: translateY(calc(var(--distance-sm) * -1));
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes aiui-skeleton-shimmer {
from {
transform: translateX(-120%);
}
to {
transform: translateX(120%);
}
}
.motion-transition {
transition-duration: var(--dur-base);
transition-property: color, background-color, border-color, box-shadow, opacity,
transform;
transition-timing-function: var(--ease-standard);
}
.motion-pressable {
transition-duration: var(--dur-fast);
transition-property: color, background-color, border-color, box-shadow, transform;
transition-timing-function: var(--ease-standard);
}
.motion-pressable:hover {
transform: translateY(calc(var(--distance-xs) * -0.25)) scale(var(--scale-hover));
}
.motion-pressable:active {
transform: scale(var(--scale-press));
}
.motion-enter-fade {
animation: aiui-fade-in var(--dur-base) var(--ease-standard) both;
}
.motion-enter-rise {
animation: aiui-slide-up-sm var(--dur-slow) var(--ease-emphasized) both;
}
.motion-overlay-enter {
animation: aiui-fade-in var(--dur-fast) var(--ease-standard) both;
}
.motion-overlay-exit {
animation: aiui-fade-out var(--dur-fast) var(--ease-exit) both;
}
.motion-exit-fade {
animation: aiui-fade-out var(--dur-fast) var(--ease-exit) both;
}
.motion-exit-drop {
animation: aiui-slide-down-sm calc(var(--dur-fast) * 0.9) var(--ease-exit) reverse
both;
}
.motion-ring {
transition-duration: var(--dur-fast);
transition-property: box-shadow, outline-color, border-color;
transition-timing-function: var(--ease-standard);
}
@@ -0,0 +1,4 @@
@import "./base.css";
@import "./tokens.css";
@import "./motion.css";
+130
View File
@@ -0,0 +1,130 @@
:root {
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia,
serif;
--font-mono: "SF Mono", "SFMono-Regular", "Consolas", monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 2rem;
--text-4xl: clamp(2.5rem, 4vw, 4rem);
--leading-tight: 1.1;
--leading-snug: 1.25;
--leading-normal: 1.5;
--leading-loose: 1.7;
--tracking-tight: -0.03em;
--tracking-normal: 0;
--tracking-caps: 0.18em;
--border-width-thin: 1px;
--border-width-strong: 1.5px;
--radius-xs: 8px;
--radius-sm: 12px;
--radius-md: 18px;
--radius-lg: 28px;
--radius-xl: 40px;
--radius-full: 999px;
--shadow-xs: 0 1px 2px oklch(0.28 0.02 55 / 0.06);
--shadow-sm: 0 8px 24px oklch(0.28 0.02 55 / 0.08);
--shadow-md: 0 18px 48px oklch(0.28 0.03 55 / 0.12);
--shadow-lg: 0 32px 72px oklch(0.2 0.02 55 / 0.16);
}
:root,
[data-theme="light"] {
color-scheme: light;
--color-background: oklch(0.985 0.004 85);
--color-foreground: oklch(0.24 0.03 60);
--color-surface: oklch(0.965 0.008 80);
--color-surface-strong: oklch(0.93 0.012 78);
--color-surface-contrast: oklch(0.28 0.028 58);
--color-border: oklch(0.87 0.01 75);
--color-border-strong: oklch(0.72 0.018 68);
--color-input: var(--color-border);
--color-ring: oklch(0.56 0.12 32);
--color-primary: oklch(0.53 0.15 30);
--color-primary-foreground: oklch(0.98 0.01 80);
--color-secondary: oklch(0.9 0.02 74);
--color-secondary-foreground: oklch(0.26 0.024 60);
--color-muted: oklch(0.94 0.008 78);
--color-muted-foreground: oklch(0.42 0.028 60);
--color-accent: oklch(0.76 0.1 82);
--color-accent-foreground: oklch(0.24 0.03 60);
--color-success: oklch(0.58 0.12 152);
--color-success-foreground: oklch(0.97 0.01 155);
--color-warning: oklch(0.74 0.12 80);
--color-warning-foreground: oklch(0.22 0.02 64);
--color-destructive: oklch(0.51 0.18 28);
--color-destructive-foreground: oklch(0.98 0.01 80);
--color-card: color-mix(in oklch, var(--color-surface) 86%, white 14%);
--color-card-foreground: var(--color-foreground);
--color-overlay: oklch(0.12 0.01 40 / 0.48);
}
[data-theme="dark"] {
color-scheme: dark;
--color-background: oklch(0.2 0.015 60);
--color-foreground: oklch(0.94 0.01 80);
--color-surface: oklch(0.26 0.018 60);
--color-surface-strong: oklch(0.31 0.018 60);
--color-surface-contrast: oklch(0.93 0.012 78);
--color-border: oklch(0.4 0.015 60);
--color-border-strong: oklch(0.56 0.028 64);
--color-input: var(--color-border);
--color-ring: oklch(0.7 0.12 35);
--color-primary: oklch(0.72 0.13 40);
--color-primary-foreground: oklch(0.22 0.014 60);
--color-secondary: oklch(0.39 0.035 66);
--color-secondary-foreground: oklch(0.95 0.01 80);
--color-muted: oklch(0.28 0.015 60);
--color-muted-foreground: oklch(0.8 0.02 72);
--color-accent: oklch(0.68 0.09 82);
--color-accent-foreground: oklch(0.17 0.012 60);
--color-success: oklch(0.7 0.12 152);
--color-success-foreground: oklch(0.17 0.012 152);
--color-warning: oklch(0.78 0.12 82);
--color-warning-foreground: oklch(0.16 0.012 64);
--color-destructive: oklch(0.68 0.16 28);
--color-destructive-foreground: oklch(0.16 0.012 60);
--color-card: color-mix(in oklch, var(--color-surface) 90%, black 10%);
--color-card-foreground: var(--color-foreground);
--color-overlay: oklch(0.05 0.01 50 / 0.72);
}
[data-theme="brand"] {
color-scheme: light;
--color-background: oklch(0.972 0.016 172);
--color-foreground: oklch(0.24 0.03 182);
--color-surface: oklch(0.946 0.018 172);
--color-surface-strong: oklch(0.91 0.024 172);
--color-surface-contrast: oklch(0.29 0.034 182);
--color-border: oklch(0.83 0.026 172);
--color-border-strong: oklch(0.67 0.045 176);
--color-input: var(--color-border);
--color-ring: oklch(0.53 0.12 190);
--color-primary: oklch(0.48 0.12 188);
--color-primary-foreground: oklch(0.97 0.008 172);
--color-secondary: oklch(0.82 0.066 156);
--color-secondary-foreground: oklch(0.2 0.02 178);
--color-muted: oklch(0.91 0.018 172);
--color-muted-foreground: oklch(0.42 0.03 180);
--color-accent: oklch(0.75 0.105 130);
--color-accent-foreground: oklch(0.2 0.02 160);
--color-success: oklch(0.6 0.12 155);
--color-success-foreground: oklch(0.98 0.006 170);
--color-warning: oklch(0.76 0.13 86);
--color-warning-foreground: oklch(0.22 0.02 74);
--color-destructive: oklch(0.53 0.16 30);
--color-destructive-foreground: oklch(0.98 0.01 80);
--color-card: color-mix(in oklch, var(--color-surface) 88%, white 12%);
--color-card-foreground: var(--color-foreground);
--color-overlay: oklch(0.13 0.015 185 / 0.5);
}
+1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app';
import './cadence-ui/tokens/styles.css';
import './styles.css';
const rootElement = document.getElementById('root');