{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "text-reveal",
  "title": "Text Reveal",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/text-reveal/text-reveal.component.ts",
      "content": "import {\n  Component,\n  ElementRef,\n  Input,\n  OnDestroy,\n  OnInit,\n  ViewEncapsulation,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\n\nexport interface TextSegment {\n  text: string\n  space: boolean\n}\n\n// Copied verbatim from the React TextReveal (injected there into <head>). Unscoped\n// (ViewEncapsulation.None) so the [data-slot] selectors reach the rendered segments.\nconst STYLE_CONTENT = `\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[data-slot='text-reveal'].is-revealed .text-reveal-seg {\n  opacity: 1;\n  transform: translateY(0);\n}\n[data-slot='text-reveal'] .text-reveal-blur {\n  filter: blur(8px);\n}\n[data-slot='text-reveal'].is-revealed .text-reveal-blur {\n  filter: blur(0);\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`\n\n/** Same segmentation as React: whitespace collapsed, words separated by explicit space segments. */\nexport function buildSegments(text: string, mode: 'words' | 'chars'): TextSegment[] {\n  const source = text.replace(/\\s+/g, ' ').trim()\n  if (mode === 'chars') return Array.from(source).map((ch) => ({ text: ch, space: ch === ' ' }))\n  const words = source.split(' ')\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\n/**\n * Scroll-triggered staggered text entrance (React `TextReveal`). Splits `text` into word or\n * character segments that fade / rise (and un-blur) in sequence once the element is 20% in\n * view. React's `as` maps to the attribute form: `<h2 ui-text-reveal text=\"…\">`.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-text-reveal, [ui-text-reveal]',\n  standalone: true,\n  encapsulation: ViewEncapsulation.None,\n  styles: [STYLE_CONTENT],\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'text-reveal',\n    '[attr.aria-label]': 'text',\n    '[class]': 'hostClass',\n  },\n  template: `\n    @for (seg of segments; track $index; let i = $index) {\n      @if (seg.space) {\n        <span aria-hidden=\"true\">&nbsp;</span>\n      } @else {\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"text-reveal-segment\"\n          [class]=\"segmentClass\"\n          [style.transition-delay]=\"delay + i * stagger + 'ms'\"\n          [style.transition-duration]=\"duration + 'ms'\"\n          >{{ seg.text }}</span\n        >\n      }\n    }\n  `,\n})\nexport class UiTextRevealComponent implements OnInit, OnDestroy {\n  @Input({ required: true }) text = ''\n  @Input() mode: 'words' | 'chars' = 'words'\n  /** ms between segment starts */\n  @Input() stagger = 40\n  /** ms per segment transition */\n  @Input() duration = 600\n  /** ms before the first segment starts */\n  @Input() delay = 0\n  @Input({ transform: booleanAttribute }) blur = true\n  /** reveal only on first intersection; false re-hides when scrolled away */\n  @Input({ transform: booleanAttribute }) once = true\n  @Input('class') className?: string\n\n  readonly revealed = signal(false)\n  private readonly host: ElementRef<HTMLElement> | null = null\n  private observer: IntersectionObserver | null = null\n\n  constructor() {\n    try {\n      this.host = inject<ElementRef<HTMLElement>>(ElementRef, { optional: true })\n    } catch {}\n  }\n\n  get segments(): TextSegment[] {\n    return buildSegments(this.text, this.mode)\n  }\n\n  get hostClass(): string {\n    return cn('inline-block', this.revealed() && 'is-revealed', this.className)\n  }\n\n  get segmentClass(): string {\n    return `text-reveal-seg inline-block will-change-transform ${this.blur ? 'text-reveal-blur' : ''}`.trim()\n  }\n\n  ngOnInit(): void {\n    if (typeof window === 'undefined') return\n    const reduce = !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches\n    if (reduce || typeof IntersectionObserver === 'undefined') {\n      this.revealed.set(true)\n      return\n    }\n    this.observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            this.revealed.set(true)\n            if (this.once) this.disconnect()\n          } else if (!this.once) {\n            this.revealed.set(false)\n          }\n        }\n      },\n      { threshold: 0.2 },\n    )\n    if (this.host) this.observer.observe(this.host.nativeElement)\n  }\n\n  ngOnDestroy(): void {\n    this.disconnect()\n  }\n\n  private disconnect(): void {\n    this.observer?.disconnect()\n    this.observer = null\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/text-reveal/text-reveal.component.ts"
    },
    {
      "path": "packages/registry-angular/components/text-reveal/index.ts",
      "content": "export { UiTextRevealComponent, type TextSegment } from './text-reveal.component'\n",
      "type": "registry:ui",
      "target": "~/src/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"
  ]
}