{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "curtain-navbar",
  "name": "Curtain Navbar",
  "title": "Curtain Navbar",
  "type": "registry:ui",
  "description": "A floating glass header opens into a full-screen color curtain with oversized condensed links and synchronized hover details.",
  "summary": "Inspired by the TUX Creative House navigation, this Dev Club navbar reveals a rounded curtain from below, then staggers oversized centered links into view. Pointer hover and keyboard focus animate supporting category and description text. A reversible GSAP timeline keeps opening and closing synchronized, while Radix Dialog manages focus and Escape. Customize colors, links, notes and motion timing. Header links scramble through ASCII glyphs, and cursor height smoothly controls the oversized menu scroll on fine pointers. Wheel, touch and keyboard navigation remain available.",
  "category": "navbar",
  "tags": [
    "navbar",
    "fullscreen",
    "curtain",
    "editorial",
    "hover",
    "responsive",
    "gsap"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@radix-ui/react-dialog",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "ascii-hover-button"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-04",
  "updatedDate": "2026-10-04",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Floating translucent header with a rotating four-square menu mark",
    "Rounded bottom-to-top curtain reveal with staggered text masks",
    "Oversized centered menu links with supporting hover and focus details",
    "Reversible timeline and configurable closing speed",
    "Full-screen mobile layout with scrollable navigation",
    "Radix focus trap, Escape to close and reduced-motion support",
    "ASCII glitch on header links reuses the existing AsciiGlitchText component",
    "Pointer-controlled smooth menu scrolling with native wheel and touch input"
  ],
  "anatomy": [
    "Floating brand header and quick navigation",
    "Radix modal and upward curtain",
    "Masked oversized link entrances",
    "Side category and description details",
    "Community links and persistent footer"
  ],
  "physics": {
    "engine": "GSAP",
    "description": "One reversible scoped timeline sequences the curtain transform, masked link entrances and supporting notes. Delegated pointer and focus handlers animate link opacity and side details with overwrite enabled. Match-media contexts respect reduced motion and clean up animations and listeners. A reusable quickTo tween maps cursor height to scrollTop; wheel and touch input pause it. Focus brings obscured links into view. ASCII text reuses the existing GSAP primitive with fixed-width glyph slots.",
    "parameters": [
      {
        "label": "Curtain",
        "value": "0.85s · power4.inOut"
      },
      {
        "label": "Link stagger",
        "value": "0.045s"
      },
      {
        "label": "Hover",
        "value": "0.35s · power3.out"
      },
      {
        "label": "Closing multiplier",
        "value": "1.2×"
      }
    ]
  },
  "accessibility": {
    "role": "dialog / navigation",
    "keyboard": [
      {
        "key": "Enter / Space",
        "description": "Activate the menu trigger or close control."
      },
      {
        "key": "Tab / Shift+Tab",
        "description": "Navigate focusable controls inside the modal."
      },
      {
        "key": "Escape",
        "description": "Close the menu and restore focus to its trigger."
      }
    ],
    "aria": "The modal has a title and description. Decorative hover details are hidden from assistive technology; main destinations remain semantic links. Radix isolates the background and traps focus until the exit completes.",
    "reducedMotion": "The curtain, masked labels, menu mark and hover details update immediately when prefers-reduced-motion is enabled. Pointer scrolling is disabled in reduced-motion mode; wheel, touch and keyboard scrolling remain available."
  },
  "guidelines": {
    "recommended": [
      "Use short main labels for the oversized condensed typography.",
      "Provide meaningful categories and descriptions for desktop hover.",
      "Use contained mode inside a positioned full-height preview."
    ],
    "bestPractices": [
      "Preserve visible contrast when changing colors.",
      "Keep destinations as real links.",
      "Limit the main menu to a handful of destinations; extra items remain scrollable."
    ]
  },
  "props": [
    {
      "name": "brand",
      "type": "string",
      "defaultValue": "\"devclub\"",
      "description": "Brand label in the floating header."
    },
    {
      "name": "brandHref",
      "type": "string",
      "defaultValue": "\"/\"",
      "description": "Brand destination."
    },
    {
      "name": "logo",
      "type": "ReactNode",
      "defaultValue": "undefined",
      "description": "Optional logo content."
    },
    {
      "name": "items",
      "type": "readonly CurtainNavbarItem[]",
      "defaultValue": "CURTAIN_NAVBAR_ITEMS",
      "description": "Menu labels, destinations, categories, details and hover descriptions."
    },
    {
      "name": "quickLinks",
      "type": "readonly CurtainNavbarLink[]",
      "defaultValue": "Dev Club quick links",
      "description": "Desktop header links."
    },
    {
      "name": "footerLinks",
      "type": "readonly CurtainNavbarLink[]",
      "defaultValue": "Dev Club footer links",
      "description": "Secondary links below the main menu."
    },
    {
      "name": "leftNote",
      "type": "string",
      "defaultValue": "Dev Club community note",
      "description": "Desktop top-left note. Newlines are preserved."
    },
    {
      "name": "rightNote",
      "type": "string",
      "defaultValue": "Dev Club brand note",
      "description": "Desktop top-right note. Newlines are preserved."
    },
    {
      "name": "footerText",
      "type": "string",
      "defaultValue": "Thoughtful interfaces. Motion with purpose.",
      "description": "Persistent lower-left footer copy."
    },
    {
      "name": "background",
      "type": "string",
      "defaultValue": "\"#67df32\"",
      "description": "Full-screen curtain color."
    },
    {
      "name": "foreground",
      "type": "string",
      "defaultValue": "\"#161616\"",
      "description": "Menu text color."
    },
    {
      "name": "headerBackground",
      "type": "string",
      "defaultValue": "\"#222222b3\"",
      "description": "Translucent floating header CSS color."
    },
    {
      "name": "headerForeground",
      "type": "string",
      "defaultValue": "\"#ffffff\"",
      "description": "Floating header text color."
    },
    {
      "name": "duration",
      "type": "number",
      "defaultValue": "0.85",
      "description": "Curtain duration in seconds, bounded to 0.3–1.6."
    },
    {
      "name": "stagger",
      "type": "number",
      "defaultValue": "0.045",
      "description": "Link entrance spacing in seconds, bounded to 0–0.12."
    },
    {
      "name": "hoverDuration",
      "type": "number",
      "defaultValue": "0.35",
      "description": "Supporting detail and label hover duration, bounded to 0.15–0.8 seconds."
    },
    {
      "name": "closeSpeed",
      "type": "number",
      "defaultValue": "1.2",
      "description": "Reverse playback multiplier, bounded to 0.7–2."
    },
    {
      "name": "contained",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Fill the positioned parent instead of the viewport."
    },
    {
      "name": "open",
      "type": "boolean",
      "defaultValue": "undefined",
      "description": "Controlled menu state."
    },
    {
      "name": "defaultOpen",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Initial uncontrolled menu state."
    },
    {
      "name": "onOpenChange",
      "type": "(open: boolean) => void",
      "defaultValue": "undefined",
      "description": "Called when the user requests opening or closing."
    },
    {
      "name": "className",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Classes for the navbar host."
    },
    {
      "name": "glitchDuration",
      "type": "number",
      "defaultValue": "0.55",
      "description": "ASCII header scramble duration in seconds, bounded to 0.15–1.2."
    },
    {
      "name": "hoverScroll",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Enable pointer-height scrolling on fine hover pointers. Disabled for reduced motion."
    },
    {
      "name": "scrollDuration",
      "type": "number",
      "defaultValue": "0.8",
      "description": "Pointer scroll easing duration in seconds, bounded to 0.2–1.5."
    }
  ],
  "usage": "import { CurtainNavbar } from \"@/components/ui/curtain-navbar\";\n\nexport function CurtainNavbarDemo() {\n  return <CurtainNavbar brand=\"devclub\" brandHref=\"/\" />;\n}",
  "files": [
    {
      "name": "curtain-navbar.tsx",
      "path": "src/registry/ui/curtain-navbar.tsx",
      "type": "registry:ui",
      "target": "@ui/curtain-navbar.tsx",
      "code": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  AsciiGlitchText,\n  type AsciiGlitchTextHandle,\n} from \"./ascii-hover-button\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface CurtainNavbarItem {\n  label: string;\n  href: string;\n  category?: string;\n  detail?: string;\n  description?: string;\n}\n\nexport interface CurtainNavbarLink {\n  label: string;\n  href: string;\n}\n\nexport interface CurtainNavbarProps {\n  brand?: string;\n  brandHref?: string;\n  logo?: ReactNode;\n  items?: readonly CurtainNavbarItem[];\n  quickLinks?: readonly CurtainNavbarLink[];\n  footerLinks?: readonly CurtainNavbarLink[];\n  leftNote?: string;\n  rightNote?: string;\n  footerText?: string;\n  background?: string;\n  foreground?: string;\n  headerBackground?: string;\n  headerForeground?: string;\n  duration?: number;\n  stagger?: number;\n  hoverDuration?: number;\n  closeSpeed?: number;\n  glitchDuration?: number;\n  hoverScroll?: boolean;\n  scrollDuration?: number;\n  contained?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nexport const CURTAIN_NAVBAR_ITEMS: readonly CurtainNavbarItem[] = [\n  {\n    label: \"Build\",\n    href: \"/components\",\n    category: \"Components\",\n    detail: \"UI\",\n    description: \"Find your next component.\",\n  },\n  {\n    label: \"Learn\",\n    href: \"/docs/introduction\",\n    category: \"Documentation\",\n    detail: \"01\",\n    description: \"Make motion part of your craft.\",\n  },\n  {\n    label: \"Club\",\n    href: \"https://github.com/devclub-nstru/component_library\",\n    category: \"Community\",\n    detail: \"Open\",\n    description: \"Built together. Shared with everyone.\",\n  },\n  {\n    label: \"Hello\",\n    href: \"https://github.com/devclub-nstru/component_library/issues\",\n    category: \"Connect\",\n    detail: \"↗\",\n    description: \"Bring an idea. Start a conversation.\",\n  },\n  {\n    label: \"Source\",\n    href: \"https://github.com/devclub-nstru/component_library\",\n    category: \"Open source\",\n    detail: \"MIT\",\n    description: \"Explore the code behind the motion.\",\n  },\n];\n\nconst DEFAULT_QUICK_LINKS: readonly CurtainNavbarLink[] = [\n  { label: \"Build\", href: \"/components\" },\n  { label: \"Learn\", href: \"/docs/introduction\" },\n  {\n    label: \"Hello\",\n    href: \"https://github.com/devclub-nstru/component_library/issues\",\n  },\n];\n\nconst DEFAULT_FOOTER_LINKS: readonly CurtainNavbarLink[] = [\n  {\n    label: \"GitHub\",\n    href: \"https://github.com/devclub-nstru/component_library\",\n  },\n  { label: \"Installation\", href: \"/docs/installation\" },\n  { label: \"Dev Club\", href: \"https://devclub.co\" },\n];\n\nconst clamp = (value: number, min: number, max: number, fallback: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nfunction MenuMark({ open }: { open: boolean }) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const animateRef = useRef<((next: boolean) => void) | null>(null);\n  const openRef = useRef(open);\n  useEffect(() => {\n    openRef.current = open;\n  }, [open]);\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      media.add(\n        { reduced: \"(prefers-reduced-motion: reduce)\", all: \"all\" },\n        (context) => {\n          const animate = context.add(\"animate\", (next: boolean) =>\n            gsap.to(rootRef.current, {\n              rotation: next ? 135 : 0,\n              duration: context.conditions?.reduced ? 0 : 0.45,\n              ease: \"power3.inOut\",\n              overwrite: \"auto\",\n            }),\n          );\n          animateRef.current = (next) => animate(next);\n          animate(openRef.current);\n          return () => {\n            animateRef.current = null;\n          };\n        },\n      );\n      return () => media.revert();\n    },\n    { scope: rootRef },\n  );\n  useGSAP(\n    () => {\n      animateRef.current?.(open);\n    },\n    { scope: rootRef, dependencies: [open] },\n  );\n  return (\n    <span\n      ref={rootRef}\n      aria-hidden=\"true\"\n      className=\"grid size-3.5 grid-cols-2 gap-0.5\"\n    >\n      {[0, 1, 2, 3].map((index) => (\n        <span key={index} className=\"rounded-[1px] bg-current\" />\n      ))}\n    </span>\n  );\n}\n\nfunction HeaderLink({\n  link,\n  onNavigate,\n  duration,\n}: {\n  link: CurtainNavbarLink;\n  onNavigate?: () => void;\n  duration: number;\n}) {\n  const glitchRef = useRef<AsciiGlitchTextHandle>(null);\n  return (\n    <a\n      href={link.href}\n      aria-label={link.label}\n      onClick={onNavigate}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") glitchRef.current?.trigger();\n      }}\n      onPointerLeave={() => glitchRef.current?.reset()}\n      onFocus={() => glitchRef.current?.trigger()}\n      onBlur={() => glitchRef.current?.reset()}\n      className=\"rounded-sm opacity-70 hover:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current\"\n    >\n      <AsciiGlitchText\n        ref={glitchRef}\n        text={link.label.toUpperCase()}\n        duration={duration}\n        className=\"font-mono text-[10px] font-normal tracking-wide text-inherit transition-none [&>span]:w-[1ch] [&>span]:font-mono [&>span]:text-inherit [&>span]:transition-none\"\n      />\n    </a>\n  );\n}\n\nfunction CurtainHeader({\n  brand,\n  brandHref,\n  logo,\n  quickLinks,\n  button,\n  onNavigate,\n  background,\n  foreground,\n  glitchDuration,\n}: {\n  brand: string;\n  brandHref: string;\n  logo?: ReactNode;\n  quickLinks: readonly CurtainNavbarLink[];\n  button: ReactNode;\n  onNavigate?: () => void;\n  background: string;\n  foreground: string;\n  glitchDuration: number;\n}) {\n  return (\n    <header\n      className=\"@container/curtain-header pointer-events-auto absolute inset-x-4 top-4 z-20 mx-auto flex h-14 max-w-2xl items-center justify-between gap-3 rounded-xl px-4 font-mono text-[10px] uppercase tracking-wide backdrop-blur-xl\"\n      style={{ background, color: foreground }}\n    >\n      <a\n        href={brandHref}\n        onClick={onNavigate}\n        className=\"flex min-w-0 items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current\"\n      >\n        {logo && (\n          <span className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md\">\n            {logo}\n          </span>\n        )}\n        <span className=\"truncate\">{brand}</span>\n      </a>\n      <div className=\"flex shrink-0 items-center gap-2\">\n        <nav\n          aria-label=\"Quick navigation\"\n          className=\"hidden items-center gap-4 @[450px]/curtain-header:flex\"\n        >\n          {quickLinks.map((link) => (\n            <HeaderLink\n              key={`${link.label}-${link.href}`}\n              link={link}\n              onNavigate={onNavigate}\n              duration={glitchDuration}\n            />\n          ))}\n        </nav>\n        {button}\n      </div>\n    </header>\n  );\n}\n\ntype CurtainSettings = Required<\n  Omit<\n    CurtainNavbarProps,\n    \"logo\" | \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"className\"\n  >\n> &\n  Pick<CurtainNavbarProps, \"logo\">;\n\nfunction CurtainPanel({\n  open,\n  onClose,\n  onExit,\n  settings,\n}: {\n  open: boolean;\n  onClose: () => void;\n  onExit: () => void;\n  settings: CurtainSettings;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const moveRef = useRef<((next: boolean) => void) | null>(null);\n  const openRef = useRef(open);\n  const enteredRef = useRef(false);\n  const {\n    brand,\n    brandHref,\n    logo,\n    items,\n    footerLinks,\n    leftNote,\n    rightNote,\n    footerText,\n    background,\n    foreground,\n    headerBackground,\n    headerForeground,\n    duration,\n    stagger,\n    hoverDuration,\n    closeSpeed,\n    glitchDuration,\n    hoverScroll,\n    scrollDuration,\n    contained,\n  } = settings;\n\n  useEffect(() => {\n    openRef.current = open;\n  }, [open]);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const sheet = sheetRef.current;\n      if (!root || !sheet) return;\n      const titles = root.querySelectorAll<HTMLElement>(\"[data-curtain-title]\");\n      const notes = root.querySelectorAll<HTMLElement>(\"[data-curtain-note]\");\n      const nav = root.querySelector<HTMLElement>(\"[data-curtain-nav]\");\n      const scroller = root.querySelector<HTMLElement>(\"[data-curtain-scroll]\");\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          pointer: \"(hover: hover) and (pointer: fine)\",\n          all: \"all\",\n        },\n        (context) => {\n          const reduced = Boolean(context.conditions?.reduced);\n          gsap.set(sheet, {\n            y: 0,\n            yPercent: 100,\n            borderTopLeftRadius: 32,\n            borderTopRightRadius: 32,\n          });\n          gsap.set(titles, { y: 0, yPercent: 115 });\n          gsap.set(notes, { opacity: 0, y: 10 });\n          const timeline = gsap\n            .timeline({\n              paused: true,\n              onComplete: () => {\n                enteredRef.current = true;\n              },\n              onReverseComplete: onExit,\n            })\n            .to(\n              sheet,\n              {\n                yPercent: 0,\n                borderTopLeftRadius: 0,\n                borderTopRightRadius: 0,\n                duration,\n                ease: \"power4.inOut\",\n              },\n              0,\n            )\n            .to(\n              titles,\n              {\n                y: 0,\n                yPercent: 0,\n                duration: duration * 0.75,\n                stagger,\n                ease: \"power4.out\",\n              },\n              duration * 0.3,\n            )\n            .to(\n              notes,\n              {\n                opacity: 1,\n                y: 0,\n                duration: duration * 0.4,\n                stagger: 0.02,\n                ease: \"power3.out\",\n              },\n              duration * 0.6,\n            );\n          let selected: HTMLElement | null = null;\n          let pointer: { x: number; y: number } | null = null;\n          const scrollTo =\n            scroller && !reduced\n              ? gsap.quickTo(scroller, \"scrollTop\", {\n                  duration: scrollDuration,\n                  ease: \"power3.out\",\n                })\n              : null;\n          const select = context.add(\"select\", (row: HTMLElement | null) => {\n            if (selected === row) return;\n            if (selected) {\n              gsap.to(selected.querySelector(\"[data-curtain-word]\"), {\n                opacity: 1,\n                x: 0,\n                duration: reduced ? 0 : hoverDuration,\n                ease: \"power3.out\",\n                overwrite: \"auto\",\n              });\n              gsap.to(selected.querySelectorAll(\"[data-curtain-detail]\"), {\n                autoAlpha: 0,\n                y: reduced ? 0 : -8,\n                duration: reduced ? 0 : hoverDuration * 0.7,\n                ease: \"power2.out\",\n                overwrite: \"auto\",\n              });\n            }\n            selected = row;\n            if (!row) return;\n            gsap.to(row.querySelector(\"[data-curtain-word]\"), {\n              opacity: 0.45,\n              x: reduced ? 0 : 5,\n              duration: reduced ? 0 : hoverDuration,\n              ease: \"power3.out\",\n              overwrite: \"auto\",\n            });\n            gsap.fromTo(\n              row.querySelectorAll(\"[data-curtain-detail]\"),\n              { autoAlpha: 0, y: reduced ? 0 : 8 },\n              {\n                autoAlpha: 1,\n                y: 0,\n                duration: reduced ? 0 : hoverDuration,\n                ease: \"power3.out\",\n                overwrite: \"auto\",\n              },\n            );\n          });\n          const point = (event: Event) => {\n            if (event instanceof PointerEvent && event.pointerType !== \"mouse\")\n              return;\n            const row =\n              event.target instanceof Element\n                ? event.target.closest<HTMLElement>(\"[data-curtain-row]\")\n                : null;\n            if (row && nav?.contains(row)) select(row);\n          };\n          const scroll = (event: PointerEvent) => {\n            if (event.pointerType === \"mouse\")\n              pointer = { x: event.clientX, y: event.clientY };\n            if (\n              !scroller ||\n              !scrollTo ||\n              !hoverScroll ||\n              !context.conditions?.pointer ||\n              event.pointerType !== \"mouse\" ||\n              !openRef.current ||\n              !enteredRef.current\n            )\n              return;\n            const bounds = scroller.getBoundingClientRect();\n            const progress = gsap.utils.clamp(\n              0,\n              1,\n              (event.clientY - bounds.top - 24) /\n                Math.max(1, bounds.height - 48),\n            );\n            scrollTo(\n              progress *\n                Math.max(0, scroller.scrollHeight - scroller.clientHeight),\n              scroller.scrollTop,\n            );\n          };\n          const stopScroll = () => scrollTo?.tween.pause();\n          const syncHover = () => {\n            if (!pointer || nav?.contains(document.activeElement)) return;\n            const target = document.elementFromPoint(pointer.x, pointer.y);\n            const row =\n              target?.closest<HTMLElement>(\"[data-curtain-row]\") ?? null;\n            select(row && nav?.contains(row) ? row : null);\n          };\n          const leaveScroll = () => {\n            pointer = null;\n            stopScroll();\n          };\n          const focusScroll = (event: FocusEvent) => {\n            if (!scroller || !(event.target instanceof HTMLElement)) return;\n            stopScroll();\n            const bounds = scroller.getBoundingClientRect();\n            const target = event.target.getBoundingClientRect();\n            const offset =\n              target.top < bounds.top + 12\n                ? target.top - bounds.top - 12\n                : target.bottom > bounds.bottom - 12\n                  ? target.bottom - bounds.bottom + 12\n                  : 0;\n            if (offset === 0) return;\n            const next = gsap.utils.clamp(\n              0,\n              Math.max(0, scroller.scrollHeight - scroller.clientHeight),\n              scroller.scrollTop + offset,\n            );\n            if (scrollTo) scrollTo(next, scroller.scrollTop);\n            else scroller.scrollTop = next;\n          };\n          const leave = () => {\n            if (!nav?.contains(document.activeElement)) select(null);\n          };\n          const blur = (event: FocusEvent) => {\n            if (\n              !(event.relatedTarget instanceof Node) ||\n              !nav?.contains(event.relatedTarget)\n            )\n              select(null);\n          };\n          nav?.addEventListener(\"pointerover\", point);\n          nav?.addEventListener(\"focusin\", point);\n          nav?.addEventListener(\"pointerleave\", leave);\n          nav?.addEventListener(\"focusout\", blur);\n          scroller?.addEventListener(\"pointermove\", scroll, { passive: true });\n          scroller?.addEventListener(\"pointerleave\", leaveScroll);\n          scroller?.addEventListener(\"scroll\", syncHover, { passive: true });\n          scroller?.addEventListener(\"wheel\", stopScroll, { passive: true });\n          scroller?.addEventListener(\"touchstart\", stopScroll, {\n            passive: true,\n          });\n          scroller?.addEventListener(\"focusin\", focusScroll);\n          const move = context.add(\"move\", (next: boolean) => {\n            if (!next) stopScroll();\n            if (reduced) {\n              timeline.progress(1).pause();\n              if (!next) onExit();\n            } else if (next) timeline.timeScale(1).play();\n            else if (timeline.progress() === 0) onExit();\n            else timeline.timeScale(closeSpeed).reverse();\n          });\n          moveRef.current = (next) => move(next);\n          if (enteredRef.current && openRef.current)\n            timeline.progress(1).pause();\n          else move(openRef.current);\n          return () => {\n            moveRef.current = null;\n            nav?.removeEventListener(\"pointerover\", point);\n            nav?.removeEventListener(\"focusin\", point);\n            nav?.removeEventListener(\"pointerleave\", leave);\n            nav?.removeEventListener(\"focusout\", blur);\n            scroller?.removeEventListener(\"pointermove\", scroll);\n            scroller?.removeEventListener(\"pointerleave\", leaveScroll);\n            scroller?.removeEventListener(\"scroll\", syncHover);\n            scroller?.removeEventListener(\"wheel\", stopScroll);\n            scroller?.removeEventListener(\"touchstart\", stopScroll);\n            scroller?.removeEventListener(\"focusin\", focusScroll);\n          };\n        },\n      );\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        duration,\n        stagger,\n        hoverDuration,\n        closeSpeed,\n        hoverScroll,\n        scrollDuration,\n        items,\n        onExit,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    () => {\n      moveRef.current?.(open);\n    },\n    { scope: rootRef, dependencies: [open] },\n  );\n\n  return (\n    <Dialog.Content\n      ref={rootRef}\n      data-slot=\"curtain-navbar-panel\"\n      className={cn(\n        \"@container/curtain pointer-events-auto inset-0 z-50 overflow-hidden outline-none\",\n        contained ? \"absolute\" : \"fixed\",\n      )}\n      style={{ color: foreground, containerType: \"size\" }}\n      onOpenAutoFocus={(event) => {\n        event.preventDefault();\n        closeRef.current?.focus({ preventScroll: true });\n      }}\n      onEscapeKeyDown={(event) => {\n        event.preventDefault();\n        onClose();\n      }}\n      onPointerDownOutside={(event) => event.preventDefault()}\n    >\n      <Dialog.Title className=\"sr-only\">{brand} navigation</Dialog.Title>\n      <Dialog.Description className=\"sr-only\">\n        Explore Dev Club components, documentation, and community resources.\n      </Dialog.Description>\n      <div\n        ref={sheetRef}\n        className=\"absolute inset-0 overflow-hidden\"\n        style={{ background }}\n      >\n        <div\n          data-curtain-note\n          className=\"pointer-events-none absolute left-7 top-5 hidden max-w-[16%] whitespace-pre-line font-mono text-[9px] uppercase leading-relaxed @[1100px]/curtain:block\"\n        >\n          {leftNote}\n        </div>\n        <div\n          data-curtain-note\n          className=\"pointer-events-none absolute right-7 top-5 hidden max-w-[16%] whitespace-pre-line text-right font-mono text-[9px] uppercase leading-relaxed @[1100px]/curtain:block\"\n        >\n          {rightNote}\n        </div>\n        <div\n          data-curtain-scroll\n          className=\"absolute inset-x-0 bottom-20 top-20 overflow-y-auto overscroll-contain px-4 py-3 scrollbar-none [&::-webkit-scrollbar]:hidden @[760px]/curtain:px-7\"\n        >\n          <nav\n            data-curtain-nav\n            aria-label={`${brand} main navigation`}\n            className=\"my-auto w-full shrink-0\"\n            style={\n              {\n                \"--curtain-row-height\": \"clamp(96px,20cqh,260px)\",\n              } as CSSProperties\n            }\n          >\n            {items.map((item, index) => (\n              <div\n                key={`${item.label}-${index}`}\n                data-curtain-row\n                className=\"relative flex items-center justify-center\"\n                style={{ height: \"var(--curtain-row-height)\" }}\n              >\n                <div\n                  data-curtain-detail\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none invisible absolute inset-y-0 left-0 hidden w-[22%] items-center justify-between gap-3 font-mono text-[9px] uppercase opacity-0 @[760px]/curtain:flex\"\n                >\n                  <span className=\"max-w-[75%] wrap-break-words\">\n                    {item.category}\n                  </span>\n                  <span>{item.detail}</span>\n                </div>\n                <a\n                  href={item.href}\n                  onClick={onClose}\n                  className=\"flex h-full w-full items-center justify-center overflow-hidden rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current @[760px]/curtain:w-[54%]\"\n                  aria-label={item.label}\n                >\n                  <span\n                    className=\"block shrink-0 pb-1\"\n                    style={{\n                      clipPath: \"inset(0 -100% 0 -100%)\",\n                      fontFamily: \"Arial, Helvetica, sans-serif\",\n                      fontSize: `clamp(60px,min(calc(var(--curtain-row-height) * 1.05),${Math.min(34, 235 / Math.max(item.label.length, 1))}cqw),260px)`,\n                      fontWeight: 400,\n                      lineHeight: 0.9,\n                      letterSpacing: \"-0.075em\",\n                    }}\n                  >\n                    <span data-curtain-title className=\"block\">\n                      <span\n                        data-curtain-word\n                        className=\"block origin-center scale-x-[0.62] whitespace-nowrap uppercase\"\n                      >\n                        {item.label}\n                      </span>\n                    </span>\n                  </span>\n                </a>\n                <div\n                  data-curtain-detail\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none invisible absolute inset-y-0 right-0 hidden w-[22%] items-center justify-end text-right font-mono text-[9px] uppercase leading-relaxed opacity-0 @[760px]/curtain:flex\"\n                >\n                  {item.description}\n                </div>\n              </div>\n            ))}\n            <div\n              data-curtain-note\n              className=\"mt-6 flex flex-wrap justify-center gap-x-5 gap-y-3 font-mono text-[9px] uppercase\"\n            >\n              {footerLinks.map((link) => (\n                <a\n                  key={`${link.label}-${link.href}`}\n                  href={link.href}\n                  onClick={onClose}\n                  className=\"rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current\"\n                >\n                  {link.label}\n                </a>\n              ))}\n            </div>\n          </nav>\n        </div>\n        <div\n          data-curtain-note\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 flex min-h-16 items-end justify-between gap-4 px-5 pb-5 pt-5 font-mono text-[9px] uppercase leading-relaxed @[760px]/curtain:px-7\"\n          style={{\n            background: `linear-gradient(transparent, ${background} 45%)`,\n          }}\n        >\n          <p className=\"max-w-52 whitespace-pre-line\">{footerText}</p>\n          <span className=\"shrink-0\">Dev Club ©</span>\n        </div>\n      </div>\n      <CurtainHeader\n        brand={brand}\n        brandHref={brandHref}\n        logo={logo}\n        quickLinks={[]}\n        background={headerBackground}\n        foreground={headerForeground}\n        glitchDuration={glitchDuration}\n        onNavigate={onClose}\n        button={\n          <button\n            ref={closeRef}\n            type=\"button\"\n            aria-label=\"Close menu\"\n            onClick={onClose}\n            className=\"flex min-h-10 min-w-10 cursor-pointer items-center justify-center rounded-md hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\"\n          >\n            <MenuMark open={open} />\n          </button>\n        }\n      />\n    </Dialog.Content>\n  );\n}\n\nexport function CurtainNavbar({\n  brand = \"devclub\",\n  brandHref = \"/\",\n  logo,\n  items = CURTAIN_NAVBAR_ITEMS,\n  quickLinks = DEFAULT_QUICK_LINKS,\n  footerLinks = DEFAULT_FOOTER_LINKS,\n  leftNote = \"A community of builders.\\nOpen source. Open minds.\",\n  rightNote = \"Made by Dev Club.\\nBuilt for the web.\",\n  footerText = \"Thoughtful interfaces.\\nMotion with purpose.\",\n  background = \"#67df32\",\n  foreground = \"#161616\",\n  headerBackground = \"#222222b3\",\n  headerForeground = \"#ffffff\",\n  duration = 0.85,\n  stagger = 0.045,\n  hoverDuration = 0.35,\n  closeSpeed = 1.2,\n  glitchDuration = 0.55,\n  hoverScroll = true,\n  scrollDuration = 0.8,\n  contained = false,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: CurtainNavbarProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? internalOpen;\n  const [present, setPresent] = useState(open);\n  const [host, setHost] = useState<HTMLDivElement | null>(null);\n  const finishExit = useCallback(() => setPresent(false), []);\n  if (open && !present) setPresent(true);\n  const changeOpen = (next: boolean) => {\n    if (controlledOpen === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n  const settings: CurtainSettings = {\n    brand,\n    brandHref,\n    logo,\n    items,\n    quickLinks,\n    footerLinks,\n    leftNote,\n    rightNote,\n    footerText,\n    background,\n    foreground,\n    headerBackground,\n    headerForeground,\n    duration: clamp(duration, 0.3, 1.6, 0.85),\n    stagger: clamp(stagger, 0, 0.12, 0.045),\n    hoverDuration: clamp(hoverDuration, 0.15, 0.8, 0.35),\n    closeSpeed: clamp(closeSpeed, 0.7, 2, 1.2),\n    glitchDuration: clamp(glitchDuration, 0.15, 1.2, 0.55),\n    hoverScroll,\n    scrollDuration: clamp(scrollDuration, 0.2, 1.5, 0.8),\n    contained,\n  };\n  return (\n    <div\n      ref={setHost}\n      data-slot=\"curtain-navbar\"\n      className={cn(\n        \"pointer-events-none inset-0 z-30\",\n        contained ? \"absolute\" : \"fixed\",\n        className,\n      )}\n    >\n      <Dialog.Root open={present} onOpenChange={changeOpen}>\n        <div aria-hidden={present} inert={present}>\n          <CurtainHeader\n            brand={brand}\n            brandHref={brandHref}\n            logo={logo}\n            quickLinks={quickLinks}\n            background={headerBackground}\n            foreground={headerForeground}\n            glitchDuration={settings.glitchDuration}\n            button={\n              <Dialog.Trigger asChild>\n                <button\n                  type=\"button\"\n                  aria-label=\"Open menu\"\n                  className=\"flex min-h-10 min-w-10 cursor-pointer items-center justify-center rounded-md hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\"\n                >\n                  <MenuMark open={false} />\n                </button>\n              </Dialog.Trigger>\n            }\n          />\n        </div>\n        <Dialog.Portal container={contained ? host : undefined}>\n          <CurtainPanel\n            open={open}\n            onClose={() => changeOpen(false)}\n            onExit={finishExit}\n            settings={settings}\n          />\n        </Dialog.Portal>\n      </Dialog.Root>\n    </div>\n  );\n}\n\nexport default CurtainNavbar;\n",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  AsciiGlitchText,\n  type AsciiGlitchTextHandle,\n} from \"./ascii-hover-button\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface CurtainNavbarItem {\n  label: string;\n  href: string;\n  category?: string;\n  detail?: string;\n  description?: string;\n}\n\nexport interface CurtainNavbarLink {\n  label: string;\n  href: string;\n}\n\nexport interface CurtainNavbarProps {\n  brand?: string;\n  brandHref?: string;\n  logo?: ReactNode;\n  items?: readonly CurtainNavbarItem[];\n  quickLinks?: readonly CurtainNavbarLink[];\n  footerLinks?: readonly CurtainNavbarLink[];\n  leftNote?: string;\n  rightNote?: string;\n  footerText?: string;\n  background?: string;\n  foreground?: string;\n  headerBackground?: string;\n  headerForeground?: string;\n  duration?: number;\n  stagger?: number;\n  hoverDuration?: number;\n  closeSpeed?: number;\n  glitchDuration?: number;\n  hoverScroll?: boolean;\n  scrollDuration?: number;\n  contained?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nexport const CURTAIN_NAVBAR_ITEMS: readonly CurtainNavbarItem[] = [\n  {\n    label: \"Build\",\n    href: \"/components\",\n    category: \"Components\",\n    detail: \"UI\",\n    description: \"Find your next component.\",\n  },\n  {\n    label: \"Learn\",\n    href: \"/docs/introduction\",\n    category: \"Documentation\",\n    detail: \"01\",\n    description: \"Make motion part of your craft.\",\n  },\n  {\n    label: \"Club\",\n    href: \"https://github.com/devclub-nstru/component_library\",\n    category: \"Community\",\n    detail: \"Open\",\n    description: \"Built together. Shared with everyone.\",\n  },\n  {\n    label: \"Hello\",\n    href: \"https://github.com/devclub-nstru/component_library/issues\",\n    category: \"Connect\",\n    detail: \"↗\",\n    description: \"Bring an idea. Start a conversation.\",\n  },\n  {\n    label: \"Source\",\n    href: \"https://github.com/devclub-nstru/component_library\",\n    category: \"Open source\",\n    detail: \"MIT\",\n    description: \"Explore the code behind the motion.\",\n  },\n];\n\nconst DEFAULT_QUICK_LINKS: readonly CurtainNavbarLink[] = [\n  { label: \"Build\", href: \"/components\" },\n  { label: \"Learn\", href: \"/docs/introduction\" },\n  {\n    label: \"Hello\",\n    href: \"https://github.com/devclub-nstru/component_library/issues\",\n  },\n];\n\nconst DEFAULT_FOOTER_LINKS: readonly CurtainNavbarLink[] = [\n  {\n    label: \"GitHub\",\n    href: \"https://github.com/devclub-nstru/component_library\",\n  },\n  { label: \"Installation\", href: \"/docs/installation\" },\n  { label: \"Dev Club\", href: \"https://devclub.co\" },\n];\n\nconst clamp = (value: number, min: number, max: number, fallback: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nfunction MenuMark({ open }: { open: boolean }) {\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const animateRef = useRef<((next: boolean) => void) | null>(null);\n  const openRef = useRef(open);\n  useEffect(() => {\n    openRef.current = open;\n  }, [open]);\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      media.add(\n        { reduced: \"(prefers-reduced-motion: reduce)\", all: \"all\" },\n        (context) => {\n          const animate = context.add(\"animate\", (next: boolean) =>\n            gsap.to(rootRef.current, {\n              rotation: next ? 135 : 0,\n              duration: context.conditions?.reduced ? 0 : 0.45,\n              ease: \"power3.inOut\",\n              overwrite: \"auto\",\n            }),\n          );\n          animateRef.current = (next) => animate(next);\n          animate(openRef.current);\n          return () => {\n            animateRef.current = null;\n          };\n        },\n      );\n      return () => media.revert();\n    },\n    { scope: rootRef },\n  );\n  useGSAP(\n    () => {\n      animateRef.current?.(open);\n    },\n    { scope: rootRef, dependencies: [open] },\n  );\n  return (\n    <span\n      ref={rootRef}\n      aria-hidden=\"true\"\n      className=\"grid size-3.5 grid-cols-2 gap-0.5\"\n    >\n      {[0, 1, 2, 3].map((index) => (\n        <span key={index} className=\"rounded-[1px] bg-current\" />\n      ))}\n    </span>\n  );\n}\n\nfunction HeaderLink({\n  link,\n  onNavigate,\n  duration,\n}: {\n  link: CurtainNavbarLink;\n  onNavigate?: () => void;\n  duration: number;\n}) {\n  const glitchRef = useRef<AsciiGlitchTextHandle>(null);\n  return (\n    <a\n      href={link.href}\n      aria-label={link.label}\n      onClick={onNavigate}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") glitchRef.current?.trigger();\n      }}\n      onPointerLeave={() => glitchRef.current?.reset()}\n      onFocus={() => glitchRef.current?.trigger()}\n      onBlur={() => glitchRef.current?.reset()}\n      className=\"rounded-sm opacity-70 hover:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current\"\n    >\n      <AsciiGlitchText\n        ref={glitchRef}\n        text={link.label.toUpperCase()}\n        duration={duration}\n        className=\"font-mono text-[10px] font-normal tracking-wide text-inherit transition-none [&>span]:w-[1ch] [&>span]:font-mono [&>span]:text-inherit [&>span]:transition-none\"\n      />\n    </a>\n  );\n}\n\nfunction CurtainHeader({\n  brand,\n  brandHref,\n  logo,\n  quickLinks,\n  button,\n  onNavigate,\n  background,\n  foreground,\n  glitchDuration,\n}: {\n  brand: string;\n  brandHref: string;\n  logo?: ReactNode;\n  quickLinks: readonly CurtainNavbarLink[];\n  button: ReactNode;\n  onNavigate?: () => void;\n  background: string;\n  foreground: string;\n  glitchDuration: number;\n}) {\n  return (\n    <header\n      className=\"@container/curtain-header pointer-events-auto absolute inset-x-4 top-4 z-20 mx-auto flex h-14 max-w-2xl items-center justify-between gap-3 rounded-xl px-4 font-mono text-[10px] uppercase tracking-wide backdrop-blur-xl\"\n      style={{ background, color: foreground }}\n    >\n      <a\n        href={brandHref}\n        onClick={onNavigate}\n        className=\"flex min-w-0 items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current\"\n      >\n        {logo && (\n          <span className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md\">\n            {logo}\n          </span>\n        )}\n        <span className=\"truncate\">{brand}</span>\n      </a>\n      <div className=\"flex shrink-0 items-center gap-2\">\n        <nav\n          aria-label=\"Quick navigation\"\n          className=\"hidden items-center gap-4 @[450px]/curtain-header:flex\"\n        >\n          {quickLinks.map((link) => (\n            <HeaderLink\n              key={`${link.label}-${link.href}`}\n              link={link}\n              onNavigate={onNavigate}\n              duration={glitchDuration}\n            />\n          ))}\n        </nav>\n        {button}\n      </div>\n    </header>\n  );\n}\n\ntype CurtainSettings = Required<\n  Omit<\n    CurtainNavbarProps,\n    \"logo\" | \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"className\"\n  >\n> &\n  Pick<CurtainNavbarProps, \"logo\">;\n\nfunction CurtainPanel({\n  open,\n  onClose,\n  onExit,\n  settings,\n}: {\n  open: boolean;\n  onClose: () => void;\n  onExit: () => void;\n  settings: CurtainSettings;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const sheetRef = useRef<HTMLDivElement>(null);\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const moveRef = useRef<((next: boolean) => void) | null>(null);\n  const openRef = useRef(open);\n  const enteredRef = useRef(false);\n  const {\n    brand,\n    brandHref,\n    logo,\n    items,\n    footerLinks,\n    leftNote,\n    rightNote,\n    footerText,\n    background,\n    foreground,\n    headerBackground,\n    headerForeground,\n    duration,\n    stagger,\n    hoverDuration,\n    closeSpeed,\n    glitchDuration,\n    hoverScroll,\n    scrollDuration,\n    contained,\n  } = settings;\n\n  useEffect(() => {\n    openRef.current = open;\n  }, [open]);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const sheet = sheetRef.current;\n      if (!root || !sheet) return;\n      const titles = root.querySelectorAll<HTMLElement>(\"[data-curtain-title]\");\n      const notes = root.querySelectorAll<HTMLElement>(\"[data-curtain-note]\");\n      const nav = root.querySelector<HTMLElement>(\"[data-curtain-nav]\");\n      const scroller = root.querySelector<HTMLElement>(\"[data-curtain-scroll]\");\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          pointer: \"(hover: hover) and (pointer: fine)\",\n          all: \"all\",\n        },\n        (context) => {\n          const reduced = Boolean(context.conditions?.reduced);\n          gsap.set(sheet, {\n            y: 0,\n            yPercent: 100,\n            borderTopLeftRadius: 32,\n            borderTopRightRadius: 32,\n          });\n          gsap.set(titles, { y: 0, yPercent: 115 });\n          gsap.set(notes, { opacity: 0, y: 10 });\n          const timeline = gsap\n            .timeline({\n              paused: true,\n              onComplete: () => {\n                enteredRef.current = true;\n              },\n              onReverseComplete: onExit,\n            })\n            .to(\n              sheet,\n              {\n                yPercent: 0,\n                borderTopLeftRadius: 0,\n                borderTopRightRadius: 0,\n                duration,\n                ease: \"power4.inOut\",\n              },\n              0,\n            )\n            .to(\n              titles,\n              {\n                y: 0,\n                yPercent: 0,\n                duration: duration * 0.75,\n                stagger,\n                ease: \"power4.out\",\n              },\n              duration * 0.3,\n            )\n            .to(\n              notes,\n              {\n                opacity: 1,\n                y: 0,\n                duration: duration * 0.4,\n                stagger: 0.02,\n                ease: \"power3.out\",\n              },\n              duration * 0.6,\n            );\n          let selected: HTMLElement | null = null;\n          let pointer: { x: number; y: number } | null = null;\n          const scrollTo =\n            scroller && !reduced\n              ? gsap.quickTo(scroller, \"scrollTop\", {\n                  duration: scrollDuration,\n                  ease: \"power3.out\",\n                })\n              : null;\n          const select = context.add(\"select\", (row: HTMLElement | null) => {\n            if (selected === row) return;\n            if (selected) {\n              gsap.to(selected.querySelector(\"[data-curtain-word]\"), {\n                opacity: 1,\n                x: 0,\n                duration: reduced ? 0 : hoverDuration,\n                ease: \"power3.out\",\n                overwrite: \"auto\",\n              });\n              gsap.to(selected.querySelectorAll(\"[data-curtain-detail]\"), {\n                autoAlpha: 0,\n                y: reduced ? 0 : -8,\n                duration: reduced ? 0 : hoverDuration * 0.7,\n                ease: \"power2.out\",\n                overwrite: \"auto\",\n              });\n            }\n            selected = row;\n            if (!row) return;\n            gsap.to(row.querySelector(\"[data-curtain-word]\"), {\n              opacity: 0.45,\n              x: reduced ? 0 : 5,\n              duration: reduced ? 0 : hoverDuration,\n              ease: \"power3.out\",\n              overwrite: \"auto\",\n            });\n            gsap.fromTo(\n              row.querySelectorAll(\"[data-curtain-detail]\"),\n              { autoAlpha: 0, y: reduced ? 0 : 8 },\n              {\n                autoAlpha: 1,\n                y: 0,\n                duration: reduced ? 0 : hoverDuration,\n                ease: \"power3.out\",\n                overwrite: \"auto\",\n              },\n            );\n          });\n          const point = (event: Event) => {\n            if (event instanceof PointerEvent && event.pointerType !== \"mouse\")\n              return;\n            const row =\n              event.target instanceof Element\n                ? event.target.closest<HTMLElement>(\"[data-curtain-row]\")\n                : null;\n            if (row && nav?.contains(row)) select(row);\n          };\n          const scroll = (event: PointerEvent) => {\n            if (event.pointerType === \"mouse\")\n              pointer = { x: event.clientX, y: event.clientY };\n            if (\n              !scroller ||\n              !scrollTo ||\n              !hoverScroll ||\n              !context.conditions?.pointer ||\n              event.pointerType !== \"mouse\" ||\n              !openRef.current ||\n              !enteredRef.current\n            )\n              return;\n            const bounds = scroller.getBoundingClientRect();\n            const progress = gsap.utils.clamp(\n              0,\n              1,\n              (event.clientY - bounds.top - 24) /\n                Math.max(1, bounds.height - 48),\n            );\n            scrollTo(\n              progress *\n                Math.max(0, scroller.scrollHeight - scroller.clientHeight),\n              scroller.scrollTop,\n            );\n          };\n          const stopScroll = () => scrollTo?.tween.pause();\n          const syncHover = () => {\n            if (!pointer || nav?.contains(document.activeElement)) return;\n            const target = document.elementFromPoint(pointer.x, pointer.y);\n            const row =\n              target?.closest<HTMLElement>(\"[data-curtain-row]\") ?? null;\n            select(row && nav?.contains(row) ? row : null);\n          };\n          const leaveScroll = () => {\n            pointer = null;\n            stopScroll();\n          };\n          const focusScroll = (event: FocusEvent) => {\n            if (!scroller || !(event.target instanceof HTMLElement)) return;\n            stopScroll();\n            const bounds = scroller.getBoundingClientRect();\n            const target = event.target.getBoundingClientRect();\n            const offset =\n              target.top < bounds.top + 12\n                ? target.top - bounds.top - 12\n                : target.bottom > bounds.bottom - 12\n                  ? target.bottom - bounds.bottom + 12\n                  : 0;\n            if (offset === 0) return;\n            const next = gsap.utils.clamp(\n              0,\n              Math.max(0, scroller.scrollHeight - scroller.clientHeight),\n              scroller.scrollTop + offset,\n            );\n            if (scrollTo) scrollTo(next, scroller.scrollTop);\n            else scroller.scrollTop = next;\n          };\n          const leave = () => {\n            if (!nav?.contains(document.activeElement)) select(null);\n          };\n          const blur = (event: FocusEvent) => {\n            if (\n              !(event.relatedTarget instanceof Node) ||\n              !nav?.contains(event.relatedTarget)\n            )\n              select(null);\n          };\n          nav?.addEventListener(\"pointerover\", point);\n          nav?.addEventListener(\"focusin\", point);\n          nav?.addEventListener(\"pointerleave\", leave);\n          nav?.addEventListener(\"focusout\", blur);\n          scroller?.addEventListener(\"pointermove\", scroll, { passive: true });\n          scroller?.addEventListener(\"pointerleave\", leaveScroll);\n          scroller?.addEventListener(\"scroll\", syncHover, { passive: true });\n          scroller?.addEventListener(\"wheel\", stopScroll, { passive: true });\n          scroller?.addEventListener(\"touchstart\", stopScroll, {\n            passive: true,\n          });\n          scroller?.addEventListener(\"focusin\", focusScroll);\n          const move = context.add(\"move\", (next: boolean) => {\n            if (!next) stopScroll();\n            if (reduced) {\n              timeline.progress(1).pause();\n              if (!next) onExit();\n            } else if (next) timeline.timeScale(1).play();\n            else if (timeline.progress() === 0) onExit();\n            else timeline.timeScale(closeSpeed).reverse();\n          });\n          moveRef.current = (next) => move(next);\n          if (enteredRef.current && openRef.current)\n            timeline.progress(1).pause();\n          else move(openRef.current);\n          return () => {\n            moveRef.current = null;\n            nav?.removeEventListener(\"pointerover\", point);\n            nav?.removeEventListener(\"focusin\", point);\n            nav?.removeEventListener(\"pointerleave\", leave);\n            nav?.removeEventListener(\"focusout\", blur);\n            scroller?.removeEventListener(\"pointermove\", scroll);\n            scroller?.removeEventListener(\"pointerleave\", leaveScroll);\n            scroller?.removeEventListener(\"scroll\", syncHover);\n            scroller?.removeEventListener(\"wheel\", stopScroll);\n            scroller?.removeEventListener(\"touchstart\", stopScroll);\n            scroller?.removeEventListener(\"focusin\", focusScroll);\n          };\n        },\n      );\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        duration,\n        stagger,\n        hoverDuration,\n        closeSpeed,\n        hoverScroll,\n        scrollDuration,\n        items,\n        onExit,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  useGSAP(\n    () => {\n      moveRef.current?.(open);\n    },\n    { scope: rootRef, dependencies: [open] },\n  );\n\n  return (\n    <Dialog.Content\n      ref={rootRef}\n      data-slot=\"curtain-navbar-panel\"\n      className={cn(\n        \"@container/curtain pointer-events-auto inset-0 z-50 overflow-hidden outline-none\",\n        contained ? \"absolute\" : \"fixed\",\n      )}\n      style={{ color: foreground, containerType: \"size\" }}\n      onOpenAutoFocus={(event) => {\n        event.preventDefault();\n        closeRef.current?.focus({ preventScroll: true });\n      }}\n      onEscapeKeyDown={(event) => {\n        event.preventDefault();\n        onClose();\n      }}\n      onPointerDownOutside={(event) => event.preventDefault()}\n    >\n      <Dialog.Title className=\"sr-only\">{brand} navigation</Dialog.Title>\n      <Dialog.Description className=\"sr-only\">\n        Explore Dev Club components, documentation, and community resources.\n      </Dialog.Description>\n      <div\n        ref={sheetRef}\n        className=\"absolute inset-0 overflow-hidden\"\n        style={{ background }}\n      >\n        <div\n          data-curtain-note\n          className=\"pointer-events-none absolute left-7 top-5 hidden max-w-[16%] whitespace-pre-line font-mono text-[9px] uppercase leading-relaxed @[1100px]/curtain:block\"\n        >\n          {leftNote}\n        </div>\n        <div\n          data-curtain-note\n          className=\"pointer-events-none absolute right-7 top-5 hidden max-w-[16%] whitespace-pre-line text-right font-mono text-[9px] uppercase leading-relaxed @[1100px]/curtain:block\"\n        >\n          {rightNote}\n        </div>\n        <div\n          data-curtain-scroll\n          className=\"absolute inset-x-0 bottom-20 top-20 overflow-y-auto overscroll-contain px-4 py-3 scrollbar-none [&::-webkit-scrollbar]:hidden @[760px]/curtain:px-7\"\n        >\n          <nav\n            data-curtain-nav\n            aria-label={`${brand} main navigation`}\n            className=\"my-auto w-full shrink-0\"\n            style={\n              {\n                \"--curtain-row-height\": \"clamp(96px,20cqh,260px)\",\n              } as CSSProperties\n            }\n          >\n            {items.map((item, index) => (\n              <div\n                key={`${item.label}-${index}`}\n                data-curtain-row\n                className=\"relative flex items-center justify-center\"\n                style={{ height: \"var(--curtain-row-height)\" }}\n              >\n                <div\n                  data-curtain-detail\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none invisible absolute inset-y-0 left-0 hidden w-[22%] items-center justify-between gap-3 font-mono text-[9px] uppercase opacity-0 @[760px]/curtain:flex\"\n                >\n                  <span className=\"max-w-[75%] wrap-break-words\">\n                    {item.category}\n                  </span>\n                  <span>{item.detail}</span>\n                </div>\n                <a\n                  href={item.href}\n                  onClick={onClose}\n                  className=\"flex h-full w-full items-center justify-center overflow-hidden rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current @[760px]/curtain:w-[54%]\"\n                  aria-label={item.label}\n                >\n                  <span\n                    className=\"block shrink-0 pb-1\"\n                    style={{\n                      clipPath: \"inset(0 -100% 0 -100%)\",\n                      fontFamily: \"Arial, Helvetica, sans-serif\",\n                      fontSize: `clamp(60px,min(calc(var(--curtain-row-height) * 1.05),${Math.min(34, 235 / Math.max(item.label.length, 1))}cqw),260px)`,\n                      fontWeight: 400,\n                      lineHeight: 0.9,\n                      letterSpacing: \"-0.075em\",\n                    }}\n                  >\n                    <span data-curtain-title className=\"block\">\n                      <span\n                        data-curtain-word\n                        className=\"block origin-center scale-x-[0.62] whitespace-nowrap uppercase\"\n                      >\n                        {item.label}\n                      </span>\n                    </span>\n                  </span>\n                </a>\n                <div\n                  data-curtain-detail\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none invisible absolute inset-y-0 right-0 hidden w-[22%] items-center justify-end text-right font-mono text-[9px] uppercase leading-relaxed opacity-0 @[760px]/curtain:flex\"\n                >\n                  {item.description}\n                </div>\n              </div>\n            ))}\n            <div\n              data-curtain-note\n              className=\"mt-6 flex flex-wrap justify-center gap-x-5 gap-y-3 font-mono text-[9px] uppercase\"\n            >\n              {footerLinks.map((link) => (\n                <a\n                  key={`${link.label}-${link.href}`}\n                  href={link.href}\n                  onClick={onClose}\n                  className=\"rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current\"\n                >\n                  {link.label}\n                </a>\n              ))}\n            </div>\n          </nav>\n        </div>\n        <div\n          data-curtain-note\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 flex min-h-16 items-end justify-between gap-4 px-5 pb-5 pt-5 font-mono text-[9px] uppercase leading-relaxed @[760px]/curtain:px-7\"\n          style={{\n            background: `linear-gradient(transparent, ${background} 45%)`,\n          }}\n        >\n          <p className=\"max-w-52 whitespace-pre-line\">{footerText}</p>\n          <span className=\"shrink-0\">Dev Club ©</span>\n        </div>\n      </div>\n      <CurtainHeader\n        brand={brand}\n        brandHref={brandHref}\n        logo={logo}\n        quickLinks={[]}\n        background={headerBackground}\n        foreground={headerForeground}\n        glitchDuration={glitchDuration}\n        onNavigate={onClose}\n        button={\n          <button\n            ref={closeRef}\n            type=\"button\"\n            aria-label=\"Close menu\"\n            onClick={onClose}\n            className=\"flex min-h-10 min-w-10 cursor-pointer items-center justify-center rounded-md hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\"\n          >\n            <MenuMark open={open} />\n          </button>\n        }\n      />\n    </Dialog.Content>\n  );\n}\n\nexport function CurtainNavbar({\n  brand = \"devclub\",\n  brandHref = \"/\",\n  logo,\n  items = CURTAIN_NAVBAR_ITEMS,\n  quickLinks = DEFAULT_QUICK_LINKS,\n  footerLinks = DEFAULT_FOOTER_LINKS,\n  leftNote = \"A community of builders.\\nOpen source. Open minds.\",\n  rightNote = \"Made by Dev Club.\\nBuilt for the web.\",\n  footerText = \"Thoughtful interfaces.\\nMotion with purpose.\",\n  background = \"#67df32\",\n  foreground = \"#161616\",\n  headerBackground = \"#222222b3\",\n  headerForeground = \"#ffffff\",\n  duration = 0.85,\n  stagger = 0.045,\n  hoverDuration = 0.35,\n  closeSpeed = 1.2,\n  glitchDuration = 0.55,\n  hoverScroll = true,\n  scrollDuration = 0.8,\n  contained = false,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: CurtainNavbarProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? internalOpen;\n  const [present, setPresent] = useState(open);\n  const [host, setHost] = useState<HTMLDivElement | null>(null);\n  const finishExit = useCallback(() => setPresent(false), []);\n  if (open && !present) setPresent(true);\n  const changeOpen = (next: boolean) => {\n    if (controlledOpen === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n  const settings: CurtainSettings = {\n    brand,\n    brandHref,\n    logo,\n    items,\n    quickLinks,\n    footerLinks,\n    leftNote,\n    rightNote,\n    footerText,\n    background,\n    foreground,\n    headerBackground,\n    headerForeground,\n    duration: clamp(duration, 0.3, 1.6, 0.85),\n    stagger: clamp(stagger, 0, 0.12, 0.045),\n    hoverDuration: clamp(hoverDuration, 0.15, 0.8, 0.35),\n    closeSpeed: clamp(closeSpeed, 0.7, 2, 1.2),\n    glitchDuration: clamp(glitchDuration, 0.15, 1.2, 0.55),\n    hoverScroll,\n    scrollDuration: clamp(scrollDuration, 0.2, 1.5, 0.8),\n    contained,\n  };\n  return (\n    <div\n      ref={setHost}\n      data-slot=\"curtain-navbar\"\n      className={cn(\n        \"pointer-events-none inset-0 z-30\",\n        contained ? \"absolute\" : \"fixed\",\n        className,\n      )}\n    >\n      <Dialog.Root open={present} onOpenChange={changeOpen}>\n        <div aria-hidden={present} inert={present}>\n          <CurtainHeader\n            brand={brand}\n            brandHref={brandHref}\n            logo={logo}\n            quickLinks={quickLinks}\n            background={headerBackground}\n            foreground={headerForeground}\n            glitchDuration={settings.glitchDuration}\n            button={\n              <Dialog.Trigger asChild>\n                <button\n                  type=\"button\"\n                  aria-label=\"Open menu\"\n                  className=\"flex min-h-10 min-w-10 cursor-pointer items-center justify-center rounded-md hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\"\n                >\n                  <MenuMark open={false} />\n                </button>\n              </Dialog.Trigger>\n            }\n          />\n        </div>\n        <Dialog.Portal container={contained ? host : undefined}>\n          <CurtainPanel\n            open={open}\n            onClose={() => changeOpen(false)}\n            onExit={finishExit}\n            settings={settings}\n          />\n        </Dialog.Portal>\n      </Dialog.Root>\n    </div>\n  );\n}\n\nexport default CurtainNavbar;\n"
    },
    {
      "name": "ascii-hover-button.tsx",
      "path": "src/registry/ui/ascii-hover-button.tsx",
      "code": "\"use client\";\n\nimport React, {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type AsciiHoverColor =\n  | \"default\"\n  | \"emerald\"\n  | \"blue\"\n  | \"violet\"\n  | \"amber\"\n  | \"rose\"\n  | \"cyan\";\n\nexport interface AsciiColorPalette {\n  dark: {\n    text: string;\n    glow: string;\n  };\n  light: {\n    text: string;\n    glow: string;\n  };\n}\n\nconst COLOR_PALETTES: Record<AsciiHoverColor, AsciiColorPalette> = {\n  default: {\n    dark: {\n      text: \"#ffffff\",\n      glow: \"rgba(255, 255, 255, 0.4)\",\n    },\n    light: {\n      text: \"#09090b\",\n      glow: \"rgba(0, 0, 0, 0.12)\",\n    },\n  },\n  emerald: {\n    dark: {\n      text: \"#34d399\",\n      glow: \"rgba(52, 211, 153, 0.45)\",\n    },\n    light: {\n      text: \"#059669\",\n      glow: \"rgba(5, 150, 105, 0.25)\",\n    },\n  },\n  blue: {\n    dark: {\n      text: \"#60a5fa\",\n      glow: \"rgba(96, 165, 250, 0.45)\",\n    },\n    light: {\n      text: \"#2563eb\",\n      glow: \"rgba(37, 99, 235, 0.25)\",\n    },\n  },\n  violet: {\n    dark: {\n      text: \"#a78bfa\",\n      glow: \"rgba(167, 139, 250, 0.45)\",\n    },\n    light: {\n      text: \"#7c3aed\",\n      glow: \"rgba(124, 58, 237, 0.25)\",\n    },\n  },\n  cyan: {\n    dark: {\n      text: \"#38bdf8\",\n      glow: \"rgba(56, 189, 248, 0.45)\",\n    },\n    light: {\n      text: \"#0284c7\",\n      glow: \"rgba(2, 132, 199, 0.25)\",\n    },\n  },\n  amber: {\n    dark: {\n      text: \"#fbbf24\",\n      glow: \"rgba(251, 191, 36, 0.45)\",\n    },\n    light: {\n      text: \"#d97706\",\n      glow: \"rgba(217, 119, 6, 0.25)\",\n    },\n  },\n  rose: {\n    dark: {\n      text: \"#fb7185\",\n      glow: \"rgba(251, 113, 133, 0.45)\",\n    },\n    light: {\n      text: \"#e11d48\",\n      glow: \"rgba(225, 29, 72, 0.25)\",\n    },\n  },\n};\n\nconst DEFAULT_ASCII_CHARS = \"!<>-_\\\\/[]{}—=+*^?#_$%&~:;0123456789\";\n\nexport interface AsciiGlitchTextHandle {\n  trigger: () => void;\n  reset: () => void;\n}\n\nexport interface AsciiGlitchTextProps {\n  text: string;\n  isHovered?: boolean;\n  active?: boolean;\n  color?: AsciiHoverColor | string;\n  duration?: number;\n  asciiChars?: string;\n  className?: string;\n}\n\nexport const AsciiGlitchText = React.forwardRef<\n  AsciiGlitchTextHandle,\n  AsciiGlitchTextProps\n>(\n  (\n    {\n      text,\n      isHovered = false,\n      active = false,\n      color = \"default\",\n      duration = 0.75,\n      asciiChars = DEFAULT_ASCII_CHARS,\n      className,\n    },\n    ref,\n  ) => {\n    const [displayChars, setDisplayChars] = useState<string[]>(() =>\n      text.split(\"\"),\n    );\n    const [prevText, setPrevText] = useState(text);\n    const progressRef = useRef({ progress: 0 });\n    const lastUpdateRef = useRef(0);\n    const tweenRef = useRef<gsap.core.Tween | null>(null);\n\n    if (prevText !== text) {\n      setPrevText(text);\n      setDisplayChars(text.split(\"\"));\n    }\n\n    const isPresetColor = color in COLOR_PALETTES;\n    const isCustomColor = !isPresetColor && Boolean(color);\n\n    const triggerGlitch = useCallback(() => {\n      if (typeof window === \"undefined\") return;\n\n      if (tweenRef.current) {\n        tweenRef.current.kill();\n      }\n\n      const reduceMotion = window.matchMedia(\n        \"(prefers-reduced-motion: reduce)\",\n      ).matches;\n\n      if (reduceMotion) {\n        setDisplayChars(text.split(\"\"));\n        return;\n      }\n\n      progressRef.current.progress = 0;\n      lastUpdateRef.current = 0;\n      const chars = text.split(\"\");\n      const total = chars.length;\n      const glyphs = asciiChars || DEFAULT_ASCII_CHARS;\n\n      tweenRef.current = gsap.to(progressRef.current, {\n        progress: 1,\n        duration,\n        ease: \"power2.out\",\n        onUpdate: () => {\n          const now = performance.now();\n          const p = progressRef.current.progress;\n\n          const shouldRegenerate =\n            now - lastUpdateRef.current >= 45 || p >= 0.98;\n          if (!shouldRegenerate && p < 0.98) return;\n          lastUpdateRef.current = now;\n\n          const updated = chars.map((char, index) => {\n            if (char === \" \") return \" \";\n            const threshold = (index + 0.6) / (total + 0.4);\n            if (p >= threshold) {\n              return char;\n            }\n            const randomIndex = Math.floor(Math.random() * glyphs.length);\n            return glyphs[randomIndex];\n          });\n          setDisplayChars(updated);\n        },\n        onComplete: () => {\n          setDisplayChars(chars);\n        },\n      });\n    }, [text, duration, asciiChars]);\n\n    const resetGlitch = useCallback(() => {\n      if (tweenRef.current) {\n        tweenRef.current.kill();\n      }\n      setDisplayChars(text.split(\"\"));\n    }, [text]);\n\n    useImperativeHandle(\n      ref,\n      () => ({\n        trigger: triggerGlitch,\n        reset: resetGlitch,\n      }),\n      [triggerGlitch, resetGlitch],\n    );\n\n    useEffect(() => {\n      if (isHovered) {\n        const frame = requestAnimationFrame(() => {\n          triggerGlitch();\n        });\n        return () => cancelAnimationFrame(frame);\n      }\n    }, [isHovered, triggerGlitch]);\n\n    useEffect(() => {\n      return () => {\n        if (tweenRef.current) {\n          tweenRef.current.kill();\n        }\n      };\n    }, []);\n\n    const highlighted = isHovered || active;\n\n    const customColorStyle = useMemo(() => {\n      if (!highlighted) return undefined;\n      if (isCustomColor) {\n        return {\n          color,\n          textShadow: `0 0 10px ${color}80`,\n        };\n      }\n      return undefined;\n    }, [highlighted, isCustomColor, color]);\n\n    const originalChars = useMemo(() => text.split(\"\"), [text]);\n\n    return (\n      <span\n        className={cn(\n          \"inline-flex items-center justify-center font-sans text-sm tracking-wider font-semibold select-none transition-colors duration-300 ease-out\",\n          !highlighted &&\n            \"text-zinc-400 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100\",\n          highlighted &&\n            isPresetColor &&\n            color === \"default\" &&\n            \"text-zinc-950 dark:text-white\",\n          highlighted &&\n            isPresetColor &&\n            color === \"emerald\" &&\n            \"text-emerald-600 dark:text-emerald-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"blue\" &&\n            \"text-blue-600 dark:text-blue-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"violet\" &&\n            \"text-violet-600 dark:text-violet-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"cyan\" &&\n            \"text-cyan-600 dark:text-cyan-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"amber\" &&\n            \"text-amber-600 dark:text-amber-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"rose\" &&\n            \"text-rose-600 dark:text-rose-400\",\n          className,\n        )}\n        style={customColorStyle}\n        aria-label={text}\n      >\n        {displayChars.map((char, index) => {\n          const isGlitching = char !== originalChars[index];\n\n          return (\n            <span\n              key={index}\n              className={cn(\n                \"inline-block text-center transition-colors duration-150\",\n                isGlitching &&\n                  \"text-zinc-500 dark:text-zinc-300 opacity-90 font-mono\",\n              )}\n            >\n              {char === \" \" ? \"\\u00A0\" : char}\n            </span>\n          );\n        })}\n      </span>\n    );\n  },\n);\n\nAsciiGlitchText.displayName = \"AsciiGlitchText\";\n\nexport type AsciiHoverButtonVariant =\n  | \"segmented\"\n  | \"card\"\n  | \"outline\"\n  | \"ghost\";\n\nexport type AsciiHoverButtonSize = \"sm\" | \"default\" | \"lg\";\n\nexport interface AsciiHoverButtonProps extends Omit<\n  React.ButtonHTMLAttributes<HTMLButtonElement>,\n  \"color\"\n> {\n  text?: string;\n  variant?: AsciiHoverButtonVariant;\n  size?: AsciiHoverButtonSize;\n  color?: AsciiHoverColor | string;\n  active?: boolean;\n  asciiChars?: string;\n  scrambleDuration?: number;\n  leftIcon?: React.ReactNode;\n  rightIcon?: React.ReactNode;\n}\n\nconst sizeStyles: Record<AsciiHoverButtonSize, string> = {\n  sm: \"h-9 px-3.5 text-xs gap-1.5 rounded-xl\",\n  default: \"h-11 px-5 text-sm gap-2 rounded-2xl\",\n  lg: \"h-13 px-7 text-base gap-2.5 rounded-2xl\",\n};\n\nexport const AsciiHoverButton = React.forwardRef<\n  HTMLButtonElement,\n  AsciiHoverButtonProps\n>(\n  (\n    {\n      text = \"Explore\",\n      children,\n      variant = \"segmented\",\n      size = \"default\",\n      color = \"default\",\n      active = false,\n      asciiChars = DEFAULT_ASCII_CHARS,\n      scrambleDuration = 0.75,\n      leftIcon,\n      rightIcon,\n      className,\n      disabled,\n      onMouseEnter,\n      onMouseLeave,\n      ...props\n    },\n    ref,\n  ) => {\n    const [isHovered, setIsHovered] = useState(false);\n    const glitchRef = useRef<AsciiGlitchTextHandle>(null);\n\n    const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {\n      if (disabled) return;\n      setIsHovered(true);\n      glitchRef.current?.trigger();\n      onMouseEnter?.(e);\n    };\n\n    const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {\n      setIsHovered(false);\n      onMouseLeave?.(e);\n    };\n\n    const buttonText = typeof children === \"string\" ? children : text;\n\n    return (\n      <button\n        ref={ref}\n        disabled={disabled}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium transition-all duration-200 cursor-pointer select-none overflow-hidden active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed\",\n          sizeStyles[size],\n          variant === \"segmented\" &&\n            (active\n              ? \"bg-white text-zinc-950 border border-zinc-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.1),0_1px_2px_rgba(0,0,0,0.06)] dark:bg-[#18181b] dark:text-white dark:border-white/20 dark:shadow-[0_2px_10px_rgba(0,0,0,0.6),inset_0_1px_0_rgba(255,255,255,0.15)]\"\n              : \"bg-zinc-100/70 border border-transparent text-zinc-600 hover:bg-zinc-200/80 hover:text-zinc-900 dark:bg-zinc-900/60 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-zinc-100\"),\n          variant === \"card\" &&\n            \"bg-zinc-100 text-zinc-900 border border-zinc-200/80 shadow-sm hover:border-zinc-300 hover:bg-zinc-200/60 dark:bg-[#121214] dark:text-zinc-100 dark:border-white/10 dark:hover:border-white/25 dark:hover:bg-zinc-900/90 dark:shadow-xl\",\n          variant === \"outline\" &&\n            \"border border-zinc-300 text-zinc-700 hover:border-zinc-400 hover:bg-zinc-100/80 dark:border-white/20 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:bg-white/5\",\n          variant === \"ghost\" &&\n            \"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-zinc-100\",\n          className,\n        )}\n        {...props}\n      >\n        {leftIcon && (\n          <span className=\"relative z-10 shrink-0 transition-transform duration-200 group-hover:scale-105\">\n            {leftIcon}\n          </span>\n        )}\n        <AsciiGlitchText\n          ref={glitchRef}\n          text={buttonText}\n          isHovered={isHovered}\n          active={active}\n          color={color}\n          duration={scrambleDuration}\n          asciiChars={asciiChars}\n        />\n        {rightIcon && (\n          <span className=\"relative z-10 shrink-0 transition-transform duration-200 group-hover:scale-105\">\n            {rightIcon}\n          </span>\n        )}\n      </button>\n    );\n  },\n);\n\nAsciiHoverButton.displayName = \"AsciiHoverButton\";\n\nexport default AsciiHoverButton;\n",
      "content": "\"use client\";\n\nimport React, {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type AsciiHoverColor =\n  | \"default\"\n  | \"emerald\"\n  | \"blue\"\n  | \"violet\"\n  | \"amber\"\n  | \"rose\"\n  | \"cyan\";\n\nexport interface AsciiColorPalette {\n  dark: {\n    text: string;\n    glow: string;\n  };\n  light: {\n    text: string;\n    glow: string;\n  };\n}\n\nconst COLOR_PALETTES: Record<AsciiHoverColor, AsciiColorPalette> = {\n  default: {\n    dark: {\n      text: \"#ffffff\",\n      glow: \"rgba(255, 255, 255, 0.4)\",\n    },\n    light: {\n      text: \"#09090b\",\n      glow: \"rgba(0, 0, 0, 0.12)\",\n    },\n  },\n  emerald: {\n    dark: {\n      text: \"#34d399\",\n      glow: \"rgba(52, 211, 153, 0.45)\",\n    },\n    light: {\n      text: \"#059669\",\n      glow: \"rgba(5, 150, 105, 0.25)\",\n    },\n  },\n  blue: {\n    dark: {\n      text: \"#60a5fa\",\n      glow: \"rgba(96, 165, 250, 0.45)\",\n    },\n    light: {\n      text: \"#2563eb\",\n      glow: \"rgba(37, 99, 235, 0.25)\",\n    },\n  },\n  violet: {\n    dark: {\n      text: \"#a78bfa\",\n      glow: \"rgba(167, 139, 250, 0.45)\",\n    },\n    light: {\n      text: \"#7c3aed\",\n      glow: \"rgba(124, 58, 237, 0.25)\",\n    },\n  },\n  cyan: {\n    dark: {\n      text: \"#38bdf8\",\n      glow: \"rgba(56, 189, 248, 0.45)\",\n    },\n    light: {\n      text: \"#0284c7\",\n      glow: \"rgba(2, 132, 199, 0.25)\",\n    },\n  },\n  amber: {\n    dark: {\n      text: \"#fbbf24\",\n      glow: \"rgba(251, 191, 36, 0.45)\",\n    },\n    light: {\n      text: \"#d97706\",\n      glow: \"rgba(217, 119, 6, 0.25)\",\n    },\n  },\n  rose: {\n    dark: {\n      text: \"#fb7185\",\n      glow: \"rgba(251, 113, 133, 0.45)\",\n    },\n    light: {\n      text: \"#e11d48\",\n      glow: \"rgba(225, 29, 72, 0.25)\",\n    },\n  },\n};\n\nconst DEFAULT_ASCII_CHARS = \"!<>-_\\\\/[]{}—=+*^?#_$%&~:;0123456789\";\n\nexport interface AsciiGlitchTextHandle {\n  trigger: () => void;\n  reset: () => void;\n}\n\nexport interface AsciiGlitchTextProps {\n  text: string;\n  isHovered?: boolean;\n  active?: boolean;\n  color?: AsciiHoverColor | string;\n  duration?: number;\n  asciiChars?: string;\n  className?: string;\n}\n\nexport const AsciiGlitchText = React.forwardRef<\n  AsciiGlitchTextHandle,\n  AsciiGlitchTextProps\n>(\n  (\n    {\n      text,\n      isHovered = false,\n      active = false,\n      color = \"default\",\n      duration = 0.75,\n      asciiChars = DEFAULT_ASCII_CHARS,\n      className,\n    },\n    ref,\n  ) => {\n    const [displayChars, setDisplayChars] = useState<string[]>(() =>\n      text.split(\"\"),\n    );\n    const [prevText, setPrevText] = useState(text);\n    const progressRef = useRef({ progress: 0 });\n    const lastUpdateRef = useRef(0);\n    const tweenRef = useRef<gsap.core.Tween | null>(null);\n\n    if (prevText !== text) {\n      setPrevText(text);\n      setDisplayChars(text.split(\"\"));\n    }\n\n    const isPresetColor = color in COLOR_PALETTES;\n    const isCustomColor = !isPresetColor && Boolean(color);\n\n    const triggerGlitch = useCallback(() => {\n      if (typeof window === \"undefined\") return;\n\n      if (tweenRef.current) {\n        tweenRef.current.kill();\n      }\n\n      const reduceMotion = window.matchMedia(\n        \"(prefers-reduced-motion: reduce)\",\n      ).matches;\n\n      if (reduceMotion) {\n        setDisplayChars(text.split(\"\"));\n        return;\n      }\n\n      progressRef.current.progress = 0;\n      lastUpdateRef.current = 0;\n      const chars = text.split(\"\");\n      const total = chars.length;\n      const glyphs = asciiChars || DEFAULT_ASCII_CHARS;\n\n      tweenRef.current = gsap.to(progressRef.current, {\n        progress: 1,\n        duration,\n        ease: \"power2.out\",\n        onUpdate: () => {\n          const now = performance.now();\n          const p = progressRef.current.progress;\n\n          const shouldRegenerate =\n            now - lastUpdateRef.current >= 45 || p >= 0.98;\n          if (!shouldRegenerate && p < 0.98) return;\n          lastUpdateRef.current = now;\n\n          const updated = chars.map((char, index) => {\n            if (char === \" \") return \" \";\n            const threshold = (index + 0.6) / (total + 0.4);\n            if (p >= threshold) {\n              return char;\n            }\n            const randomIndex = Math.floor(Math.random() * glyphs.length);\n            return glyphs[randomIndex];\n          });\n          setDisplayChars(updated);\n        },\n        onComplete: () => {\n          setDisplayChars(chars);\n        },\n      });\n    }, [text, duration, asciiChars]);\n\n    const resetGlitch = useCallback(() => {\n      if (tweenRef.current) {\n        tweenRef.current.kill();\n      }\n      setDisplayChars(text.split(\"\"));\n    }, [text]);\n\n    useImperativeHandle(\n      ref,\n      () => ({\n        trigger: triggerGlitch,\n        reset: resetGlitch,\n      }),\n      [triggerGlitch, resetGlitch],\n    );\n\n    useEffect(() => {\n      if (isHovered) {\n        const frame = requestAnimationFrame(() => {\n          triggerGlitch();\n        });\n        return () => cancelAnimationFrame(frame);\n      }\n    }, [isHovered, triggerGlitch]);\n\n    useEffect(() => {\n      return () => {\n        if (tweenRef.current) {\n          tweenRef.current.kill();\n        }\n      };\n    }, []);\n\n    const highlighted = isHovered || active;\n\n    const customColorStyle = useMemo(() => {\n      if (!highlighted) return undefined;\n      if (isCustomColor) {\n        return {\n          color,\n          textShadow: `0 0 10px ${color}80`,\n        };\n      }\n      return undefined;\n    }, [highlighted, isCustomColor, color]);\n\n    const originalChars = useMemo(() => text.split(\"\"), [text]);\n\n    return (\n      <span\n        className={cn(\n          \"inline-flex items-center justify-center font-sans text-sm tracking-wider font-semibold select-none transition-colors duration-300 ease-out\",\n          !highlighted &&\n            \"text-zinc-400 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100\",\n          highlighted &&\n            isPresetColor &&\n            color === \"default\" &&\n            \"text-zinc-950 dark:text-white\",\n          highlighted &&\n            isPresetColor &&\n            color === \"emerald\" &&\n            \"text-emerald-600 dark:text-emerald-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"blue\" &&\n            \"text-blue-600 dark:text-blue-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"violet\" &&\n            \"text-violet-600 dark:text-violet-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"cyan\" &&\n            \"text-cyan-600 dark:text-cyan-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"amber\" &&\n            \"text-amber-600 dark:text-amber-400\",\n          highlighted &&\n            isPresetColor &&\n            color === \"rose\" &&\n            \"text-rose-600 dark:text-rose-400\",\n          className,\n        )}\n        style={customColorStyle}\n        aria-label={text}\n      >\n        {displayChars.map((char, index) => {\n          const isGlitching = char !== originalChars[index];\n\n          return (\n            <span\n              key={index}\n              className={cn(\n                \"inline-block text-center transition-colors duration-150\",\n                isGlitching &&\n                  \"text-zinc-500 dark:text-zinc-300 opacity-90 font-mono\",\n              )}\n            >\n              {char === \" \" ? \"\\u00A0\" : char}\n            </span>\n          );\n        })}\n      </span>\n    );\n  },\n);\n\nAsciiGlitchText.displayName = \"AsciiGlitchText\";\n\nexport type AsciiHoverButtonVariant =\n  | \"segmented\"\n  | \"card\"\n  | \"outline\"\n  | \"ghost\";\n\nexport type AsciiHoverButtonSize = \"sm\" | \"default\" | \"lg\";\n\nexport interface AsciiHoverButtonProps extends Omit<\n  React.ButtonHTMLAttributes<HTMLButtonElement>,\n  \"color\"\n> {\n  text?: string;\n  variant?: AsciiHoverButtonVariant;\n  size?: AsciiHoverButtonSize;\n  color?: AsciiHoverColor | string;\n  active?: boolean;\n  asciiChars?: string;\n  scrambleDuration?: number;\n  leftIcon?: React.ReactNode;\n  rightIcon?: React.ReactNode;\n}\n\nconst sizeStyles: Record<AsciiHoverButtonSize, string> = {\n  sm: \"h-9 px-3.5 text-xs gap-1.5 rounded-xl\",\n  default: \"h-11 px-5 text-sm gap-2 rounded-2xl\",\n  lg: \"h-13 px-7 text-base gap-2.5 rounded-2xl\",\n};\n\nexport const AsciiHoverButton = React.forwardRef<\n  HTMLButtonElement,\n  AsciiHoverButtonProps\n>(\n  (\n    {\n      text = \"Explore\",\n      children,\n      variant = \"segmented\",\n      size = \"default\",\n      color = \"default\",\n      active = false,\n      asciiChars = DEFAULT_ASCII_CHARS,\n      scrambleDuration = 0.75,\n      leftIcon,\n      rightIcon,\n      className,\n      disabled,\n      onMouseEnter,\n      onMouseLeave,\n      ...props\n    },\n    ref,\n  ) => {\n    const [isHovered, setIsHovered] = useState(false);\n    const glitchRef = useRef<AsciiGlitchTextHandle>(null);\n\n    const handleMouseEnter = (e: React.MouseEvent<HTMLButtonElement>) => {\n      if (disabled) return;\n      setIsHovered(true);\n      glitchRef.current?.trigger();\n      onMouseEnter?.(e);\n    };\n\n    const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {\n      setIsHovered(false);\n      onMouseLeave?.(e);\n    };\n\n    const buttonText = typeof children === \"string\" ? children : text;\n\n    return (\n      <button\n        ref={ref}\n        disabled={disabled}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium transition-all duration-200 cursor-pointer select-none overflow-hidden active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed\",\n          sizeStyles[size],\n          variant === \"segmented\" &&\n            (active\n              ? \"bg-white text-zinc-950 border border-zinc-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.1),0_1px_2px_rgba(0,0,0,0.06)] dark:bg-[#18181b] dark:text-white dark:border-white/20 dark:shadow-[0_2px_10px_rgba(0,0,0,0.6),inset_0_1px_0_rgba(255,255,255,0.15)]\"\n              : \"bg-zinc-100/70 border border-transparent text-zinc-600 hover:bg-zinc-200/80 hover:text-zinc-900 dark:bg-zinc-900/60 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-zinc-100\"),\n          variant === \"card\" &&\n            \"bg-zinc-100 text-zinc-900 border border-zinc-200/80 shadow-sm hover:border-zinc-300 hover:bg-zinc-200/60 dark:bg-[#121214] dark:text-zinc-100 dark:border-white/10 dark:hover:border-white/25 dark:hover:bg-zinc-900/90 dark:shadow-xl\",\n          variant === \"outline\" &&\n            \"border border-zinc-300 text-zinc-700 hover:border-zinc-400 hover:bg-zinc-100/80 dark:border-white/20 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:bg-white/5\",\n          variant === \"ghost\" &&\n            \"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-zinc-100\",\n          className,\n        )}\n        {...props}\n      >\n        {leftIcon && (\n          <span className=\"relative z-10 shrink-0 transition-transform duration-200 group-hover:scale-105\">\n            {leftIcon}\n          </span>\n        )}\n        <AsciiGlitchText\n          ref={glitchRef}\n          text={buttonText}\n          isHovered={isHovered}\n          active={active}\n          color={color}\n          duration={scrambleDuration}\n          asciiChars={asciiChars}\n        />\n        {rightIcon && (\n          <span className=\"relative z-10 shrink-0 transition-transform duration-200 group-hover:scale-105\">\n            {rightIcon}\n          </span>\n        )}\n      </button>\n    );\n  },\n);\n\nAsciiHoverButton.displayName = \"AsciiHoverButton\";\n\nexport default AsciiHoverButton;\n",
      "type": "registry:ui",
      "target": "@ui/ascii-hover-button.tsx"
    }
  ]
}
