{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "text-reveal",
  "title": "Text Reveal",
  "type": "registry:ui",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/text-reveal/text-reveal.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\ninterface Segment {\n  text: string\n  space: boolean\n}\n\nfunction buildSegments(text: string, mode: 'words' | 'chars'): Segment[] {\n  const source = text.replace(/\\s+/g, ' ').trim()\n  if (mode === 'chars') {\n    return Array.from(source).map((ch) => ({ text: ch, space: ch === ' ' }))\n  }\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\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\nconst textRevealStyles = css`\n  :host {\n    display: inline-block;\n  }\n  .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  :host([data-revealed]) .text-reveal-seg,\n  .is-revealed .text-reveal-seg {\n    opacity: 1;\n    transform: translateY(0);\n  }\n  .text-reveal-blur {\n    filter: blur(8px);\n  }\n  :host([data-revealed]) .text-reveal-blur,\n  .is-revealed .text-reveal-blur {\n    filter: blur(0);\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .text-reveal-seg {\n      opacity: 1 !important;\n      transform: none !important;\n      filter: none !important;\n      transition: none !important;\n    }\n  }\n`\n\n/**\n * <uip-text-reveal> — Word-by-word or character-by-character reveal on scroll.\n */\nexport class UipTextReveal extends LitElement {\n  static styles = [tailwind, textRevealStyles]\n\n  static properties = {\n    text: { type: String },\n    mode: { type: String },\n    stagger: { type: Number },\n    duration: { type: Number },\n    delay: { type: Number },\n    blurred: { attribute: 'blur', converter: trueByDefault },\n    once: { converter: trueByDefault },\n    revealed: { state: true },\n  }\n\n  text = ''\n  mode: 'words' | 'chars' = 'words'\n  stagger = 40\n  duration = 600\n  delay = 0\n  blurred = true\n  once = true\n\n  private revealed = false\n  private observer?: IntersectionObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'text-reveal')\n    this.setAttribute('aria-label', this.text)\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.observer?.disconnect()\n  }\n\n  protected firstUpdated() {\n    if (isServer) return\n    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    if (reduce || typeof IntersectionObserver === 'undefined') {\n      this.revealed = true\n      this.setAttribute('data-revealed', '')\n      return\n    }\n\n    this.observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            this.revealed = true\n            this.setAttribute('data-revealed', '')\n            if (this.once) this.observer?.disconnect()\n          } else if (!this.once) {\n            this.revealed = false\n            this.removeAttribute('data-revealed')\n          }\n        }\n      },\n      { threshold: 0.2 },\n    )\n    this.observer.observe(this)\n  }\n\n  render() {\n    const segments = buildSegments(this.text, this.mode)\n\n    return html`\n      <span\n        part=\"base\"\n        class=${cn('inline-block', this.revealed && 'is-revealed')}\n        aria-hidden=\"true\"\n      >\n        ${segments.map((seg, i) =>\n          seg.space\n            ? html`<span>&nbsp;</span>`\n            : html`\n                <span\n                  data-slot=\"text-reveal-segment\"\n                  class=${cn(\n                    'text-reveal-seg inline-block will-change-transform',\n                    this.blurred && 'text-reveal-blur',\n                  )}\n                  style=${styleMap({\n                    transitionDelay: `${this.delay + i * this.stagger}ms`,\n                    transitionDuration: `${this.duration}ms`,\n                  })}\n                >\n                  ${seg.text}\n                </span>\n              `,\n        )}\n      </span>\n    `\n  }\n}\n\ncustomElements.get('uip-text-reveal') || customElements.define('uip-text-reveal', UipTextReveal)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-text-reveal': UipTextReveal\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/text-reveal/text-reveal.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
