{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"alert","type":"registry:ui","title":"Alert","description":"An inline message that keeps context and recovery beside the work.","dependencies":["@radix-ui/react-slot","clsx","lucide-react","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/alert.tsx","type":"registry:ui","target":"components/ui/alert.tsx","content":"\"use client\";\n\nimport { X } from \"lucide-react\";\nimport { useId, type ComponentProps, type ReactNode } from \"react\";\nimport { Button } from \"./button\";\nimport { FeedbackIcon, type FeedbackVariant } from \"./feedback\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/alert.css\";\nimport \"./alert.css\";\n\nexport type AlertProps = Omit<ComponentProps<\"div\">, \"title\"> & {\n  title: ReactNode;\n  variant?: FeedbackVariant;\n  loading?: boolean;\n  action?: ReactNode;\n  onDismiss?: () => void;\n};\n\nexport function Alert({\n  title,\n  variant = \"neutral\",\n  loading = false,\n  children,\n  action,\n  onDismiss,\n  className,\n  ...props\n}: AlertProps) {\n  const titleId = useId();\n  return (\n    <div\n      data-slot=\"alert\"\n      data-variant={loading ? \"neutral\" : variant}\n      role={!loading && variant === \"error\" ? \"alert\" : \"status\"}\n      aria-busy={loading || undefined}\n      aria-atomic=\"true\"\n      aria-labelledby={titleId}\n      className={cn(\n        \"grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-3 rounded-overlay border border-border bg-surface p-4 text-sm text-foreground [overflow-wrap:anywhere]\",\n        className,\n      )}\n      {...props}\n    >\n      <FeedbackIcon variant={variant} loading={loading} />\n      <div data-slot=\"feedback-copy\" className=\"min-w-0 pt-1\">\n        <div id={titleId} data-slot=\"feedback-title\" className=\"font-semibold leading-5\">\n          <span key={loading ? \"loading\" : variant} data-slot=\"feedback-message\">{title}</span>\n        </div>\n        {children != null && (\n          <div data-slot=\"feedback-description\" className=\"mt-1 text-sm leading-5 text-muted-foreground\">\n            <span key={loading ? \"loading\" : variant} data-slot=\"feedback-message\">{children}</span>\n          </div>\n        )}\n        {action != null && (\n          <div data-slot=\"feedback-action\" className=\"mt-3 flex max-w-full flex-wrap items-center gap-2\">\n            {action}\n          </div>\n        )}\n      </div>\n      {onDismiss && (\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"-mt-1 -mr-1\"\n          aria-label=\"Dismiss alert\"\n          onClick={onDismiss}\n        >\n          <X aria-hidden=\"true\" />\n        </Button>\n      )}\n    </div>\n  );\n}\n"},{"path":"ui/button.tsx","type":"registry:ui","target":"components/ui/button.tsx","content":"\"use client\";\n\nimport { Slot, Slottable } from \"@radix-ui/react-slot\";\nimport { LoaderCircle } from \"lucide-react\";\nimport {\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/loading-indicator.css\";\nimport \"./button.css\";\n\n/* Colour, material and press live in button.css keyed on data-variant, so a\n   consumer's utility classes still override them. Raised to press: the\n   button sinks 1px into an inner shade instead of scaling. */\nconst variants = {\n  default: \"\",\n  secondary: \"\",\n  ghost: \"\",\n  destructive: \"\",\n};\n\nconst sizes = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-[13px] max-md:min-h-11 [@media(pointer:coarse)]:min-h-11\",\n  md: \"h-[var(--control-height)] gap-2 rounded-full px-3.5 text-sm\",\n  lg: \"h-11 gap-2 rounded-full px-[18px] text-[15px]\",\n  icon: \"size-[var(--control-height)] rounded-full p-0\",\n};\n\nexport type ButtonProps = ComponentProps<\"button\"> & {\n  variant?: keyof typeof variants;\n  size?: keyof typeof sizes;\n  asChild?: boolean;\n  /** Shows a spinner over the label and swallows clicks while keeping focus and width. */\n  loading?: boolean;\n  /** Animate a meaningful label/icon change, such as saving → saved. */\n  feedbackKey?: string;\n};\n\nexport function Button({\n  className,\n  variant = \"default\",\n  size = \"md\",\n  asChild = false,\n  loading = false,\n  feedbackKey,\n  disabled,\n  \"aria-disabled\": ariaDisabled,\n  children,\n  onClick,\n  onClickCapture,\n  tabIndex,\n  type = \"button\",\n  ...props\n}: ButtonProps) {\n  const Component = asChild ? Slot : \"button\";\n  const nativeDisabled = Boolean(disabled);\n  const blocked =\n    nativeDisabled ||\n    loading ||\n    ariaDisabled === true ||\n    ariaDisabled === \"true\";\n\n  return (\n    <Component\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      type={asChild ? undefined : type}\n      className={cn(\n        \"relative inline-flex shrink-0 items-center justify-center whitespace-nowrap border border-transparent font-medium tracking-[-0.006em] outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none aria-disabled:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n        variants[variant],\n        sizes[size],\n        className,\n      )}\n      disabled={asChild ? undefined : nativeDisabled}\n      aria-disabled={nativeDisabled || ariaDisabled ? true : undefined}\n      aria-busy={loading || undefined}\n      tabIndex={asChild && nativeDisabled ? -1 : tabIndex}\n      {...props}\n      onKeyDown={(event) => {\n        if (blocked && (event.key === \"Enter\" || event.key === \" \")) {\n          event.preventDefault();\n        }\n        props.onKeyDown?.(event);\n      }}\n      onClickCapture={(event) => {\n        if (blocked) {\n          event.preventDefault();\n          event.stopPropagation();\n          return;\n        }\n        onClickCapture?.(event);\n      }}\n      onClick={onClick}\n    >\n      {loading && asChild && (\n        <LoaderCircle data-slot=\"loading-indicator\" aria-hidden=\"true\" />\n      )}\n      {loading && !asChild && feedbackKey === undefined && (\n        <span data-slot=\"button-busy\" aria-hidden=\"true\">\n          <LoaderCircle data-slot=\"loading-indicator\" />\n        </span>\n      )}\n      <Slottable>\n        {loading && !asChild && feedbackKey === undefined ? (\n          <span data-slot=\"button-label\" data-hidden=\"true\">\n            {children}\n          </span>\n        ) : feedbackKey !== undefined && !asChild ? (\n          <ButtonFeedback stateKey={feedbackKey}>\n            {loading && (\n              <LoaderCircle data-slot=\"loading-indicator\" aria-hidden=\"true\" />\n            )}\n            {children}\n          </ButtonFeedback>\n        ) : (\n          children\n        )}\n      </Slottable>\n    </Component>\n  );\n}\n\nfunction ButtonFeedback({\n  stateKey,\n  children,\n}: {\n  stateKey: string;\n  children: ReactNode;\n}) {\n  const content = useRef<HTMLSpanElement>(null);\n  const [initialKey] = useState(stateKey);\n  const [width, setWidth] = useState<number>();\n  useLayoutEffect(() => {\n    const element = content.current;\n    if (!element) return;\n    const measure = () => setWidth(element.offsetWidth);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [stateKey]);\n  return (\n    <span data-slot=\"button-feedback\" style={{ width }}>\n      <span\n        key={stateKey}\n        ref={content}\n        data-slot=\"button-feedback-label\"\n        data-enter={stateKey !== initialKey}\n      >\n        {children}\n      </span>\n    </span>\n  );\n}\n"},{"path":"ui/feedback.tsx","type":"registry:ui","target":"components/ui/feedback.tsx","content":"import { CircleCheck, CircleX, Info, LoaderCircle, TriangleAlert } from \"lucide-react\";\nimport \"./feedback.css\";\n\nexport type FeedbackVariant = \"neutral\" | \"success\" | \"warning\" | \"error\";\n\nconst icons = {\n  neutral: Info,\n  success: CircleCheck,\n  warning: TriangleAlert,\n  error: CircleX,\n};\n\nexport function FeedbackIcon({\n  variant,\n  loading = false,\n}: {\n  variant: FeedbackVariant;\n  loading?: boolean;\n}) {\n  const Icon = loading ? LoaderCircle : icons[variant];\n  return (\n    <span data-slot=\"feedback-icon\" aria-hidden=\"true\">\n      <span data-slot=\"feedback-symbol\" key={loading ? \"loading\" : variant}>\n        <Icon data-slot={loading ? \"loading-indicator\" : undefined} />\n      </span>\n    </span>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/shared/alert.css","type":"registry:file","target":"components/ui/shared/alert.css","content":"/* Shared by alert.tsx, toast.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"feedback-message\"] {\n    display: block;\n    animation: caliper-fade-in var(--duration-normal) var(--ease-out);\n  }\n  :is([data-slot=\"alert\"], [data-slot=\"toast\"])[data-variant=\"success\"]\n    [data-slot=\"feedback-icon\"] {\n    background: var(--success-subtle);\n    color: var(--success);\n  }\n  :is([data-slot=\"alert\"], [data-slot=\"toast\"])[data-variant=\"warning\"]\n    [data-slot=\"feedback-icon\"] {\n    background: var(--warning-subtle);\n    color: var(--warning);\n  }\n  :is([data-slot=\"alert\"], [data-slot=\"toast\"])[data-variant=\"error\"]\n    [data-slot=\"feedback-icon\"] {\n    background: var(--destructive-subtle);\n    color: var(--destructive);\n  }\n  @container (max-width: 22rem) {\n    [data-slot=\"feedback-copy\"] {\n      display: contents;\n    }\n    [data-slot=\"feedback-title\"] {\n      grid-column: 2;\n      grid-row: 1;\n      min-width: 0;\n      padding-top: 0.25rem;\n    }\n    [data-slot=\"feedback-description\"],\n    [data-slot=\"feedback-action\"] {\n      grid-column: 1 / -1;\n      margin-top: 0;\n    }\n    [data-slot=\"feedback-description\"] {\n      grid-row: 2;\n    }\n    [data-slot=\"feedback-action\"] {\n      grid-row: 3;\n    }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"feedback-message\"] {\n      animation: none;\n    }\n  }\n}\n"},{"path":"ui/alert.css","type":"registry:file","target":"components/ui/alert.css","content":"/* alert.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"alert\"] {\n    container-type: inline-size;\n    opacity: 1;\n    transform: translateY(0);\n    transition-property: opacity, transform, display;\n    transition-duration: var(--duration-normal);\n    transition-timing-function: var(--ease-out);\n    transition-behavior: allow-discrete;\n  }\n  [data-slot=\"alert\"][hidden] {\n    display: none !important;\n    opacity: 0;\n    pointer-events: none;\n    transition-duration: var(--duration-fast);\n  }\n  @starting-style {\n    [data-slot=\"alert\"]:not([hidden]) {\n      opacity: 0;\n      transform: translateY(var(--motion-enter-distance));\n    }\n  }\n  @container (max-width: 22rem) {\n    [data-slot=\"alert\"] > [data-slot=\"button\"] {\n      grid-column: 3;\n      grid-row: 1;\n    }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"alert\"] {\n      transition: none;\n      transform: none !important;\n    }\n    @starting-style {\n      [data-slot=\"alert\"]:not([hidden]) {\n        opacity: 1;\n        transform: none;\n      }\n    }\n  }\n}\n"},{"path":"ui/shared/loading-indicator.css","type":"registry:file","target":"components/ui/shared/loading-indicator.css","content":"/* Shared by button.tsx, confirm-morph.tsx, expanding-button-group.tsx, field.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"loading-indicator\"] {\n    animation: caliper-spin var(--duration-loading) var(--ease-linear) infinite;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"loading-indicator\"] {\n      animation: none;\n    }\n  }\n}\n"},{"path":"ui/button.css","type":"registry:file","target":"components/ui/button.css","content":"/* button.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"button\"] {\n    transition-property: background-color, color, border-color;\n    transition-duration: var(--duration-fast);\n    transition-timing-function: var(--ease-out);\n  }\n\n  /* Button material. Raised to press: a lit top edge and a small lift; the\n     press travels 1px down and swaps the lift for an inner shade. Only\n     translate animates (release on the settle curve); shadows swap. */\n  [data-slot=\"button\"] {\n    --b-bg: var(--raised);\n    --b-bg-hover: var(--raised-hover);\n    --b-bg-press: var(--raised-press);\n    --b-fg: var(--foreground);\n    --b-shadow: var(--shadow-raised);\n    --b-shadow-press: var(--shadow-raised-press);\n    background-color: var(--b-bg);\n    color: var(--b-fg);\n    box-shadow: var(--b-shadow);\n    transition-property: background-color, color, border-color, translate, scale;\n    transition-duration:\n      var(--duration-fast), var(--duration-fast), var(--duration-fast),\n      var(--duration-normal), var(--duration-normal);\n    transition-timing-function:\n      var(--ease-out), var(--ease-out), var(--ease-out), var(--ease-settle),\n      var(--ease-settle);\n  }\n  [data-slot=\"button\"][data-variant=\"default\"] {\n    --b-bg: var(--primary);\n    --b-bg-hover: var(--primary-hover);\n    --b-bg-press: var(--primary-press);\n    --b-fg: var(--primary-foreground);\n    --b-shadow: var(--shadow-accent);\n    --b-shadow-press: var(--shadow-accent-press);\n  }\n  [data-slot=\"button\"][data-variant=\"destructive\"] {\n    --b-bg: var(--danger-fill);\n    --b-bg-hover: var(--danger-fill-hover);\n    --b-bg-press: var(--danger-fill-press);\n    --b-fg: var(--danger-fill-foreground);\n    --b-shadow: var(--shadow-danger);\n    --b-shadow-press: var(--shadow-danger-press);\n  }\n  [data-slot=\"button\"][data-variant=\"ghost\"] {\n    --b-bg: transparent;\n    --b-bg-hover: var(--muted);\n    --b-bg-press: var(--muted);\n    --b-fg: var(--muted-foreground);\n    --b-shadow: none;\n    --b-shadow-press: none;\n  }\n  @media (hover: hover) {\n    [data-slot=\"button\"]:hover {\n      background-color: var(--b-bg-hover);\n    }\n    [data-slot=\"button\"][data-variant=\"ghost\"]:hover {\n      color: var(--foreground);\n    }\n  }\n  [data-slot=\"button\"]:active:not(:disabled):not([aria-disabled=\"true\"]):not(\n      [aria-busy=\"true\"]\n    ) {\n    translate: 0 var(--motion-press-travel);\n    scale: var(--motion-press-scale);\n    background-color: var(--b-bg-press);\n    box-shadow: var(--b-shadow-press);\n    transition-duration:\n      var(--duration-fast), var(--duration-fast), var(--duration-fast), 90ms,\n      90ms;\n    transition-timing-function: var(--ease-out);\n  }\n  [data-slot=\"button\"]:is(:disabled, [aria-disabled=\"true\"]):not([aria-busy=\"true\"]) {\n    background-color: var(--sunken);\n    color: var(--muted-foreground);\n  }\n  [data-slot=\"button\"][data-variant=\"ghost\"]:is(:disabled, [aria-disabled=\"true\"]):not(\n      [aria-busy=\"true\"]\n    ) {\n    background-color: transparent;\n    box-shadow: none;\n  }\n  /* Loading keeps the exact width: the label stays in flow but invisible. */\n  [data-slot=\"button-label\"] {\n    display: inline-flex;\n    align-items: center;\n    gap: inherit;\n  }\n  /* Opacity, not visibility, so the label still names the button. */\n  [data-slot=\"button-label\"][data-hidden=\"true\"] {\n    opacity: 0;\n  }\n  [data-slot=\"button-busy\"] {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    place-items: center;\n  }\n  [data-slot=\"button-feedback\"] {\n    display: inline-flex;\n    align-items: center;\n    gap: inherit;\n    overflow: clip;\n    transition: width var(--duration-normal) var(--ease-out);\n  }\n  [data-slot=\"button-feedback-label\"] {\n    display: inline-flex;\n    flex-shrink: 0;\n    align-items: center;\n    gap: inherit;\n    width: max-content;\n  }\n  [data-slot=\"button-feedback-label\"][data-enter=\"true\"] {\n    animation: caliper-feedback-enter var(--duration-normal) var(--ease-out);\n  }\n\n  [data-slot=\"button\"][aria-busy=\"true\"] {\n    pointer-events: none;\n  }\n  [data-slot=\"button\"][data-action-phase=\"error\"] {\n    color: var(--danger);\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"button\"],\n    [data-slot=\"button-feedback\"] {\n      transition: none;\n    }\n    [data-slot=\"button\"]:active:not(:disabled):not([aria-disabled=\"true\"]):not(\n        [aria-busy=\"true\"]\n      ) {\n      translate: none;\n    }\n    [data-slot=\"button-feedback-label\"],\n    [data-slot=\"button-feedback-label\"][data-enter=\"true\"] {\n      animation: none;\n    }\n  }\n\n  /* Toggle: pressed is recessed, as a toggled tool in a button group is. */\n  /* Four attributes, so it wins over the group and toolbar pressed rules in any stylesheet order. */\n  [data-slot=\"button\"][data-variant=\"ghost\"][data-toggle][aria-pressed=\"true\"] {\n    --b-bg: var(--raised-press);\n    --b-bg-hover: var(--raised-press);\n    --b-bg-press: var(--raised-press);\n    --b-fg: var(--foreground);\n    --b-shadow: var(--shadow-sunken);\n    --b-shadow-press: var(--shadow-sunken);\n  }\n}\n"},{"path":"ui/feedback.css","type":"registry:file","target":"components/ui/feedback.css","content":"/* feedback.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"feedback-icon\"] {\n    display: flex;\n    width: 2rem;\n    height: 2rem;\n    flex-shrink: 0;\n    align-items: center;\n    justify-content: center;\n    border-radius: var(--radius-surface);\n    background: var(--muted);\n    color: var(--muted-foreground);\n    transition:\n      color var(--duration-fast) var(--ease-out),\n      background-color var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"feedback-symbol\"] {\n    display: flex;\n    animation: caliper-feedback-symbol-enter var(--duration-normal) var(--ease-out);\n  }\n  [data-slot=\"feedback-icon\"] svg {\n    width: 1rem;\n    height: 1rem;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"feedback-icon\"] {\n      transition: none;\n      transform: none !important;\n    }\n    [data-slot=\"feedback-symbol\"] {\n      animation: none;\n    }\n  }\n}\n"}],"categories":["feedback"],"meta":{"tier":"free","license":"MIT","family":"feedback","docs":"/docs/components/alert"}}