{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "title": "Toast",
  "description": "Quiet notifications that stack, follow a promise and swipe away.",
  "dependencies": [
    "@base-ui/react",
    "@hugeicons/core-free-icons",
    "@hugeicons/react",
    "@stylexjs/stylex"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/button.json",
    "https://constella.aayush.cv/r/spinner.json"
  ],
  "files": [
    {
      "path": "src/components/primitives/toast.tsx",
      "content": "\"use client\";\n\nimport { Toast as ToastPrimitive } from \"@base-ui/react/toast\";\nimport {\n  Alert02Icon,\n  AlertCircleIcon,\n  Cancel01Icon,\n  CheckmarkCircle02Icon,\n  InformationCircleIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type { IconSvgElement } from \"@hugeicons/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\n\nimport { Button } from \"@/components/primitives/button\";\nimport { Spinner } from \"@/components/primitives/spinner\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  easings,\n  fontWeights,\n  layers,\n  lineHeights,\n  media,\n  motion,\n  presses,\n  radii,\n  shadows,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\nconst toast = ToastPrimitive.createToastManager();\n\n// Base UI measures the stack and swipe; these turn its variables into motion.\nconst gap = space.sm;\nconst peek = space.sm;\nconst height = \"var(--toast-frontmost-height, var(--toast-height))\";\nconst scale = `max(0, 1 - var(--toast-index) * ${motion.stackScale})`;\nconst offsetY = `calc(var(--toast-offset-y) * -1 - var(--toast-index) * ${gap} + var(--toast-swipe-movement-y))`;\nconst swipeX = \"var(--toast-swipe-movement-x)\";\nconst swipeY = \"var(--toast-swipe-movement-y)\";\n\nconst textIn = stylex.keyframes({\n  from: { filter: `blur(${motion.crossfadeTextBlur})`, opacity: 0 },\n});\nconst textOut = stylex.keyframes({\n  to: { filter: `blur(${motion.crossfadeTextBlur})`, opacity: 0 },\n});\n\nconst layoutAnimation = {\n  // The Web Animations API takes bare milliseconds; the token carries `ms`.\n  duration: Number(durations.layout.slice(0, -\"ms\".length)),\n  easing: easings.layout,\n};\n\n// `error`, not `danger`: Base UI's `toast.promise` sets it.\nconst statuses = [\"error\", \"info\", \"success\", \"warning\"] as const;\nconst toastTypes = [...statuses, \"loading\"] as const;\n\ntype ToastType = (typeof toastTypes)[number];\n\nconst icons = {\n  error: AlertCircleIcon,\n  info: InformationCircleIcon,\n  success: CheckmarkCircle02Icon,\n  warning: Alert02Icon,\n} satisfies Record<(typeof statuses)[number], IconSvgElement>;\n\nfunction isToastType(type: string | undefined): type is ToastType {\n  return toastTypes.some((name) => name === type);\n}\n\nconst styles = stylex.create({\n  viewport: {\n    bottom: space.md,\n    insetInlineEnd: space.md,\n    position: \"fixed\",\n    width: {\n      default: `calc(100vw - 2 * ${space.md})`,\n      [media.sm]: sizes.toast,\n    },\n    zIndex: layers.toast,\n  },\n  toast: {\n    borderStartStartRadius: radii.md,\n    borderStartEndRadius: radii.md,\n    borderEndStartRadius: radii.md,\n    borderEndEndRadius: radii.md,\n    color: colors.textPrimary,\n    cursor: \"default\",\n    height: {\n      default: height,\n      \":is([data-expanded])\": \"var(--toast-height)\",\n    },\n    insetBlockEnd: 0,\n    insetInlineEnd: 0,\n    opacity: {\n      default: 1,\n      \":is([data-limited], [data-starting-style], [data-ending-style])\": 0,\n    },\n    position: \"absolute\",\n    transform: {\n      default: `translateX(${swipeX}) translateY(calc(${swipeY} - var(--toast-index) * ${peek} - (1 - ${scale}) * ${height})) scale(${scale})`,\n      \":is([data-expanded])\": `translateX(${swipeX}) translateY(${offsetY})`,\n      // Two attributes, so these outrank the expanded position above.\n      \":is([data-starting-style]):not([data-swipe-direction])\":\n        \"translateY(100%)\",\n      \":is([data-ending-style]):not([data-swipe-direction])\":\n        \"translateY(100%)\",\n      \":is([data-ending-style][data-swipe-direction='down'])\": `translateY(calc(${swipeY} + 150%))`,\n      \":is([data-ending-style][data-swipe-direction='right'])\": `translateX(calc(${swipeX} + 150%)) translateY(${offsetY})`,\n    },\n    transformOrigin: \"bottom center\",\n    transitionDuration: {\n      default: durations.move,\n      \":is([data-ending-style])\": durations.popoverExit,\n    },\n    transitionProperty: {\n      default: \"opacity, height, transform\",\n      // The toast follows the finger with no lag.\n      \":is([data-swiping])\": \"opacity, height\",\n      [media.reducedMotion]: \"opacity\",\n    },\n    transitionTimingFunction: easings.out,\n    userSelect: \"none\",\n    width: \"100%\",\n    zIndex: \"calc(1000 - var(--toast-index))\",\n    // The surface is its own layer, so a content change can grow it without\n    // resizing the toast, which Base UI is measuring.\n    \"::before\": {\n      backgroundColor: colors.raised,\n      borderStartStartRadius: radii.md,\n      borderStartEndRadius: radii.md,\n      borderEndStartRadius: radii.md,\n      borderEndEndRadius: radii.md,\n      boxShadow: shadows.lg,\n      content: \"''\",\n      insetBlockEnd: 0,\n      insetBlockStart: 0,\n      insetInlineEnd: 0,\n      insetInlineStart: 0,\n      position: \"absolute\",\n      zIndex: -1,\n    },\n    // Bridges the gap between toasts, so moving across it keeps the stack expanded.\n    \"::after\": {\n      content: \"''\",\n      height: `calc(${gap} + 1px)`,\n      insetBlockStart: \"100%\",\n      insetInlineEnd: 0,\n      insetInlineStart: 0,\n      position: \"absolute\",\n    },\n  },\n  content: {\n    alignItems: \"flex-start\",\n    display: \"flex\",\n    gap: space.sm,\n    opacity: {\n      default: 1,\n      \":is([data-behind]):not([data-expanded])\": 0,\n    },\n    paddingBlock: space.sm,\n    paddingInlineEnd: space.sm,\n    paddingInlineStart: {\n      default: space.md,\n      \":has(> [data-slot='toast-icon'])\": space.sm,\n    },\n    transitionDuration: durations.move,\n    transitionProperty: \"opacity\",\n    transitionTimingFunction: easings.out,\n  },\n  icon: {\n    color: colors.textSecondary,\n    flexShrink: 0,\n    height: sizes.icon,\n    // Centers the icon on the title's first line.\n    marginBlockStart: `calc((${lineHeights.textSm} - ${sizes.icon}) / 2)`,\n    position: \"relative\",\n    width: sizes.icon,\n  },\n  // Every icon stays mounted, so a type change cross-fades instead of swapping.\n  layer: {\n    // Flex, so the SVG doesn't sit on a text baseline a pixel low.\n    display: \"flex\",\n    filter: `blur(${motion.crossfadeBlur})`,\n    insetBlockEnd: 0,\n    insetBlockStart: 0,\n    insetInlineEnd: 0,\n    insetInlineStart: 0,\n    opacity: 0,\n    position: \"absolute\",\n    transform: `scale(${motion.crossfadeScale})`,\n    transitionDuration: durations.crossfade,\n    transitionProperty: {\n      default: \"opacity, transform, filter\",\n      [media.reducedMotion]: \"opacity\",\n    },\n    transitionTimingFunction: easings.crossfade,\n  },\n  shown: {\n    filter: \"blur(0)\",\n    opacity: 1,\n    transform: \"scale(1)\",\n  },\n  error: { color: colors.dangerText },\n  info: { color: colors.infoText },\n  success: { color: colors.successText },\n  warning: { color: colors.warningText },\n  // The text and its action share a row while both fit, and the action drops under the text\n  // when its label is too long, so it never pushes the close button out.\n  main: {\n    alignItems: \"center\",\n    columnGap: space.sm,\n    display: \"flex\",\n    flex: 1,\n    flexWrap: \"wrap\",\n    minWidth: 0,\n    rowGap: space.xs,\n  },\n  body: {\n    display: \"flex\",\n    flexBasis: sizes.menu,\n    flexDirection: \"column\",\n    flexGrow: 1,\n    gap: space.xxxs,\n    minWidth: 0,\n    position: \"relative\",\n  },\n  // Blur bridges the two texts, so they read as one changing instead of two overlapping.\n  entering: {\n    animationDuration: durations.crossfade,\n    animationName: textIn,\n    animationTimingFunction: easings.out,\n  },\n  leaving: {\n    animationDuration: durations.popover,\n    animationFillMode: \"forwards\",\n    animationName: textOut,\n    animationTimingFunction: easings.out,\n    insetBlockStart: 0,\n    insetInlineEnd: 0,\n    insetInlineStart: 0,\n    pointerEvents: \"none\",\n    position: \"absolute\",\n  },\n  title: {\n    fontWeight: fontWeights.medium,\n    overflowWrap: \"anywhere\",\n    textWrap: \"balance\",\n  },\n  description: {\n    color: colors.textSecondary,\n    overflowWrap: \"anywhere\",\n    textWrap: \"pretty\",\n  },\n  action: {\n    maxWidth: \"100%\",\n  },\n  close: {\n    alignItems: \"center\",\n    // Pulled past the content padding into the corner, `space.xxs` from both edges.\n    marginBlockStart: `calc(${space.xxs} - ${space.sm})`,\n    marginInlineEnd: `calc(${space.xxs} - ${space.sm})`,\n    backgroundColor: {\n      default: \"transparent\",\n      [media.hover]: { default: \"transparent\", \":hover\": colors.fillSubtle },\n      \":active\": colors.fillSubtle,\n    },\n    borderStartStartRadius: radii.sm,\n    borderStartEndRadius: radii.sm,\n    borderEndStartRadius: radii.sm,\n    borderEndEndRadius: radii.sm,\n    borderBlockStyle: \"none\",\n    borderInlineStyle: \"none\",\n    color: {\n      default: colors.textMuted,\n      [media.hover]: {\n        default: colors.textMuted,\n        \":hover\": colors.textPrimary,\n      },\n      \":active\": colors.textPrimary,\n    },\n    cursor: \"pointer\",\n    display: \"flex\",\n    flexShrink: 0,\n    height: sizes.controlXxs,\n    justifyContent: \"center\",\n    paddingBlock: 0,\n    paddingInlineEnd: 0,\n    paddingInlineStart: 0,\n    position: \"relative\",\n    touchAction: \"manipulation\",\n    transform: { default: null, \":active\": presses.icon },\n    transitionDuration: `${durations.press}, ${durations.hover}, ${durations.hover}`,\n    transitionProperty: \"transform, background-color, color\",\n    transitionTimingFunction: `${easings.out}, ease, ease`,\n    WebkitTapHighlightColor: \"transparent\",\n    width: sizes.controlXxs,\n    \"::before\": {\n      content: \"''\",\n      insetBlockEnd: `calc((${sizes.controlXxs} - ${sizes.hitArea}) / 2)`,\n      insetBlockStart: `calc((${sizes.controlXxs} - ${sizes.hitArea}) / 2)`,\n      insetInlineEnd: `calc((${sizes.controlXxs} - ${sizes.hitArea}) / 2)`,\n      insetInlineStart: `calc((${sizes.controlXxs} - ${sizes.hitArea}) / 2)`,\n      position: \"absolute\",\n    },\n  },\n});\n\nfunction ToastProvider(props: ToastPrimitive.Provider.Props) {\n  return <ToastPrimitive.Provider {...props} />;\n}\n\nfunction ToastPortal({ sx, ...props }: Styled<ToastPrimitive.Portal.Props>) {\n  return (\n    <ToastPrimitive.Portal\n      data-slot=\"toast-portal\"\n      {...props}\n      {...stylex.props(sx)}\n    />\n  );\n}\n\nfunction ToastViewport({\n  sx,\n  ...props\n}: Styled<ToastPrimitive.Viewport.Props>) {\n  return (\n    <ToastPrimitive.Viewport\n      data-slot=\"toast-viewport\"\n      {...props}\n      {...stylex.props(styles.viewport, sx)}\n    />\n  );\n}\n\nfunction Toast({ sx, ...props }: Styled<ToastPrimitive.Root.Props>) {\n  return (\n    <ToastPrimitive.Root\n      data-slot=\"toast\"\n      {...props}\n      {...stylex.props(styles.toast, sx)}\n    />\n  );\n}\n\nfunction ToastContent({ sx, ...props }: Styled<ToastPrimitive.Content.Props>) {\n  return (\n    <ToastPrimitive.Content\n      data-slot=\"toast-content\"\n      {...props}\n      {...stylex.props(styles.content, sx)}\n    />\n  );\n}\n\n// Hue only on the icon: the shape and title still say it without color.\nconst tones = {\n  error: styles.error,\n  info: styles.info,\n  success: styles.success,\n  warning: styles.warning,\n} satisfies Record<(typeof statuses)[number], stylex.StyleXStyles>;\n\nfunction ToastIcon({\n  sx,\n  type,\n  ...props\n}: Styled<ComponentProps<\"span\">> & { type: ToastType }) {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"toast-icon\"\n      data-type={type}\n      {...props}\n      {...stylex.props(styles.icon, sx)}\n    >\n      <span {...stylex.props(styles.layer, type === \"loading\" && styles.shown)}>\n        <Spinner />\n      </span>\n      {statuses.map((name) => (\n        <span\n          key={name}\n          {...stylex.props(\n            styles.layer,\n            tones[name],\n            name === type && styles.shown\n          )}\n        >\n          <HugeiconsIcon\n            icon={icons[name]}\n            size={sizes.icon}\n            strokeWidth={Number(strokes.icon)}\n          />\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction ToastBody({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div data-slot=\"toast-body\" {...props} {...stylex.props(styles.body, sx)} />\n  );\n}\n\nfunction ToastTitle({ sx, ...props }: Styled<ToastPrimitive.Title.Props>) {\n  return (\n    <ToastPrimitive.Title\n      data-slot=\"toast-title\"\n      {...props}\n      {...stylex.props([typeStyles.textSm, styles.title], sx)}\n    />\n  );\n}\n\nfunction ToastDescription({\n  sx,\n  ...props\n}: Styled<ToastPrimitive.Description.Props>) {\n  return (\n    <ToastPrimitive.Description\n      data-slot=\"toast-description\"\n      {...props}\n      {...stylex.props([typeStyles.textSm, styles.description], sx)}\n    />\n  );\n}\n\n// The styles ride on the Button: it applies its own last, over anything Base UI merges in.\nfunction ToastAction({\n  sx,\n  ...props\n}: Omit<Styled<ToastPrimitive.Action.Props>, \"render\">) {\n  return (\n    <ToastPrimitive.Action\n      data-slot=\"toast-action\"\n      render={<Button size=\"xs\" sx={[styles.action, sx]} variant=\"primary\" />}\n      {...props}\n    />\n  );\n}\n\nfunction ToastClose({\n  children,\n  sx,\n  ...props\n}: Styled<ToastPrimitive.Close.Props>) {\n  return (\n    <ToastPrimitive.Close\n      aria-label=\"Close\"\n      data-slot=\"toast-close\"\n      {...props}\n      {...stylex.props(styles.close, sx)}\n    >\n      {children ?? (\n        <HugeiconsIcon\n          aria-hidden\n          icon={Cancel01Icon}\n          size={sizes.iconSm}\n          strokeWidth={Number(strokes.icon)}\n        />\n      )}\n    </ToastPrimitive.Close>\n  );\n}\n\ninterface Snapshot {\n  description: ReactNode;\n  key: number;\n  title: ReactNode;\n}\n\nfunction snapshot(item: ToastPrimitive.Root.ToastObject): Snapshot {\n  return {\n    description: item.description,\n    key: item.updateKey ?? 0,\n    title: item.title,\n  };\n}\n\n// Base UI re-measures the toast whenever its content resizes, reading any\n// height animation mid-flight. So the toast takes its new height at once, and\n// only the surface and content, which it never measures, travel from the old one.\nfunction useHeightMorph(natural: number | undefined) {\n  const root = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const measured = useRef(natural);\n\n  useLayoutEffect(() => {\n    const from = measured.current;\n    measured.current = natural;\n\n    if (\n      !root.current ||\n      !content.current ||\n      from === undefined ||\n      natural === undefined ||\n      from === natural ||\n      // Collapsed toasts behind take the front one's height; only one showing its own animates.\n      root.current.offsetHeight !== natural ||\n      matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    ) {\n      return;\n    }\n\n    // Bottom-anchored, so the old top edge sits `grown` px lower.\n    const grown = natural - from;\n\n    root.current.animate([{ top: `${grown}px` }, { top: \"0px\" }], {\n      ...layoutAnimation,\n      pseudoElement: \"::before\",\n    });\n    content.current.animate(\n      [\n        {\n          clipPath: `inset(0 0 ${Math.max(grown, 0)}px 0)`,\n          transform: `translateY(${grown}px)`,\n        },\n        { clipPath: \"inset(0 0 0 0)\", transform: \"none\" },\n      ],\n      layoutAnimation\n    );\n  }, [natural]);\n\n  return [root, content] as const;\n}\n\nfunction ToastItem({ item }: { item: ToastPrimitive.Root.ToastObject }) {\n  const [rootRef, contentRef] = useHeightMorph(item.height);\n\n  // The old text stays on screen, fading out, under the new text fading in.\n  const [seen, setSeen] = useState(() => snapshot(item));\n  const [previous, setPrevious] = useState<Snapshot | null>(null);\n\n  if ((item.updateKey ?? 0) !== seen.key) {\n    setPrevious(seen);\n    setSeen(snapshot(item));\n  }\n\n  return (\n    <Toast ref={rootRef} toast={item}>\n      <ToastContent ref={contentRef}>\n        {isToastType(item.type) && <ToastIcon type={item.type} />}\n        <div {...stylex.props(styles.main)}>\n          <ToastBody>\n            {previous && (\n              <span\n                aria-hidden\n                key={previous.key}\n                onAnimationEnd={() => {\n                  setPrevious(null);\n                }}\n                {...stylex.props(styles.body, styles.leaving)}\n              >\n                <span {...stylex.props([typeStyles.textSm, styles.title])}>\n                  {previous.title}\n                </span>\n                {previous.description === undefined ? null : (\n                  <span\n                    {...stylex.props([typeStyles.textSm, styles.description])}\n                  >\n                    {previous.description}\n                  </span>\n                )}\n              </span>\n            )}\n            <ToastTitle key={seen.key} sx={previous && styles.entering} />\n            <ToastDescription\n              key={`d${seen.key}`}\n              sx={previous && styles.entering}\n            />\n          </ToastBody>\n          <ToastAction />\n        </div>\n        <ToastClose />\n      </ToastContent>\n    </Toast>\n  );\n}\n\nfunction ToastList() {\n  const { toasts } = ToastPrimitive.useToastManager();\n\n  return toasts.map((item) => <ToastItem item={item} key={item.id} />);\n}\n\nfunction Toaster({\n  children,\n  sx,\n  toastManager = toast,\n  ...props\n}: ToastPrimitive.Provider.Props & { sx?: Sx }) {\n  return (\n    <ToastProvider toastManager={toastManager} {...props}>\n      {children}\n      <ToastPortal>\n        <ToastViewport sx={sx}>\n          <ToastList />\n        </ToastViewport>\n      </ToastPortal>\n    </ToastProvider>\n  );\n}\n\nconst { useToastManager } = ToastPrimitive;\n\nexport {\n  Toast,\n  ToastAction,\n  ToastBody,\n  ToastClose,\n  ToastContent,\n  ToastDescription,\n  Toaster,\n  ToastIcon,\n  ToastPortal,\n  ToastProvider,\n  ToastTitle,\n  ToastViewport,\n  toast,\n  toastTypes,\n  useToastManager,\n};\nexport type { ToastType };\n",
      "type": "registry:component"
    }
  ],
  "docs": "Render <Toaster /> once near the root, then call toast.add({ title }) from anywhere. Usage: https://constella.aayush.cv/docs/components/toast/index.md",
  "type": "registry:ui"
}