{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "typewriter",
  "title": "Typewriter",
  "type": "registry:ui",
  "description": "Types text character-by-character with a blinking caret, optionally cycling through multiple phrases (type -> pause -> delete -> next). Configurable typing/deleting speeds, completion pause, start delay, looping, and caret visibility. Renders empty on the server for hydration-safe SSR, restarts when `phrases` changes, and shows phrases in full under `prefers-reduced-motion`.",
  "categories": [
    "display",
    "motion"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/typewriter/typewriter.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\nconst typewriterStyles = css`\n  :host {\n    display: inline-block;\n  }\n  @keyframes caret-blink {\n    0%,\n    100% {\n      opacity: 1;\n    }\n    50% {\n      opacity: 0;\n    }\n  }\n  .animate-caret-blink {\n    animation: caret-blink 1s infinite;\n  }\n`\n\n/**\n * <uip-typewriter> — Dynamic typewriter text animation with looping phrases and blinking caret.\n */\nexport class UipTypewriter extends LitElement {\n  static styles = [tailwind, typewriterStyles]\n\n  static properties = {\n    phrases: {\n      type: Array,\n      converter: {\n        fromAttribute: (v: string | null) => {\n          if (!v) return []\n          try {\n            const parsed = JSON.parse(v)\n            return Array.isArray(parsed) ? parsed : [parsed]\n          } catch {\n            return [v]\n          }\n        },\n      },\n    },\n    typingSpeed: { type: Number, attribute: 'typing-speed' },\n    deletingSpeed: { type: Number, attribute: 'deleting-speed' },\n    pause: { type: Number },\n    startDelay: { type: Number, attribute: 'start-delay' },\n    loop: { converter: trueByDefault },\n    showCaret: { attribute: 'show-caret', converter: trueByDefault },\n    text: { state: true },\n    reduced: { state: true },\n  }\n\n  phrases: string | string[] = []\n  typingSpeed = 45\n  deletingSpeed = 25\n  pause = 1600\n  startDelay = 0\n  loop = true\n  showCaret = true\n\n  private text = ''\n  private reduced = false\n  private timer?: ReturnType<typeof setTimeout>\n  private phraseIndex = 0\n  private charCount = 0\n  private isDeleting = false\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', 'typewriter')\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.clearTimer()\n  }\n\n  private clearTimer() {\n    if (this.timer !== undefined) {\n      clearTimeout(this.timer)\n      this.timer = undefined\n    }\n  }\n\n  private get phraseList(): string[] {\n    if (Array.isArray(this.phrases)) return this.phrases\n    if (typeof this.phrases === 'string' && this.phrases) return [this.phrases]\n    return []\n  }\n\n  protected firstUpdated() {\n    if (isServer) return\n    const isReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    this.reduced = isReduced\n    const list = this.phraseList\n\n    if (isReduced) {\n      this.text = list[0] ?? ''\n      return\n    }\n\n    if (this.startDelay > 0) {\n      this.timer = setTimeout(() => this.step(), this.startDelay)\n    } else {\n      this.step()\n    }\n  }\n\n  private step() {\n    const list = this.phraseList\n    if (list.length === 0) return\n\n    const current = list[this.phraseIndex] ?? ''\n\n    if (!this.isDeleting) {\n      this.charCount += 1\n      this.text = current.slice(0, this.charCount)\n\n      if (this.charCount < current.length) {\n        this.timer = setTimeout(() => this.step(), this.typingSpeed)\n      } else if (this.loop || this.phraseIndex < list.length - 1) {\n        this.timer = setTimeout(() => {\n          this.isDeleting = true\n          this.step()\n        }, this.pause)\n      }\n    } else {\n      this.charCount -= 1\n      this.text = current.slice(0, this.charCount)\n\n      if (this.charCount > 0) {\n        this.timer = setTimeout(() => this.step(), this.deletingSpeed)\n      } else {\n        this.isDeleting = false\n        this.phraseIndex = (this.phraseIndex + 1) % list.length\n        this.timer = setTimeout(() => this.step(), this.typingSpeed)\n      }\n    }\n  }\n\n  render() {\n    const srText = this.phraseList.join('. ')\n\n    return html`\n      <span part=\"base\" class=\"inline-block\">\n        <span class=\"sr-only\">${srText}</span>\n        <span aria-hidden=\"true\" class=\"whitespace-pre-wrap\">\n          <span>${this.text}</span>\n          ${this.showCaret\n            ? html`\n                <span\n                  part=\"caret\"\n                  class=${cn(\n                    'inline-block h-[1em] w-[0.5ch] bg-current align-baseline',\n                    !this.reduced && 'animate-caret-blink',\n                  )}\n                ></span>\n              `\n            : nothing}\n        </span>\n      </span>\n    `\n  }\n}\n\ncustomElements.get('uip-typewriter') || customElements.define('uip-typewriter', UipTypewriter)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-typewriter': UipTypewriter\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/typewriter/typewriter.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
