{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "text-reveal",
  "title": "Text Reveal",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/text-reveal/TextReveal.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\n/**\n * Scroll-triggered staggered entrance for text. Each word (or character)\n * fades in, rises and un-blurs with a per-index delay when the root\n * enters the viewport.\n */\nconst props = withDefaults(\n  defineProps<{\n    text: string\n    as?: string\n    mode?: 'words' | 'chars'\n    /** ms between segment starts */\n    stagger?: number\n    /** ms per segment transition */\n    duration?: number\n    /** ms before the first segment starts */\n    delay?: number\n    blur?: boolean\n    /** reveal only on first intersection; false re-hides when scrolled away */\n    once?: boolean\n    class?: HTMLAttributes['class']\n  }>(),\n  {\n    as: 'span',\n    mode: 'words',\n    stagger: 40,\n    duration: 600,\n    delay: 0,\n    blur: true,\n    once: true,\n  },\n)\n\ninterface Segment {\n  text: string\n  space: boolean\n}\n\nconst segments = computed<Segment[]>(() => {\n  const source = props.text.replace(/\\s+/g, ' ').trim()\n  if (props.mode === 'chars') {\n    return Array.from(source).map((ch) => ({ text: ch, space: ch === ' ' }))\n  }\n  const words = source.split(' ')\n  // Interleave plain spaces so word spacing stays natural.\n  return words.flatMap((word, i) =>\n    i < words.length - 1\n      ? [\n          { text: word, space: false },\n          { text: '', space: true },\n        ]\n      : [{ text: word, space: false }],\n  )\n})\n\nconst revealed = ref(false)\nconst rootRef = ref<HTMLElement | null>(null)\nlet observer: IntersectionObserver | null = null\n\nfunction cleanup() {\n  observer?.disconnect()\n  observer = null\n}\n\nonMounted(() => {\n  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n  if (reduce || typeof IntersectionObserver === 'undefined') {\n    revealed.value = true\n    return\n  }\n  observer = new IntersectionObserver(\n    (entries) => {\n      for (const entry of entries) {\n        if (entry.isIntersecting) {\n          revealed.value = true\n          if (props.once) cleanup()\n        } else if (!props.once) {\n          revealed.value = false\n        }\n      }\n    },\n    { threshold: 0.2 },\n  )\n  if (rootRef.value) observer.observe(rootRef.value)\n})\n\nonBeforeUnmount(cleanup)\n\nfunction segmentStyle(index: number) {\n  return {\n    transitionDelay: `${props.delay + index * props.stagger}ms`,\n    transitionDuration: `${props.duration}ms`,\n  }\n}\n</script>\n\n<template>\n  <component\n    :is=\"as\"\n    ref=\"rootRef\"\n    data-uipkge\n    data-slot=\"text-reveal\"\n    :aria-label=\"text\"\n    :class=\"cn('inline-block', revealed && 'is-revealed', props.class)\"\n  >\n    <template v-for=\"(seg, i) in segments\" :key=\"`${seg.text}-${i}`\">\n      <span v-if=\"seg.space\" aria-hidden=\"true\">&nbsp;</span>\n      <span\n        v-else\n        aria-hidden=\"true\"\n        data-slot=\"text-reveal-segment\"\n        class=\"text-reveal-seg inline-block will-change-transform\"\n        :class=\"{ 'text-reveal-blur': blur }\"\n        :style=\"segmentStyle(i)\"\n        >{{ seg.text }}</span\n      >\n    </template>\n  </component>\n</template>\n\n<style>\n[data-slot='text-reveal'] .text-reveal-seg {\n  opacity: 0;\n  transform: translateY(0.5em);\n  transition-property: opacity, transform, filter;\n  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n[data-slot='text-reveal'].is-revealed .text-reveal-seg {\n  opacity: 1;\n  transform: translateY(0);\n}\n\n[data-slot='text-reveal'] .text-reveal-blur {\n  filter: blur(8px);\n}\n\n[data-slot='text-reveal'].is-revealed .text-reveal-blur {\n  filter: blur(0);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  [data-slot='text-reveal'] .text-reveal-seg {\n    opacity: 1 !important;\n    transform: none !important;\n    filter: none !important;\n    transition: none !important;\n  }\n}\n</style>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/text-reveal/TextReveal.vue"
    },
    {
      "path": "packages/registry-vue/components/text-reveal/index.ts",
      "content": "export { default as TextReveal } from './TextReveal.vue'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/text-reveal/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Scroll-triggered staggered entrance for text — each word or character fades in, rises and un-blurs with a per-index delay. IntersectionObserver based, `once` toggle for replay-on-scroll, honors prefers-reduced-motion.",
  "categories": [
    "display",
    "motion"
  ]
}