{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-kinetic",
  "title": "Hero Kinetic",
  "description": "Motion-first editorial hero with a line-masked type reveal, cinematic plate, and velocity marquee.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "payload-components/source/blocks/shared/heroFields.ts",
      "content": "import type { Field } from 'payload'\n\nimport { linkGroup } from '@/fields/linkGroup'\n\n/**\n * Shared field core for the Hero component family.\n *\n * Every hero variant spreads these fields first and then appends its own\n * variant-specific ones. Editing the\n * shared headline/eyebrow/description/CTA shape here updates every installed\n * hero block at once, so the family never drifts field-by-field across a repo.\n *\n * Installed once per repo at `src/blocks/shared/heroFields.ts`; re-running\n * `payload-components add hero-*` never overwrites a copy you have already edited.\n */\nexport const heroFields: Field[] = [\n  {\n    name: 'eyebrow',\n    type: 'text',\n  },\n  {\n    name: 'title',\n    type: 'text',\n    required: true,\n  },\n  {\n    name: 'description',\n    type: 'textarea',\n    required: true,\n  },\n  linkGroup({\n    overrides: {\n      admin: {\n        initCollapsed: true,\n      },\n      maxRows: 2,\n      minRows: 1,\n    },\n  }),\n]\n",
      "type": "registry:file",
      "target": "~/src/blocks/shared/heroFields.ts"
    },
    {
      "path": "payload-components/source/blocks/HeroKinetic/config.ts",
      "content": "import type { Block } from 'payload'\n\nimport { heroFields } from '@/blocks/shared/heroFields'\n\nexport const HeroKinetic: Block = {\n  slug: 'heroKinetic',\n  // Existing apps must migrate stored data before adopting this identifier:\n  // https://www.payload-components.xyz/docs/registry#installed-source-and-migrations\n  dbName: 'pc_hero_kinetic',\n  interfaceName: 'HeroKineticBlock',\n  fields: [\n    // Shared hero core (eyebrow, title, description, CTA links). Variant-specific\n    // fields follow; edit the shared shape in @/blocks/shared/heroFields.\n    ...heroFields,\n    {\n      name: 'marqueeItems',\n      type: 'array',\n      admin: {\n        initCollapsed: true,\n      },\n      fields: [\n        {\n          name: 'label',\n          type: 'text',\n          required: true,\n        },\n      ],\n      maxRows: 8,\n    },\n    {\n      name: 'image',\n      type: 'upload',\n      relationTo: 'media',\n    },\n    {\n      name: 'imageCaption',\n      type: 'text',\n    },\n    {\n      name: 'proofItems',\n      type: 'array',\n      admin: {\n        initCollapsed: true,\n      },\n      fields: [\n        {\n          name: 'label',\n          type: 'text',\n          required: true,\n        },\n      ],\n      maxRows: 4,\n    },\n  ],\n  labels: {\n    plural: 'Hero Kinetic Blocks',\n    singular: 'Hero Kinetic',\n  },\n}\n",
      "type": "registry:file",
      "target": "~/src/blocks/HeroKinetic/config.ts"
    },
    {
      "path": "payload-components/source/blocks/HeroKinetic/Component.tsx",
      "content": "'use client'\n\nimport React, { useMemo, useRef } from 'react'\n\nimport {\n  motion,\n  useAnimationFrame,\n  useMotionValue,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from 'motion/react'\n\nimport type { HeroKineticBlock as HeroKineticBlockData } from '@/payload-types'\n\nimport { CMSLink } from '@/components/Link'\nimport { Media } from '@/components/Media'\nimport { cn } from '@/utilities/ui'\n\n/* Kinetic editorial hero — a title-sequence treatment of the shared hero\n * fields. The headline splits itself into balanced line masks and rises word\n * by word (the closing word lands in the serif accent); the media plate opens\n * from a letterbox slit and settles with a slow parallax; the marquee strip\n * loops continuously and leans into scroll velocity, reversing with it.\n *\n * Motion contract: transform/opacity only, no layout shift, no animated blur.\n * `prefers-reduced-motion` lands the final frame instantly — the JS timeline\n * is gated by useReducedMotion() and every animated style is pinned to its\n * final value by `motion-reduce:` utilities, so even the pre-hydration HTML\n * renders complete. The marquee degrades to a static wrapped row. */\n\ntype Props = HeroKineticBlockData & {\n  id?: string\n  className?: string\n  disableInnerContainer?: boolean\n}\n\nconst EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]\n\n/** Split a headline into (at most) two visually balanced word lines,\n * preferring a break right after sentence punctuation. */\nconst balanceTitle = (title: string): string[][] => {\n  const words = title.trim().split(/\\s+/).filter(Boolean)\n  if (words.length <= 3) return [words]\n\n  let bestIndex = 1\n  let bestScore = Number.POSITIVE_INFINITY\n  for (let index = 1; index < words.length; index += 1) {\n    const left = words.slice(0, index).join(' ').length\n    const right = words.slice(index).join(' ').length\n    const score = Math.abs(left - right) - (/[.!?:;—–-]$/.test(words[index - 1]) ? 6 : 0)\n    if (score < bestScore) {\n      bestScore = score\n      bestIndex = index\n    }\n  }\n\n  return [words.slice(0, bestIndex), words.slice(bestIndex)]\n}\n\nconst wrapRange = (min: number, max: number, value: number): number => {\n  const range = max - min\n  return ((((value - min) % range) + range) % range) + min\n}\n\n/* Seamless velocity marquee: the item group renders enough times to cover any\n * container, then a single rAF loop translates the track and wraps it every\n * group-width. Scroll velocity (springed) boosts the speed and flips the\n * direction, so the strip answers the reader's hand. */\nconst KineticMarquee: React.FC<{\n  items: NonNullable<HeroKineticBlockData['marqueeItems']>\n  reduce: boolean\n}> = ({ items, reduce }) => {\n  const { copies, speed } = useMemo(() => {\n    const chars = items.reduce((total, item) => total + item.label.length, 0)\n    const groupWidth = Math.max(chars * 12 + items.length * 72, 160)\n    const groupCopies = Math.min(24, Math.max(2, Math.ceil(3200 / groupWidth)))\n    /* ~44px/s of baseline drift, expressed in % of the whole track per second. */\n    return { copies: groupCopies, speed: (44 / (groupWidth * groupCopies)) * 100 }\n  }, [items])\n\n  const baseX = useMotionValue(0)\n  const { scrollY } = useScroll()\n  const scrollVelocity = useVelocity(scrollY)\n  const smoothVelocity = useSpring(scrollVelocity, { damping: 50, stiffness: 400 })\n  const velocityFactor = useTransform(smoothVelocity, [0, 900], [0, 4], { clamp: false })\n  const direction = useRef(1)\n  const groupPercent = 100 / copies\n  const x = useTransform(baseX, (value) => `${wrapRange(-groupPercent, 0, value)}%`)\n\n  useAnimationFrame((_, delta) => {\n    if (reduce) return\n    const factor = velocityFactor.get()\n    if (factor < -0.1) direction.current = -1\n    else if (factor > 0.1) direction.current = 1\n    const boost = 1 + Math.min(Math.abs(factor), 5)\n    baseX.set(baseX.get() - direction.current * speed * boost * (Math.min(delta, 48) / 1000))\n  })\n\n  return (\n    <div className=\"relative -mx-6 -mb-10 border-t border-border/70 sm:-mx-8 lg:-mx-12 lg:-mb-16\">\n      <div\n        aria-hidden=\"true\"\n        className=\"hidden overflow-hidden py-5 [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)] motion-safe:block lg:py-6\"\n      >\n        <motion.div className=\"flex w-max items-center gap-x-10\" style={{ x }}>\n          {Array.from({ length: copies }, (_, copy) => copy).flatMap((copy) =>\n            items.map((item, index) => (\n              <span key={`${copy}-${index}`} className=\"flex shrink-0 items-center gap-x-10\">\n                <span className=\"text-lg font-medium uppercase tracking-eyebrow text-foreground/75 sm:text-xl\">\n                  {item.label}\n                </span>\n                <span className=\"size-1.5 rotate-45 bg-brand/70\" />\n              </span>\n            )),\n          )}\n        </motion.div>\n      </div>\n\n      {/* Reduced-motion (and no-preference-unknown) fallback: the same items as\n          a static wrapped row. Swapped purely in CSS so the pre-hydration frame\n          is already correct; under motion it stays in the accessibility tree. */}\n      <ul className=\"flex flex-wrap gap-x-8 gap-y-2 px-6 py-5 sm:px-8 lg:px-12 lg:py-6 motion-safe:sr-only\">\n        {items.map((item, index) => (\n          <li\n            key={`${item.label}-${index}`}\n            className=\"text-sm uppercase tracking-eyebrow text-muted-foreground\"\n          >\n            {item.label}\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n\n/* Token-built abstract \"film still\" for the no-upload default: layered\n * gradients over the foreground tone — top wash, drifting light beam, sun\n * disc, emerald horizon, scanline grain. Every colour derives from theme\n * variables, so it re-tones with the consumer's palette. */\nconst KineticStill: React.FC<{ reduce: boolean }> = ({ reduce }) => (\n  <div aria-hidden=\"true\" className=\"absolute inset-0\">\n    <div\n      className=\"absolute inset-0\"\n      style={{\n        background:\n          'linear-gradient(to bottom, color-mix(in oklab, var(--background) 14%, transparent), transparent 44%)',\n      }}\n    />\n    <motion.div\n      className=\"absolute -inset-x-1/4 inset-y-0\"\n      style={{\n        background:\n          'linear-gradient(104deg, transparent 38%, color-mix(in oklab, var(--background) 13%, transparent) 50%, transparent 62%)',\n      }}\n      animate={reduce ? undefined : { x: ['-3%', '3%'] }}\n      transition={reduce ? undefined : { duration: 16, ease: 'easeInOut', repeat: Infinity, repeatType: 'mirror' }}\n    />\n    <div\n      className=\"absolute\"\n      style={{\n        aspectRatio: '1 / 1',\n        background:\n          'radial-gradient(circle, color-mix(in oklab, var(--background) 34%, transparent) 0 46%, transparent 60%)',\n        left: '72%',\n        top: '14%',\n        width: '8%',\n      }}\n    />\n    <div\n      className=\"absolute inset-0\"\n      style={{\n        background:\n          'radial-gradient(58% 44% at 50% 76%, color-mix(in oklab, var(--brand) 32%, transparent), transparent 70%)',\n      }}\n    />\n    <div\n      className=\"absolute inset-x-6 sm:inset-x-10\"\n      style={{\n        background:\n          'linear-gradient(to right, transparent, color-mix(in oklab, var(--brand) 80%, transparent) 18%, color-mix(in oklab, var(--brand) 80%, transparent) 82%, transparent)',\n        height: '1px',\n        top: '76%',\n      }}\n    />\n    <div\n      className=\"absolute inset-0 opacity-35\"\n      style={{\n        background:\n          'repeating-linear-gradient(0deg, transparent 0 3px, color-mix(in oklab, var(--background) 6%, transparent) 3px 4px)',\n      }}\n    />\n  </div>\n)\n\n/* Cinematic plate: a letterbox slit irises open over the frame while the\n * content settles from an overscanned scale, then keeps a slow scroll\n * parallax. Crop marks register once the reveal completes. */\nconst KineticPlate: React.FC<{\n  image: HeroKineticBlockData['image']\n  imageCaption: HeroKineticBlockData['imageCaption']\n  reduce: boolean\n}> = ({ image, imageCaption, reduce }) => {\n  const frameRef = useRef<HTMLDivElement | null>(null)\n  const { scrollYProgress } = useScroll({ offset: ['start end', 'end start'], target: frameRef })\n  const parallax = useTransform(scrollYProgress, [0, 1], ['-4%', '4%'])\n  const settle = reduce ? { duration: 0 } : { delay: 0.45, duration: 1.3, ease: EASE }\n\n  return (\n    <figure className=\"w-full\">\n      <div\n        ref={frameRef}\n        className=\"relative aspect-[21/9] overflow-hidden rounded-panel border border-border/70 bg-muted shadow-xl\"\n      >\n        <motion.div\n          className=\"absolute inset-0 bg-foreground motion-reduce:[clip-path:none]!\"\n          initial={{ clipPath: 'inset(44% 6% 44% 6% round 999px)' }}\n          animate={{ clipPath: 'inset(0% 0% 0% 0% round 0px)' }}\n          transition={settle}\n        >\n          <motion.div\n            className=\"absolute -inset-[6%] motion-reduce:transform-none!\"\n            style={{ y: parallax }}\n            initial={{ scale: 1.14 }}\n            animate={{ scale: 1 }}\n            transition={settle}\n          >\n            {image ? (\n              <Media resource={image} imgClassName=\"h-full w-full object-cover\" />\n            ) : (\n              <KineticStill reduce={reduce} />\n            )}\n            <div\n              className=\"absolute inset-0\"\n              style={{\n                background:\n                  'radial-gradient(120% 90% at 50% 30%, transparent 58%, color-mix(in oklab, var(--foreground) 30%, transparent))',\n              }}\n            />\n          </motion.div>\n        </motion.div>\n\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute start-4 top-4 size-5 border-s border-t border-background/60 motion-reduce:opacity-100!\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={reduce ? { duration: 0 } : { delay: 1.2, duration: 0.6, ease: 'easeOut' }}\n        />\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-4 end-4 size-5 border-b border-e border-background/60 motion-reduce:opacity-100!\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={reduce ? { duration: 0 } : { delay: 1.2, duration: 0.6, ease: 'easeOut' }}\n        />\n      </div>\n\n      {imageCaption ? (\n        <motion.figcaption\n          className=\"mt-4 flex items-center gap-3 text-sm text-muted-foreground motion-reduce:opacity-100!\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={reduce ? { duration: 0 } : { delay: 1.35, duration: 0.7, ease: 'easeOut' }}\n        >\n          <span aria-hidden=\"true\" className=\"h-px w-8 bg-brand\" />\n          {imageCaption}\n        </motion.figcaption>\n      ) : null}\n    </figure>\n  )\n}\n\nexport const HeroKineticBlock: React.FC<Props> = ({\n  className,\n  description,\n  disableInnerContainer,\n  eyebrow,\n  id,\n  image,\n  imageCaption,\n  links,\n  marqueeItems,\n  proofItems,\n  title,\n}) => {\n  const reduce = useReducedMotion() ?? false\n  const lines = useMemo(() => balanceTitle(title), [title])\n  const lineOffsets = useMemo(\n    () => lines.map((_, index) => lines.slice(0, index).reduce((total, line) => total + line.length, 0)),\n    [lines],\n  )\n\n  const enter = (delay: number, duration = 0.7) =>\n    reduce ? { duration: 0 } : { delay, duration, ease: EASE }\n\n  return (\n    <section className={cn('container', className)} id={id ? `block-${id}` : undefined}>\n      <div className=\"overflow-hidden rounded-frame border border-border/70 bg-card/35 px-6 py-10 sm:px-8 lg:px-12 lg:py-16\">\n        <div\n          className={cn('flex flex-col gap-10 lg:gap-12', {\n            'mx-auto max-w-6xl': !disableInnerContainer,\n          })}\n        >\n          <div className=\"flex flex-col gap-6 lg:gap-8\">\n            {/* Masthead: eyebrow plus a hairline rule that draws itself in. */}\n            <div className=\"flex items-center gap-4 sm:gap-6\">\n              {eyebrow ? (\n                <motion.p\n                  className=\"flex shrink-0 items-center gap-2.5 text-sm font-medium uppercase tracking-eyebrow motion-reduce:opacity-100! motion-reduce:transform-none!\"\n                  initial={{ opacity: 0, y: 10 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={enter(0.05)}\n                >\n                  <span aria-hidden=\"true\" className=\"size-1.5 rounded-full bg-brand\" />\n                  {eyebrow}\n                </motion.p>\n              ) : null}\n              <motion.span\n                aria-hidden=\"true\"\n                className=\"h-px flex-1 origin-left bg-border motion-reduce:transform-none!\"\n                initial={{ scaleX: 0 }}\n                animate={{ scaleX: 1 }}\n                transition={reduce ? { duration: 0 } : { delay: 0.1, duration: 0.9, ease: EASE }}\n              />\n            </div>\n\n            {/* The headline splits itself into balanced lines; every word rises\n                out of its own overflow mask. The mask padding (cancelled by\n                negative margins) keeps descenders and italic overhangs unclipped. */}\n            <h2 className=\"text-5xl font-medium tracking-display sm:text-7xl lg:text-8xl\">\n              {lines.map((line, lineIndex) => (\n                <span key={lineIndex} className=\"block\">\n                  {line.map((word, wordIndex) => {\n                    const order = lineOffsets[lineIndex] + wordIndex\n                    const isClosingWord =\n                      lineIndex === lines.length - 1 && wordIndex === line.length - 1\n                    return (\n                      <React.Fragment key={`${word}-${order}`}>\n                        <span className=\"inline-block overflow-hidden px-2 -mx-2 py-2.5 -my-2.5\">\n                          <motion.span\n                            className={cn(\n                              'inline-block will-change-transform motion-reduce:transform-none!',\n                              { 'font-serif italic tracking-title': isClosingWord },\n                            )}\n                            initial={{ y: '125%' }}\n                            animate={{ y: '0%' }}\n                            transition={enter(0.16 + order * 0.085, 0.85)}\n                          >\n                            {word}\n                          </motion.span>\n                        </span>\n                        {wordIndex < line.length - 1 ? ' ' : null}\n                      </React.Fragment>\n                    )\n                  })}\n                </span>\n              ))}\n            </h2>\n          </div>\n\n          <div className=\"flex flex-col gap-8 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between lg:gap-12\">\n            <motion.p\n              className=\"max-w-xl text-base leading-7 text-muted-foreground sm:text-lg lg:min-w-80 lg:flex-1 motion-reduce:opacity-100! motion-reduce:transform-none!\"\n              initial={{ opacity: 0, y: 14 }}\n              animate={{ opacity: 1, y: 0 }}\n              transition={enter(0.55)}\n            >\n              {description}\n            </motion.p>\n\n            <div className=\"flex shrink-0 flex-col gap-5 lg:items-end\">\n              {links && links.length > 0 ? (\n                <motion.div\n                  className=\"flex flex-col gap-3 sm:flex-row motion-reduce:opacity-100! motion-reduce:transform-none!\"\n                  initial={{ opacity: 0, y: 14 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={enter(0.65)}\n                >\n                  {links.map(({ link }, index) => (\n                    <motion.div key={index} className=\"group/cta relative\" whileHover={{ y: -2 }}>\n                      <CMSLink\n                        appearance={link.appearance === 'outline' ? 'outline' : 'default'}\n                        {...link}\n                      />\n                      {/* Hover micro-detail: an emerald hairline draws under the CTA. */}\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"absolute inset-x-1 -bottom-1.5 h-px origin-left scale-x-0 bg-brand transition-transform duration-300 ease-out group-hover/cta:scale-x-100\"\n                      />\n                    </motion.div>\n                  ))}\n                </motion.div>\n              ) : null}\n\n              {proofItems && proofItems.length > 0 ? (\n                <ul className=\"flex flex-wrap gap-x-5 gap-y-2 lg:justify-end\">\n                  {proofItems.map(({ label }, index) => (\n                    <motion.li\n                      key={`${label}-${index}`}\n                      className=\"flex items-center gap-2 text-xs font-medium uppercase tracking-eyebrow text-muted-foreground motion-reduce:opacity-100! motion-reduce:transform-none!\"\n                      initial={{ opacity: 0, y: 8 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      transition={enter(0.75 + index * 0.06, 0.6)}\n                    >\n                      <span aria-hidden=\"true\" className=\"font-mono text-brand\">\n                        {String(index + 1).padStart(2, '0')}\n                      </span>\n                      {label}\n                    </motion.li>\n                  ))}\n                </ul>\n              ) : null}\n            </div>\n          </div>\n\n          <KineticPlate image={image} imageCaption={imageCaption} reduce={reduce} />\n\n          {marqueeItems && marqueeItems.length > 0 ? (\n            <KineticMarquee items={marqueeItems} reduce={reduce} />\n          ) : null}\n        </div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:file",
      "target": "~/src/blocks/HeroKinetic/Component.tsx"
    }
  ],
  "meta": {
    "payloadComponent": {
      "installCommand": "payload-components add hero-kinetic",
      "postInstall": [
        "generate:types",
        "generate:importmap"
      ],
      "requiresPayloadComponentWrapper": true,
      "supportedTargets": [
        "payload-website-starter",
        "payload-blocks-app"
      ]
    }
  },
  "docs": "Payload Components installs this as a Payload CMS block for the Payload website starter shape.\n\nRecommended install:\n\n```bash\npnpm payload-components add hero-kinetic\n```\n\nDirect shadcn install URL:\n\n```bash\npnpm dlx shadcn@latest add https://www.payload-components.xyz/r/hero-kinetic.json\n```\n\nDirect shadcn installs only copy the block source files and shadcn UI dependencies. Use `payload-components add hero-kinetic` when you also want Payload layout registration, `RenderBlocks` wiring, `generate:types`, and `generate:importmap` handled for you.",
  "type": "registry:block"
}