Mikkel Manniche
Dansk English Deutsch

Surfaces

Hover highlight

A grid of cards where one highlight glides to the card under the pointer or keyboard focus.

Preview

Usage

hover-highlight-demo.tsx
import { HoverHighlight } from '@/components/hover-highlight'

const items = [
  {
    title: 'Websites',
    text: 'Fast pages that are easy to find on Google.',
    href: '#websites',
  },
  {
    title: 'Shops',
    text: 'Sell online with payments and stock in one place.',
    href: '#shops',
  },
  {
    title: 'Bookings',
    text: 'Customers book a time without calling.',
    href: '#bookings',
  },
  {
    title: 'Hosting',
    text: 'Updates, backups and monitoring included.',
    href: '#hosting',
  },
  {
    title: 'SEO',
    text: 'Structured data, speed and local search.',
    href: '#seo',
  },
  {
    title: 'Support',
    text: 'A real person who answers the same day.',
    href: '#support',
  },
]

export default function HoverHighlightDemo() {
  return <HoverHighlight items={items} className="lg:grid-cols-2" />
}

Source

hover-highlight.tsx
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type HoverHighlightItem = {
  title: string
  text: ReactNode
  href?: string
}

export type HoverHighlightProps = {
  items: HoverHighlightItem[]
  className?: string
}

/**
 * A grid of cards where one soft highlight glides to the card under the pointer or with keyboard focus.
 * Under reduced motion the highlight appears on the card without sliding.
 */
export function HoverHighlight({ items, className }: HoverHighlightProps) {
  const [on, setOn] = useState<number | null>(null)
  const group = useId()

  return (
    <MotionConfig reducedMotion="user">
      <LayoutGroup id={group}>
        <ul className={cn('grid gap-2 sm:grid-cols-2 lg:grid-cols-3', className)} onPointerLeave={() => setOn(null)}>
          {items.map((item, i) => {
            const Tag = item.href ? 'a' : 'div'
            return (
              <li key={item.title} className="relative" onPointerEnter={() => setOn(i)}>
                <AnimatePresence>
                  {on === i && (
                    <motion.span
                      layoutId="highlight"
                      aria-hidden
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{
                        opacity: 0,
                        transition: { duration: 0.15, delay: 0.1 },
                      }}
                      transition={{
                        type: 'spring',
                        stiffness: 420,
                        damping: 36,
                      }}
                      className="absolute inset-0 rounded-2xl bg-muted"
                    />
                  )}
                </AnimatePresence>
                <Tag
                  {...(item.href ? { href: item.href } : {})}
                  onFocus={() => setOn(i)}
                  onBlur={() => setOn(null)}
                  className="relative grid h-full gap-1 rounded-2xl border bg-card/60 p-5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <span className="font-medium">{item.title}</span>
                  <span className="text-sm text-muted-foreground">{item.text}</span>
                </Tag>
              </li>
            )
          })}
        </ul>
      </LayoutGroup>
    </MotionConfig>
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/hover-highlight.json

With the registry added (components.json)

npx shadcn@latest add @manniche/hover-highlight

Dependencies

Files

  • hover-highlight.tsx

Manniche Lab · open source

Free tools for AI agents and web development.