{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-hover-cards",
  "title": "Hover cards",
  "description": "Link cards with four hover treatments: zoom, rise, tilt and trace.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/hover-cards.tsx",
      "content": "'use client';\n\nimport {\n  motion,\n  type MotionValue,\n  useMotionTemplate,\n  useMotionValue,\n  useSpring,\n  useTransform,\n} from 'motion/react';\nimport { useEffect, useRef, useState } from 'react';\n\nimport { cn } from '@/lib/utils';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\n\nexport type HoverEffect = 'zoom' | 'rise' | 'tilt' | 'trace';\n\nexport type HoverEffectCardImage = {\n  src: string;\n  alt: string;\n};\n\nexport type HoverEffectCardProps = Omit<\n  React.ComponentProps<'a'>,\n  'title' | 'children' | 'ref'\n> & {\n  effect: HoverEffect;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  meta?: React.ReactNode;\n  image?: HoverEffectCardImage;\n};\n\ntype EffectProps = Omit<HoverEffectCardProps, 'effect'>;\n\nconst LINK = [\n  'group/card relative block rounded-xl cursor-pointer outline-none',\n  'transition-[scale] duration-150 ease-(--ease-out-strong) active:scale-[0.97]',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n].join(' ');\n\nconst SURFACE = [\n  'relative isolate flex flex-col overflow-hidden rounded-xl bg-card text-card-foreground',\n  'hairline-ring ring-border shadow-[0_1px_2px_rgb(0_0_0/0.04)]',\n].join(' ');\n\nconst CARD = cn(LINK, SURFACE);\n\nconst CARD_RADIUS = 14;\n\nfunction CardImage({\n  image,\n  className,\n}: {\n  image: HoverEffectCardImage;\n  className?: string;\n}) {\n  return (\n    // eslint-disable-next-line @next/next/no-img-element -- registry items land in any React app, where next/image may not exist\n    <img\n      src={image.src}\n      alt={image.alt}\n      className={cn('size-full object-cover', className)}\n    />\n  );\n}\n\nfunction CardText({\n  title,\n  description,\n  meta,\n}: Pick<EffectProps, 'title' | 'description' | 'meta'>) {\n  return (\n    <div className='flex flex-col gap-1 p-4'>\n      <h3 className='text-foreground text-sm font-medium text-balance'>\n        {title}\n      </h3>\n      {description && (\n        <p className='text-muted-foreground text-[13px]'>{description}</p>\n      )}\n      {meta && (\n        <div className='text-muted-foreground mt-2 flex items-center gap-2 text-xs'>\n          {meta}\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst ZOOMING_IMAGE = [\n  'transition-[scale] duration-300 ease-(--ease-out-strong)',\n  'motion-safe:group-hover/card:scale-[1.04] motion-safe:group-hover/card:duration-500',\n].join(' ');\n\nfunction ZoomCard({\n  title,\n  description,\n  meta,\n  image,\n  className,\n  ...props\n}: EffectProps) {\n  return (\n    <a className={cn(CARD, className)} {...props}>\n      {image && (\n        <div className='relative aspect-[3/2] overflow-hidden'>\n          <CardImage image={image} className={ZOOMING_IMAGE} />\n        </div>\n      )}\n      <CardText title={title} description={description} meta={meta} />\n    </a>\n  );\n}\n\nconst RISING_PANEL = [\n  'surface-raised absolute inset-x-3 bottom-3 flex items-center gap-2 rounded-lg px-3 py-2 text-xs',\n  'transition-[opacity,translate] duration-150 ease-(--ease-out-strong)',\n  '[@media(hover:hover)]:translate-y-2 [@media(hover:hover)]:opacity-0',\n  'group-hover/card:translate-y-0 group-hover/card:opacity-100 group-hover/card:duration-250',\n  'group-focus-visible/card:translate-y-0 group-focus-visible/card:opacity-100',\n].join(' ');\n\nfunction RiseCard({\n  title,\n  description,\n  meta,\n  image,\n  className,\n  ...props\n}: EffectProps) {\n  return (\n    <a className={cn(CARD, className)} {...props}>\n      <div className='bg-foreground/[0.03] relative aspect-[4/3] overflow-hidden'>\n        {image && <CardImage image={image} />}\n        {meta && <div className={RISING_PANEL}>{meta}</div>}\n      </div>\n      <CardText title={title} description={description} />\n    </a>\n  );\n}\n\nconst TILT_DEGREES = 7;\n\nconst TILT_PERSPECTIVE = 900;\n\nconst TILT_SPRING = { stiffness: 260, damping: 32 };\n\nconst EDGE_MASK = {\n  mask: 'linear-gradient(black, black) content-box exclude, linear-gradient(black, black)',\n  WebkitMask:\n    'linear-gradient(black, black) content-box xor, linear-gradient(black, black)',\n};\n\nconst EDGE_LIGHT = [\n  'pointer-events-none absolute inset-0 rounded-[inherit] p-(--hairline) opacity-0',\n  'transition-opacity duration-150 group-hover/card:opacity-100 group-hover/card:duration-200',\n].join(' ');\n\nfunction isMouse(event: React.PointerEvent) {\n  return event.pointerType === 'mouse';\n}\n\nfunction tiltStyleOf(\n  prefersReducedMotion: boolean | null,\n  rotateX: MotionValue<number>,\n  rotateY: MotionValue<number>\n) {\n  if (prefersReducedMotion) {\n    return undefined;\n  }\n  return { rotateX, rotateY, transformPerspective: TILT_PERSPECTIVE };\n}\n\nfunction TiltCard({\n  title,\n  description,\n  meta,\n  image,\n  className,\n  onPointerMove,\n  onPointerLeave,\n  ...props\n}: EffectProps) {\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const pointerX = useMotionValue(0.5);\n  const pointerY = useMotionValue(0.5);\n  const smoothX = useSpring(pointerX, TILT_SPRING);\n  const smoothY = useSpring(pointerY, TILT_SPRING);\n  const rotateY = useTransform(smoothX, [0, 1], [-TILT_DEGREES, TILT_DEGREES]);\n  const rotateX = useTransform(smoothY, [0, 1], [TILT_DEGREES, -TILT_DEGREES]);\n  const lightX = useTransform(pointerX, [0, 1], [0, 100]);\n  const lightY = useTransform(pointerY, [0, 1], [0, 100]);\n  const edgeLight = useMotionTemplate`radial-gradient(160px circle at ${lightX}% ${lightY}%, color-mix(in oklab, var(--foreground) 45%, transparent), transparent 70%)`;\n  const tiltStyle = tiltStyleOf(prefersReducedMotion, rotateX, rotateY);\n\n  function followPointer(event: React.PointerEvent<HTMLAnchorElement>) {\n    onPointerMove?.(event);\n    if (!isMouse(event)) {\n      return;\n    }\n    const bounds = event.currentTarget.getBoundingClientRect();\n    const offsetX = event.clientX - bounds.left;\n    const offsetY = event.clientY - bounds.top;\n    const ratioX = offsetX / bounds.width;\n    const ratioY = offsetY / bounds.height;\n    pointerX.set(ratioX);\n    pointerY.set(ratioY);\n  }\n\n  function settle(event: React.PointerEvent<HTMLAnchorElement>) {\n    onPointerLeave?.(event);\n    pointerX.set(0.5);\n    pointerY.set(0.5);\n  }\n\n  return (\n    <a\n      className={cn(LINK, className)}\n      onPointerMove={followPointer}\n      onPointerLeave={settle}\n      {...props}\n    >\n      <motion.div className={cn(SURFACE, 'h-full')} style={tiltStyle}>\n        {image && (\n          <div className='relative aspect-[3/2] overflow-hidden'>\n            <CardImage image={image} />\n          </div>\n        )}\n        <CardText title={title} description={description} meta={meta} />\n        <motion.span\n          aria-hidden='true'\n          className={EDGE_LIGHT}\n          style={{ background: edgeLight, ...EDGE_MASK }}\n        />\n      </motion.div>\n    </a>\n  );\n}\n\nconst TRACE_STROKE = 1.5;\n\nconst TRACE_PATH = [\n  'fill-none stroke-primary [stroke-dasharray:1] [stroke-dashoffset:1]',\n  'transition-[stroke-dashoffset] duration-200 ease-(--ease-out-strong)',\n  'group-hover/card:[stroke-dashoffset:0] group-hover/card:duration-500 group-hover/card:ease-(--ease-in-out-strong)',\n  'group-focus-visible/card:[stroke-dashoffset:0]',\n].join(' ');\n\ntype Size = { width: number; height: number };\n\nfunction roundedOutlineOf({ width, height }: Size, radius: number) {\n  const inset = TRACE_STROKE / 2;\n  const r = radius - inset;\n  const right = width - inset;\n  const bottom = height - inset;\n  const left = inset;\n  const top = inset;\n  return [\n    `M ${left + r} ${top}`,\n    `H ${right - r}`,\n    `A ${r} ${r} 0 0 1 ${right} ${top + r}`,\n    `V ${bottom - r}`,\n    `A ${r} ${r} 0 0 1 ${right - r} ${bottom}`,\n    `H ${left + r}`,\n    `A ${r} ${r} 0 0 1 ${left} ${bottom - r}`,\n    `V ${top + r}`,\n    `A ${r} ${r} 0 0 1 ${left + r} ${top}`,\n    'Z',\n  ].join(' ');\n}\n\nfunction useElementSize<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [size, setSize] = useState<Size | null>(null);\n\n  useEffect(() => {\n    const element = ref.current;\n    if (!element) {\n      return;\n    }\n    const observer = new ResizeObserver(([entry]) => {\n      const box = entry.borderBoxSize[0];\n      setSize({ width: box.inlineSize, height: box.blockSize });\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n\n  return { ref, size };\n}\n\nfunction TraceCard({\n  title,\n  description,\n  meta,\n  image,\n  className,\n  ...props\n}: EffectProps) {\n  const { ref, size } = useElementSize<HTMLAnchorElement>();\n\n  return (\n    <a ref={ref} className={cn(CARD, className)} {...props}>\n      {image && (\n        <div className='relative aspect-[3/2] overflow-hidden'>\n          <CardImage image={image} />\n        </div>\n      )}\n      <CardText title={title} description={description} meta={meta} />\n      {size && (\n        <svg\n          aria-hidden='true'\n          className='pointer-events-none absolute inset-0 size-full'\n          viewBox={`0 0 ${size.width} ${size.height}`}\n        >\n          <path\n            d={roundedOutlineOf(size, CARD_RADIUS)}\n            pathLength={1}\n            strokeWidth={TRACE_STROKE}\n            className={TRACE_PATH}\n          />\n        </svg>\n      )}\n    </a>\n  );\n}\n\nexport function HoverEffectCard({ effect, ...props }: HoverEffectCardProps) {\n  if (effect === 'zoom') {\n    return <ZoomCard {...props} />;\n  }\n  if (effect === 'rise') {\n    return <RiseCard {...props} />;\n  }\n  if (effect === 'tilt') {\n    return <TiltCard {...props} />;\n  }\n  return <TraceCard {...props} />;\n}\n",
      "type": "registry:component",
      "target": "components/core/hover-cards.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}