{"slug":"theme-toggle","name":"Theme Toggle","description":"Theme toggle button that repaints the whole page through the View Transition API — a rectangle or circle clip-path reveal, or slats that open across the screen like a shutter.","category":"motion","source_url":"https://beui.dev/r/theme-toggle/raw","detail_url":"https://beui.dev/r/theme-toggle","raw_url":"https://beui.dev/r/theme-toggle/raw","page_url":"https://beui.dev/components/motion/theme-toggle","markdown_url":"https://beui.dev/components/motion/theme-toggle.md","published_at":"2026-06-15","updated_at":"2026-09-22","dependencies":["clsx","lucide-react","motion","next-themes","react","tailwind-merge"],"internal":["@/components/motion/action-swap","@/components/motion/theme-toggle","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/theme-toggle.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/theme-toggle\n\nimport { Moon, Sun } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useEffect, useState, type ComponentPropsWithoutRef } from \"react\";\nimport { ActionSwapIcon } from \"@/components/motion/action-swap\";\nimport { EASE_OUT_CSS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ThemeVariant = \"rectangle\" | \"circle\" | \"circle-blur\" | \"blinds\";\n\nexport type RectStart =\n  | \"top-left\"\n  | \"top-right\"\n  | \"bottom-left\"\n  | \"bottom-right\"\n  | \"center\"\n  | \"bottom-up\";\n\nexport interface ThemeToggleProps\n  extends Omit<ComponentPropsWithoutRef<\"button\">, \"children\" | \"onClick\"> {\n  /** Animation variant. Default: \"rectangle\". */\n  variant?: ThemeVariant;\n  /** Origin direction for the reveal. Default: \"bottom-up\". */\n  start?: RectStart;\n  iconClassName?: string;\n}\n\nconst VT_STYLE_ID = \"beui-theme-toggle-vt\";\n\n// View transitions animate in CSS, not motion springs, so easing here is\n// either EASE_OUT_CSS or a keyword. The circle variants keep the Material\n// standard curve because their reveal expands symmetrically rather than\n// decelerating. Durations differ per variant to match native OS mode switches.\nconst VT_CSS = `\nhtml[data-beui-vt=\"rect\"]::view-transition-old(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\nhtml[data-beui-vt=\"rect\"]::view-transition-new(root) {\n  mix-blend-mode: normal;\n  animation: beui-rect-reveal 400ms ease-out;\n}\nhtml[data-beui-vt=\"circle\"]::view-transition-old(root),\nhtml[data-beui-vt=\"circle-blur\"]::view-transition-old(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\nhtml[data-beui-vt=\"circle\"]::view-transition-new(root) {\n  mix-blend-mode: normal;\n  animation: beui-circle-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);\n}\nhtml[data-beui-vt=\"circle-blur\"]::view-transition-new(root) {\n  mix-blend-mode: normal;\n  animation: beui-circle-blur-reveal 700ms cubic-bezier(0.4, 0, 0.2, 1);\n}\nhtml[data-beui-vt=\"blinds\"]::view-transition-old(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\n/* Slats: a masked band widens inside every 72px tile, so the new theme opens\n   across the page like a shutter. The band edge has to be a registered custom\n   property — mask-image itself is not animatable, but it re-resolves every\n   frame the property ticks. mask-size fixes the tile at 72px rather than\n   letting a repeating gradient's last stop define it, which is what keeps the\n   20px soft edge from dragging the tile wider than the slat and leaving a\n   feathered gap that never closes; it also means both ends land clean, fully\n   transparent at -20px and fully opaque at 72px. Falling back to no mask\n   (unregistered property, so the var is invalid) reveals the page in one\n   step. */\n@property --beui-vt-slat {\n  syntax: \"<length>\";\n  inherits: false;\n  initial-value: 72px;\n}\nhtml[data-beui-vt=\"blinds\"]::view-transition-new(root) {\n  mix-blend-mode: normal;\n  mask-image: linear-gradient(\n    90deg,\n    #000 0 var(--beui-vt-slat),\n    transparent calc(var(--beui-vt-slat) + 20px)\n  );\n  mask-size: 72px 100%;\n  mask-repeat: repeat;\n  animation: beui-blinds-reveal 700ms ${EASE_OUT_CSS};\n}\n@keyframes beui-rect-reveal {\n  from { clip-path: var(--beui-vt-from, inset(100% 0 0 0)); }\n  to   { clip-path: inset(0 0 0 0); }\n}\n@keyframes beui-circle-reveal {\n  from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); }\n  to   { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); }\n}\n@keyframes beui-circle-blur-reveal {\n  from { clip-path: circle(0% at var(--beui-vt-origin, 50% 100%)); filter: blur(8px); }\n  to   { clip-path: circle(150% at var(--beui-vt-origin, 50% 100%)); filter: blur(0px); }\n}\n@keyframes beui-blinds-reveal {\n  from { --beui-vt-slat: -20px; }\n  to   { --beui-vt-slat: 72px; }\n}\n`;\n\nconst RECT_FROM: Record<RectStart, string> = {\n  \"top-left\":    \"inset(0 100% 100% 0)\",\n  \"top-right\":   \"inset(0 0 100% 100%)\",\n  \"bottom-left\": \"inset(100% 100% 0 0)\",\n  \"bottom-right\":\"inset(100% 0 0 100%)\",\n  center:        \"inset(50% 50% 50% 50%)\",\n  \"bottom-up\":   \"inset(100% 0 0 0)\",\n};\n\nconst CIRCLE_ORIGIN: Record<RectStart, string> = {\n  \"top-left\":    \"0% 0%\",\n  \"top-right\":   \"100% 0%\",\n  \"bottom-left\": \"0% 100%\",\n  \"bottom-right\":\"100% 100%\",\n  center:        \"50% 50%\",\n  \"bottom-up\":   \"50% 100%\",\n};\n\nexport function useThemeToggle({\n  variant = \"rectangle\",\n  start = \"bottom-up\",\n}: { variant?: ThemeVariant; start?: RectStart } = {}) {\n  const { setTheme, resolvedTheme } = useTheme();\n  const reduce = useReducedMotion() ?? false;\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  useEffect(() => {\n    if (document.getElementById(VT_STYLE_ID)) return;\n    const el = document.createElement(\"style\");\n    el.id = VT_STYLE_ID;\n    el.textContent = VT_CSS;\n    document.head.appendChild(el);\n  }, []);\n  const isDark = mounted && resolvedTheme === \"dark\";\n\n  const toggle = () => {\n    const next = isDark ? \"light\" : \"dark\";\n\n    if (reduce || !(\"startViewTransition\" in document)) {\n      setTheme(next);\n      return;\n    }\n\n    const root = document.documentElement;\n\n    if (variant === \"rectangle\") {\n      root.style.setProperty(\"--beui-vt-from\", RECT_FROM[start]);\n      root.dataset.beuiVt = \"rect\";\n    } else if (variant === \"blinds\") {\n      // Slats sweep the whole viewport; there is no origin point to set.\n      root.dataset.beuiVt = \"blinds\";\n    } else {\n      root.style.setProperty(\"--beui-vt-origin\", CIRCLE_ORIGIN[start]);\n      root.dataset.beuiVt = variant;\n    }\n\n    const vt = (\n      document as Document & {\n        startViewTransition(cb: () => void): { finished: Promise<void> };\n      }\n    ).startViewTransition(() => setTheme(next));\n\n    vt.finished.finally(() => {\n      delete root.dataset.beuiVt;\n    });\n  };\n\n  return { isDark, mounted, toggle };\n}\n\nexport function ThemeToggle({\n  variant = \"rectangle\",\n  start = \"bottom-up\",\n  className,\n  iconClassName,\n  ...rest\n}: ThemeToggleProps) {\n  const { isDark, mounted, toggle } = useThemeToggle({ variant, start });\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={mounted && isDark ? \"Switch to light mode\" : \"Switch to dark mode\"}\n      onClick={toggle}\n      className={cn(\"flex items-center justify-center\", className)}\n      {...rest}\n    >\n      {mounted ? (\n        <ActionSwapIcon\n          value={isDark ? \"dark\" : \"light\"}\n          animation=\"blur\"\n          className={iconClassName}\n        >\n          {isDark ? (\n            <Sun className={iconClassName} />\n          ) : (\n            <Moon className={iconClassName} />\n          )}\n        </ActionSwapIcon>\n      ) : (\n        <span className={iconClassName} aria-hidden=\"true\" />\n      )}\n    </button>\n  );\n}\n"},{"path":"components/motion/action-swap.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nconst BLUR_TRANSITION = { duration: 0.2, ease: \"easeInOut\" } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\nconst SWAP_BLUR = \"blur(8px)\";\nconst ROLL_BLUR = \"blur(3px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"90%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-90%\",\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 12, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -12,\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n  ghost: \"text-muted-foreground hover:bg-muted/60 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\n        \"relative -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom\",\n        className,\n      )}\n      style={{\n        clipPath: \"inset(0 -999px)\",\n        WebkitClipPath: \"inset(0 -999px)\",\n      }}\n    >\n      <span\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-[0.08em] inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            // Truncation lives on the layer that holds the text — the layer\n            // moves as a whole, so clipping it never eats the roll.\n            className=\"absolute left-0 top-[0.08em] inline-block max-w-full truncate will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {hasIcon ? (\n        <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n          {activeItem.icon ?? null}\n        </ActionSwapIcon>\n      ) : null}\n      {!iconOnly ? (\n        <ActionSwapText value={activeItem.id} animation={animation}>\n          {activeItem.label}\n        </ActionSwapText>\n      ) : null}\n    </motion.button>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/theme-toggle.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { ThemeToggle, type ThemeVariant } from \"@/components/motion/theme-toggle\";\n\nconst VARIANTS: { variant: ThemeVariant; label: string }[] = [\n  { variant: \"rectangle\", label: \"Rectangle\" },\n  { variant: \"circle\", label: \"Circle\" },\n  { variant: \"circle-blur\", label: \"Circle blur\" },\n  { variant: \"blinds\", label: \"Blinds\" },\n];\n\nexport function ThemeTogglePreview() {\n  return (\n    <div className=\"flex h-full w-full items-center justify-center gap-5\">\n      {VARIANTS.map(({ variant, label }) => (\n        <div key={variant} className=\"flex flex-col items-center gap-2\">\n          <ThemeToggle\n            variant={variant}\n            start=\"bottom-up\"\n            className=\"rounded-xl border border-border bg-background p-2.5\"\n            iconClassName=\"h-5 w-5\"\n          />\n          <span className=\"text-[11px] text-muted-foreground\">{label}</span>\n        </div>\n      ))}\n    </div>\n  );\n}\n"}]}