{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "icon-transition",
  "title": "Icon Transition",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/icon-transition/IconTransition.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref } from 'vue'\nimport type { Component, HTMLAttributes } from 'vue'\n\n// Self-contained icon-swap control. Click runs an optional async `action`,\n// then flips from `defaultIcon` to `activeIcon` with a spring pop. By default\n// it auto-reverts after 1500ms; pass `:resetAfter=\"0\"` to keep the active\n// icon, then call `iconRef.value.reset()` manually.\n//\n// Two operating modes:\n//   1. Self-managed (most common): pass `defaultIcon`, `activeIcon`, and\n//      either `action` or just listen to `@activate`. The component runs\n//      the action, manages its own `active` state, and auto-resets.\n//   2. Externally controlled: pass `:active=\"boolean\"` and the component\n//      will mirror that prop, ignoring its internal flag.\n//\n// Renders as a <button> by default. Pass `as=\"span\"` for a non-interactive\n// icon (e.g. inside a parent button) — in that mode the parent is responsible\n// for triggering `trigger()` via the exposed ref.\n\nconst props = withDefaults(\n  defineProps<{\n    defaultIcon: Component\n    activeIcon: Component\n    /** Tailwind size/color applied to the icons. */\n    iconClass?: HTMLAttributes['class']\n    /** Async work executed on click before the icon flips. Return `false` to skip the flip. */\n    action?: () => boolean | void | Promise<boolean | void>\n    /** ms before reverting to defaultIcon. Set to `0` (or null) to stay active. */\n    resetAfter?: number | null\n    /** Pop animation duration in ms (also scales the leave fade). */\n    duration?: number\n    /** aria-label shown in default state. */\n    label?: string\n    /** aria-label shown after activation; falls back to `label`. */\n    activeLabel?: string\n    /** Tailwind class applied while active (e.g. \"text-success\"). */\n    activeClass?: string\n    /** Render element. `button` adds click handler + focus styling; `span` is purely visual. */\n    as?: 'button' | 'span'\n    /** External control. When provided, this prop wins over internal state. */\n    active?: boolean\n  }>(),\n  {\n    iconClass: 'size-4',\n    resetAfter: 1500,\n    duration: 240,\n    activeClass: 'text-success',\n    as: 'button',\n    active: undefined,\n  },\n)\n\nconst emit = defineEmits<{\n  activate: []\n  reset: []\n}>()\n\nconst internalActive = ref(false)\nconst isActive = computed(() => (props.active !== undefined ? props.active : internalActive.value))\n\nlet timer: ReturnType<typeof setTimeout> | null = null\n\nasync function trigger() {\n  if (props.action) {\n    const result = await props.action()\n    if (result === false) return\n  }\n  internalActive.value = true\n  emit('activate')\n  clearTimer()\n  if (props.resetAfter && props.resetAfter > 0) {\n    timer = setTimeout(reset, props.resetAfter)\n  }\n}\n\nfunction reset() {\n  internalActive.value = false\n  emit('reset')\n  clearTimer()\n}\n\nfunction clearTimer() {\n  if (timer) {\n    clearTimeout(timer)\n    timer = null\n  }\n}\n\ndefineExpose({ trigger, reset })\n\nonBeforeUnmount(clearTimer)\n</script>\n\n<template>\n  <component\n    :is=\"as\"\n    v-bind=\"as === 'button' ? { type: 'button' } : {}\"\n    :aria-label=\"isActive ? (activeLabel ?? label) : label\"\n    :aria-live=\"isActive ? 'polite' : undefined\"\n    :class=\"[\n      'icon-transition focus-visible:ring-ring inline-grid place-items-center transition-colors focus-visible:ring-2 focus-visible:outline-none',\n      isActive ? activeClass : '',\n    ]\"\n    :style=\"{ '--it-duration': `${duration}ms` }\"\n    @click=\"as === 'button' ? trigger() : null\"\n  >\n    <Transition name=\"icon-transition\" mode=\"default\">\n      <!-- Wrapping span isolates the absolute-positioning + transform from\n        the icon itself. Putting position:absolute + inset:0 directly on the\n        SVG would stretch it to fill the parent (e.g. 28×28 inside a size-7\n        button) and the icon would visually balloon mid-transition; the\n        wrapper takes the layout role and the SVG keeps its intrinsic\n        iconClass-driven size. -->\n      <span :key=\"isActive ? 'active' : 'default'\" class=\"icon-transition-slot\">\n        <component :is=\"isActive ? activeIcon : defaultIcon\" :class=\"iconClass\" aria-hidden=\"true\" />\n      </span>\n    </Transition>\n  </component>\n</template>\n\n<style scoped>\n.icon-transition-slot {\n  /* Both default and active icons map to the same grid cell so they can\n     overlap during the cross-fade without ever leaving the parent's flow.\n     No `position: absolute` here — that would have to inset:0 the slot,\n     stretching it to the button size and dragging the SVG along with it. */\n  grid-area: 1 / 1;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n}\n.icon-transition-enter-active,\n.icon-transition-leave-active {\n  transform-origin: center;\n}\n.icon-transition-enter-active {\n  animation: icon-transition-pop var(--it-duration, 240ms) cubic-bezier(0.34, 1.56, 0.64, 1) both;\n}\n.icon-transition-leave-active {\n  animation: icon-transition-fade calc(var(--it-duration, 240ms) * 0.66) ease-in both;\n}\n@keyframes icon-transition-pop {\n  0% {\n    opacity: 0;\n    transform: scale(0.5) rotate(-12deg);\n  }\n  60% {\n    opacity: 1;\n    transform: scale(1.18) rotate(2deg);\n  }\n  100% {\n    opacity: 1;\n    transform: scale(1) rotate(0deg);\n  }\n}\n@keyframes icon-transition-fade {\n  to {\n    opacity: 0;\n    transform: scale(0.85);\n  }\n}\n@media (prefers-reduced-motion: reduce) {\n  .icon-transition-enter-active,\n  .icon-transition-leave-active {\n    animation-duration: 0ms !important;\n  }\n}\n</style>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/icon-transition/IconTransition.vue"
    },
    {
      "path": "packages/registry-vue/components/icon-transition/index.ts",
      "content": "export { default as IconTransition } from './IconTransition.vue'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/icon-transition/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Animated icon swap for click acknowledgements — copy → check, bookmark → bookmark-filled, follow → follower. Pass two icon components and an async `action`; clicking runs the action then springs the active icon in. Auto-reverts after `resetAfter` ms (default 1500), or pass `0` to keep the active icon and call the exposed `reset()` method to flip it back. Renders as a `<button>` by default; use `as=\"span\"` plus the `:active` prop when you want an externally controlled, non-interactive icon swap inside another control. Honors `prefers-reduced-motion`.",
  "categories": [
    "feedback"
  ]
}