{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "footer-pulse-links",
  "type": "registry:block",
  "title": "Footer Pulse Links",
  "description": "A dark site footer where every link label rolls up letter by letter into lime on hover while a heartbeat line draws itself in underneath, closed by a wordmark fitted to the column whose letters rise in and then lift and tilt toward the pointer.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/footer-pulse-links.tsx",
      "type": "registry:component",
      "target": "components/tweenui/footer-pulse-links.tsx",
      "content": "'use client';\n\nimport { useId, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst canHover = () =>\n  typeof window !== 'undefined' && window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n\nexport type FooterPulseSocialIcon = 'x' | 'instagram' | 'linkedin' | 'dribbble' | 'github';\n\nexport interface FooterPulseLink {\n  label: string;\n  href: string;\n}\n\nexport interface FooterPulseColumn {\n  heading: string;\n  links: FooterPulseLink[];\n}\n\nexport interface FooterPulseSocial {\n  label: string;\n  href: string;\n  icon: FooterPulseSocialIcon;\n}\n\nexport interface FooterPulseLinksProps extends ComponentPropsWithoutRef<'footer'> {\n  brand?: string;\n  logo?: ReactNode;\n  description?: string;\n  columns?: FooterPulseColumn[];\n  socials?: FooterPulseSocial[];\n  legal?: FooterPulseLink[];\n  copyright?: string;\n}\n\nconst REPO = 'https://github.com/StaticMania/tween-ui';\n\nconst DEFAULT_COLUMNS: FooterPulseColumn[] = [\n  {\n    heading: 'Components',\n    links: [\n      { label: 'All components', href: '/components' },\n      { label: 'Icon Trail Button', href: '/component/icon-trail-button' },\n      { label: 'Sliding Tabs', href: '/component/sliding-tabs' },\n      { label: 'Number Counter', href: '/component/number-counter' },\n    ],\n  },\n  {\n    heading: 'Blocks',\n    links: [\n      { label: 'All blocks', href: '/components#blocks' },\n      { label: 'Kinetic Type Ring', href: '/block/kinetic-type-ring' },\n      { label: 'Pricing Plan Switch', href: '/block/pricing-plan-switch' },\n      { label: 'Shutter Slider', href: '/block/shutter-slider' },\n    ],\n  },\n  {\n    heading: 'Community',\n    links: [\n      { label: 'GitHub', href: REPO },\n      { label: 'Report an issue', href: `${REPO}/issues` },\n    ],\n  },\n];\n\nconst DEFAULT_SOCIALS: FooterPulseSocial[] = [{ label: 'GitHub', href: REPO, icon: 'github' }];\n\nconst DEFAULT_LEGAL: FooterPulseLink[] = [\n  { label: 'MIT License', href: `${REPO}/blob/main/LICENSE` },\n  { label: 'Changelog', href: `${REPO}/blob/main/CHANGELOG.md` },\n  { label: 'Contributing', href: `${REPO}/blob/main/CONTRIBUTING.md` },\n];\n\nconst TWEEN_MARK = (\n  <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" className=\"size-7\">\n    <rect width=\"24\" height=\"24\" rx=\"6\" fill=\"#045f64\" />\n    <path\n      d=\"M5 19 C 9.5 8.9, 5 5, 19 5\"\n      fill=\"none\"\n      stroke=\"#c6f56f\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n    />\n    <circle cx=\"19\" cy=\"5\" r=\"1.8\" fill=\"#c6f56f\" />\n  </svg>\n);\n\nconst EASE_OUT = 'ease-[cubic-bezier(0.16,1,0.3,1)]';\n\nconst EASE_ROLL = 'ease-[cubic-bezier(0.45,0,0.15,1)]';\n\nconst focusRing =\n  'rounded-xs focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]';\n\nconst SOCIAL_PATHS: Record<FooterPulseSocialIcon, ReactNode> = {\n  x: (\n    <path d=\"M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z\" />\n  ),\n  instagram: (\n    <>\n      <rect width=\"20\" height=\"20\" x=\"2\" y=\"2\" rx=\"5\" ry=\"5\" />\n      <path d=\"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z\" />\n      <line x1=\"17.5\" x2=\"17.51\" y1=\"6.5\" y2=\"6.5\" />\n    </>\n  ),\n  linkedin: (\n    <>\n      <path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\" />\n      <rect width=\"4\" height=\"12\" x=\"2\" y=\"9\" />\n      <circle cx=\"4\" cy=\"4\" r=\"2\" />\n    </>\n  ),\n  dribbble: (\n    <>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M19.13 5.09C15.22 9.14 10 10.44 2.25 10.94\" />\n      <path d=\"M21.75 12.84c-6.62-1.41-12.14 1-16.38 6.32\" />\n      <path d=\"M8.56 2.75c4.37 6 6 9.42 8 17.72\" />\n    </>\n  ),\n  github: (\n    <>\n      <path d=\"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4\" />\n      <path d=\"M9 18c-4.51 2-5-2-7-2\" />\n    </>\n  ),\n};\n\nconst isExternal = (href: string) => /^https?:/.test(href);\n\nconst externalProps = (href: string) =>\n  isExternal(href) ? { target: '_blank', rel: 'noopener noreferrer' } : {};\n\nconst RollLabel = ({ label }: { label: string }) => {\n  return (\n    <>\n      <span className=\"sr-only\">{label}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex h-[1.25em] overflow-hidden leading-[1.25em]\">\n        {Array.from(label).map((char, index) => (\n          <span\n            key={`${char}-${index}`}\n            data-char={char}\n            className={cn(\n              'relative inline-block whitespace-pre transition-[translate] duration-700',\n              EASE_ROLL,\n              'after:absolute after:top-full after:left-0 after:text-[#c6f56f] after:content-[attr(data-char)]',\n              'group-hover/link:-translate-y-full group-focus-visible/link:-translate-y-full',\n              'motion-reduce:transition-none'\n            )}\n            style={{ transitionDelay: `${index * 22}ms` }}\n          >\n            {char}\n          </span>\n        ))}\n      </span>\n    </>\n  );\n};\n\nconst PulseUnderline = () => {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 100 10\"\n      preserveAspectRatio=\"none\"\n      className=\"pointer-events-none absolute inset-x-0 -bottom-1.5 h-2 w-full overflow-visible\"\n    >\n      <path\n        d=\"M0 5 H40 L45 1 L51 9 L56 5 H100\"\n        fill=\"none\"\n        stroke=\"#c6f56f\"\n        strokeWidth=\"1.4\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        className={cn(\n          'transition-[stroke-dashoffset] duration-900 [stroke-dasharray:1_2] [stroke-dashoffset:1]',\n          EASE_ROLL,\n          'group-hover/link:[stroke-dashoffset:0] group-focus-visible/link:[stroke-dashoffset:0]',\n          'motion-reduce:transition-none'\n        )}\n      />\n    </svg>\n  );\n};\n\nconst PulseLink = ({ link, className }: { link: FooterPulseLink; className?: string }) => {\n  return (\n    <a\n      href={link.href}\n      {...externalProps(link.href)}\n      className={cn('group/link relative inline-flex whitespace-nowrap', focusRing, className)}\n    >\n      <RollLabel label={link.label} />\n      <PulseUnderline />\n    </a>\n  );\n};\n\nconst LinkGroup = ({ column }: { column: FooterPulseColumn }) => {\n  const headingId = useId();\n\n  return (\n    <div className=\"grid content-start gap-4\">\n      <p id={headingId} className=\"text-xs tracking-[0.14em] text-[#9fd4d6]/60 uppercase\">\n        {column.heading}\n      </p>\n      <ul aria-labelledby={headingId} className=\"grid justify-items-start gap-3\">\n        {column.links.map((link) => (\n          <li key={`${link.label}-${link.href}`}>\n            <PulseLink link={link} className=\"text-[15px] text-[#e8f3f2]/85\" />\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n};\n\nconst LIFT_SETTLE_MS = 160;\n\nconst followPointer = (mark: HTMLElement, letters: HTMLElement[]) => {\n  const lifts = letters.map(() => 0);\n  const tilts = letters.map(() => 0);\n  const pointer = { x: 0, y: 0, isInside: false };\n  let centers: { x: number; y: number }[] = [];\n  let frame = 0;\n  let lastTime = 0;\n\n  const measure = () => {\n    centers = letters.map((letter) => ({\n      x: letter.offsetLeft + letter.offsetWidth / 2,\n      y: letter.offsetTop + letter.offsetHeight / 2,\n    }));\n  };\n\n  const tick = (time: number) => {\n    frame = 0;\n    const elapsed = lastTime ? Math.min(64, time - lastTime) : 16;\n    lastTime = time;\n    const blend = 1 - Math.exp(-elapsed / LIFT_SETTLE_MS);\n    if (centers.length !== letters.length) measure();\n\n    const size = parseFloat(getComputedStyle(mark).fontSize) || 120;\n    const maxLift = Math.min(70, size * 0.18);\n    const radius = size * 1.9 + 170;\n    const origin = mark.getBoundingClientRect();\n    let isMoving = false;\n\n    letters.forEach((letter, index) => {\n      const center = centers[index] ?? { x: 0, y: 0 };\n      const dx = pointer.x - (origin.left + center.x);\n      const dy = pointer.y - (origin.top + center.y);\n      const reach = pointer.isInside ? Math.max(0, 1 - Math.hypot(dx, dy) / radius) : 0;\n      const eased = reach * reach * (3 - 2 * reach);\n      const side = Math.max(-1, Math.min(1, dx / (radius * 0.35)));\n      const lift = (lifts[index] ?? 0) + (eased * maxLift - (lifts[index] ?? 0)) * blend;\n      const tilt = (tilts[index] ?? 0) + (-side * eased * 4.5 - (tilts[index] ?? 0)) * blend;\n      if (Math.abs(eased * maxLift - lift) > 0.05 || Math.abs(-side * eased * 4.5 - tilt) > 0.01) {\n        isMoving = true;\n      }\n      lifts[index] = lift;\n      tilts[index] = tilt;\n      letter.style.transform = `translate3d(0, ${(-lift).toFixed(3)}px, 0) rotate(${tilt.toFixed(3)}deg)`;\n    });\n\n    if (isMoving || pointer.isInside) {\n      frame = requestAnimationFrame(tick);\n    } else {\n      lastTime = 0;\n    }\n  };\n\n  const wake = () => {\n    if (!frame) frame = requestAnimationFrame(tick);\n  };\n\n  const handleMove = (event: PointerEvent) => {\n    pointer.x = event.clientX;\n    pointer.y = event.clientY;\n    pointer.isInside = true;\n    wake();\n  };\n\n  const handleLeave = () => {\n    pointer.isInside = false;\n    wake();\n  };\n\n  const observer = new ResizeObserver(measure);\n  observer.observe(mark);\n  mark.addEventListener('pointermove', handleMove);\n  mark.addEventListener('pointerleave', handleLeave);\n\n  return () => {\n    observer.disconnect();\n    mark.removeEventListener('pointermove', handleMove);\n    mark.removeEventListener('pointerleave', handleLeave);\n    cancelAnimationFrame(frame);\n    letters.forEach((letter) => {\n      letter.style.transform = '';\n    });\n  };\n};\n\nconst FooterPulseLinks = ({\n  brand = 'Tween UI',\n  logo = TWEEN_MARK,\n  description = 'GSAP & CSS animated components for React. Copy the source, own the animation.',\n  columns = DEFAULT_COLUMNS,\n  socials = DEFAULT_SOCIALS,\n  legal = DEFAULT_LEGAL,\n  copyright,\n  className,\n  ...props\n}: FooterPulseLinksProps) => {\n  const rootRef = useRef<HTMLElement>(null);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const markRef = useRef<HTMLParagraphElement>(null);\n  const wordRef = useRef<HTMLSpanElement>(null);\n\n  useGSAP(\n    () => {\n      const box = boxRef.current;\n      const mark = markRef.current;\n      const word = wordRef.current;\n      if (!box || !mark || !word) return;\n\n      let fittedFor = 0;\n      const fit = () => {\n        const width = box.clientWidth;\n        if (!width || width === fittedFor) return;\n        fittedFor = width;\n        mark.style.fontSize = '';\n        const size = parseFloat(getComputedStyle(mark).fontSize);\n        const trailing = parseFloat(getComputedStyle(word).letterSpacing) || 0;\n        const ink = word.offsetWidth - trailing;\n        if (ink > 0) mark.style.fontSize = `${(size * width) / ink}px`;\n      };\n      const observer = new ResizeObserver(fit);\n      observer.observe(box);\n      fit();\n\n      const fonts = typeof document !== 'undefined' ? document.fonts?.ready : undefined;\n      fonts?.then(() => {\n        fittedFor = 0;\n        fit();\n      });\n\n      const letters = Array.from(word.querySelectorAll<HTMLElement>('[data-letter]'));\n      let stopFollowing: (() => void) | null = null;\n      const startFollowing = () => {\n        if (canHover()) stopFollowing = followPointer(mark, letters);\n      };\n\n      const reduce = prefersReducedMotion();\n      const entrance = reduce\n        ? null\n        : gsap.fromTo(\n            letters,\n            { autoAlpha: 0, y: 46 },\n            {\n              autoAlpha: 1,\n              y: 0,\n              duration: 0.62,\n              ease: 'power3.out',\n              stagger: 0.04,\n              paused: true,\n              onComplete: () => {\n                gsap.set(letters, { clearProps: 'transform,opacity,visibility' });\n                startFollowing();\n              },\n            }\n          );\n\n      const trigger = entrance\n        ? ScrollTrigger.create({\n            trigger: box,\n            start: 'top 95%',\n            once: true,\n            onEnter: () => entrance.play(),\n          })\n        : null;\n\n      if (reduce) startFollowing();\n\n      return () => {\n        observer.disconnect();\n        trigger?.kill();\n        entrance?.kill();\n        stopFollowing?.();\n        mark.style.fontSize = '';\n      };\n    },\n    { scope: rootRef, dependencies: [brand] }\n  );\n\n  const notice = copyright ?? `© ${new Date().getFullYear()} ${brand}`;\n  const lastWordStart = brand.lastIndexOf(' ') + 1;\n\n  return (\n    <footer\n      ref={rootRef}\n      className={cn(\n        'relative isolate w-full overflow-hidden border-t border-[#9fd4d6]/12 bg-[#03110f] pt-16 pb-8 text-[#e8f3f2] max-sm:pt-12',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 -z-10 h-[420px] max-sm:h-[300px]\"\n      >\n        <span className=\"absolute top-full left-1/2 h-[200%] w-[150%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(4_95_100/0.6)_0%,rgb(4_95_100/0.24)_45%,rgb(4_95_100/0)_100%)]\" />\n        <span className=\"absolute top-full left-1/2 h-[110%] w-[70%] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,rgb(198_245_111/0.16)_0%,rgb(198_245_111/0.05)_55%,rgb(198_245_111/0)_100%)] mix-blend-plus-lighter\" />\n      </div>\n\n      <div className=\"mx-auto grid w-full max-w-[1320px] gap-4 px-5 max-sm:gap-10 sm:px-8\">\n        <div className=\"flex flex-wrap items-start justify-between gap-12 max-md:flex-col max-md:gap-10\">\n          <div className=\"grid w-[300px] justify-items-start gap-6 max-md:w-full\">\n            <a href=\"/\" className={cn('flex items-center gap-2.5', focusRing)}>\n              {logo}\n              <span className=\"text-lg font-semibold tracking-tight\">{brand}</span>\n            </a>\n            <p className=\"text-[15px] leading-relaxed text-[#9fd4d6]/70\">{description}</p>\n            {socials.length > 0 ? (\n              <ul aria-label=\"Social links\" className=\"flex gap-3\">\n                {socials.map((social) => (\n                  <li key={social.href}>\n                    <a\n                      href={social.href}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      aria-label={`${brand} on ${social.label}`}\n                      className={cn(\n                        'group/social relative flex size-11 items-center justify-center rounded-full opacity-70',\n                        'bg-[linear-gradient(175deg,rgb(159_212_214/0.36)_0%,rgb(159_212_214/0)_19%,rgb(159_212_214/1)_41%,rgb(159_212_214/0)_77%,rgb(159_212_214/0.05)_100%)]',\n                        \"before:absolute before:inset-px before:rounded-full before:bg-[#03110f] before:transition-colors before:duration-300 before:content-['']\",\n                        'transition-[opacity,translate] duration-500 hover:-translate-y-0.5 hover:opacity-100 hover:before:bg-[#071d1b]',\n                        EASE_OUT,\n                        'focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f]',\n                        'motion-reduce:transition-none motion-reduce:hover:translate-y-0'\n                      )}\n                    >\n                      <svg\n                        aria-hidden=\"true\"\n                        viewBox=\"0 0 24 24\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"2\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        className=\"relative size-4 text-[#e8f3f2] transition-colors duration-300 group-hover/social:text-[#c6f56f] motion-reduce:transition-none\"\n                      >\n                        {SOCIAL_PATHS[social.icon]}\n                      </svg>\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            ) : null}\n          </div>\n\n          <nav aria-label=\"Footer\" className=\"flex flex-wrap gap-x-20 gap-y-10 max-lg:gap-x-12\">\n            {columns.map((column) => (\n              <LinkGroup key={column.heading} column={column} />\n            ))}\n          </nav>\n        </div>\n\n        <div ref={boxRef} className=\"min-w-0\">\n          <p\n            ref={markRef}\n            aria-hidden=\"true\"\n            className=\"relative pt-6 pb-2 text-center text-[length:min(20vw,240px)] leading-none font-bold tracking-[0.02em] whitespace-nowrap uppercase select-none\"\n          >\n            <span ref={wordRef} className=\"-mr-[0.02em] inline-block\">\n              {Array.from(brand).map((char, index) => (\n                <span\n                  key={`${char}-${index}`}\n                  data-letter=\"\"\n                  className={cn(\n                    'inline-block bg-clip-text whitespace-pre text-transparent will-change-transform',\n                    index >= lastWordStart\n                      ? 'bg-[linear-gradient(to_top,rgb(198_245_111/0.15)_13%,rgb(198_245_111/0.95)_100%)]'\n                      : 'bg-[linear-gradient(to_top,rgb(232_243_242/0.12)_4%,rgb(240_248_247/0.82)_38%,rgb(255_255_255/1)_100%)]'\n                  )}\n                >\n                  {char}\n                </span>\n              ))}\n            </span>\n          </p>\n        </div>\n\n        <div className=\"flex flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-[#9fd4d6]/12 pt-6 max-sm:flex-col max-sm:items-start\">\n          <p className=\"text-xs tracking-[0.14em] whitespace-nowrap text-[#9fd4d6]/65 uppercase\">\n            {notice}\n          </p>\n          <ul className=\"flex flex-wrap items-center gap-x-8 gap-y-3 max-sm:gap-x-6\">\n            {legal.map((link) => (\n              <li key={`${link.label}-${link.href}`}>\n                <PulseLink\n                  link={link}\n                  className=\"text-xs tracking-[0.14em] text-[#9fd4d6]/65 uppercase\"\n                />\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n    </footer>\n  );\n};\n\nexport default FooterPulseLinks;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/footer-pulse-links\nGSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up.\nPass your own `columns`, `socials` and `legal` links; the link hover is plain CSS and only the wordmark uses GSAP."
}