# Blog List Hover Preview — React Blog Section

> A typographic list of posts; a floating cover card trails the cursor on a spring, tilts with its speed and flips to the hovered post's artwork.

- Category: [Blog Sections](https://pixelpapa.com/components/blog.md)
- Tags: blog, list, hover, preview, cursor
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/blog/blog-list-hover-preview

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/blog-list-hover-preview.json"
```

The file is written to `components/pixelpapa/blog-list-hover-preview.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUpRight } from "lucide-react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "motion/react";
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { cn } from "@/lib/utils";

type Post = { title: string; category: string; date: string; minutes: number; tones: [string, string]; glyph: number };

const defaultPosts: Post[] = [
  { title: "The case against the hamburger menu, revisited", category: "Navigation", date: "12 Nov", minutes: 8, tones: ["var(--chart-1)", "var(--chart-3)"], glyph: 0 },
  { title: "Type scales are a lie we tell developers", category: "Typography", date: "03 Nov", minutes: 6, tones: ["var(--chart-4)", "var(--chart-3)"], glyph: 1 },
  { title: "What 400 onboarding flows taught us about step two", category: "Research", date: "24 Oct", minutes: 12, tones: ["var(--chart-2)", "var(--chart-5)"], glyph: 2 },
  { title: "Colour contrast in the dark: beyond 4.5 to 1", category: "Accessibility", date: "15 Oct", minutes: 9, tones: ["var(--chart-3)", "var(--chart-2)"], glyph: 3 },
  { title: "Animating layout without layout thrash", category: "Motion", date: "02 Oct", minutes: 7, tones: ["var(--chart-5)", "var(--chart-1)"], glyph: 4 },
];

const CARD = { w: 300, h: 210 };

function Artwork({ post }: { post: Post }) {
  return (
    <div className="absolute inset-0" style={{ background: `linear-gradient(145deg, ${post.tones[0]}, ${post.tones[1]})` }}>
      <svg viewBox="0 0 300 210" className="size-full" fill="none" stroke="white" aria-hidden>
        {post.glyph === 0 && [70, 105, 140].map((y, i) => <path key={y} d={`M80 ${y}h${140 - i * 40}`} strokeWidth="14" strokeLinecap="round" strokeOpacity={0.9 - i * 0.25} />)}
        {post.glyph === 1 && (
          <g fill="white" stroke="none" fontFamily="Georgia, serif" fontStyle="italic">
            <text x="52" y="150" fontSize="150" fillOpacity="0.95">A</text>
            <text x="158" y="150" fontSize="92" fillOpacity="0.6">a</text>
            <text x="216" y="150" fontSize="52" fillOpacity="0.35">a</text>
          </g>
        )}
        {post.glyph === 2 && [0, 1, 2, 3].map((i) => (
          <g key={i}>
            <circle cx={62 + i * 58} cy="105" r="17" fill="white" fillOpacity={i === 1 ? 1 : 0.3} stroke="none" />
            {i < 3 && <path d={`M${84 + i * 58} 105h14`} strokeWidth="3" strokeOpacity="0.5" strokeLinecap="round" />}
          </g>
        ))}
        {post.glyph === 3 && (
          <>
            <circle cx="150" cy="105" r="62" fill="white" fillOpacity="0.18" stroke="none" />
            <path d="M150 43a62 62 0 0 1 0 124Z" fill="white" stroke="none" />
          </>
        )}
        {post.glyph === 4 && [0, 1, 2, 3, 4].map((i) => (
          <rect key={i} x={56 + i * 26} y={52 + i * 12} width="84" height="84" rx="16" strokeWidth="2" strokeOpacity={0.3 + i * 0.17} />
        ))}
      </svg>
      <span className="absolute bottom-3 left-3 rounded-full bg-black/30 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
        {post.category} · {post.minutes} min
      </span>
    </div>
  );
}

export default function BlogListHoverPreview({ posts = defaultPosts }: { posts?: Post[] }) {
  const root = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const [active, setActive] = useState(1);
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const sx = useSpring(x, { stiffness: 170, damping: 20, mass: 0.6 });
  const sy = useSpring(y, { stiffness: 170, damping: 20, mass: 0.6 });
  const rotate = useTransform(useVelocity(sx), [-1500, 0, 1500], [-14, 0, 14], { clamp: true });

  // Dock the card on the right until the pointer takes over, so the section never looks empty.
  useEffect(() => {
    const r = root.current?.getBoundingClientRect();
    if (!r) return;
    x.jump(r.width - CARD.w - 150);
    y.jump(96);
  }, [x, y]);

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    const r = root.current?.getBoundingClientRect();
    if (!r || e.pointerType === "touch") return;
    x.set(Math.min(Math.max(e.clientX - r.left + 28, 0), r.width - CARD.w));
    y.set(e.clientY - r.top - CARD.h / 2);
  }

  return (
    <section className="w-full overflow-hidden bg-background px-6 py-20">
      <div className="mx-auto max-w-5xl">
        <div className="flex items-baseline justify-between">
          <h2 className="text-sm font-medium tracking-[0.18em] text-muted-foreground uppercase">Journal</h2>
          <p className="text-sm text-muted-foreground">Autumn 2026 · {posts.length} essays</p>
        </div>

        <div ref={root} onPointerMove={onPointerMove} className="relative mt-6">
          <ul className="border-t border-foreground">
            {posts.map((post, i) => (
              <li key={post.title} className="border-b border-border">
                <a
                  href="#"
                  onPointerEnter={() => setActive(i)}
                  onFocus={() => setActive(i)}
                  className="group grid grid-cols-[2.5rem_1fr_auto] items-center gap-4 py-6 sm:grid-cols-[3.5rem_1fr_8rem_4rem_2rem]"
                >
                  <span className="font-mono text-xs text-muted-foreground">{String(i + 1).padStart(2, "0")}</span>
                  <span
                    className={cn(
                      "text-2xl font-medium tracking-tight transition-[color,transform] duration-300 sm:text-3xl",
                      active === i ? "translate-x-2 text-foreground" : "text-foreground/40",
                    )}
                  >
                    {post.title}
                  </span>
                  <span className="hidden text-sm text-muted-foreground sm:block">{post.category}</span>
                  <time className="hidden text-sm text-muted-foreground tabular-nums sm:block">{post.date}</time>
                  <ArrowUpRight
                    className={cn("size-5 transition-all duration-300", active === i ? "text-primary" : "-translate-x-1 translate-y-1 text-transparent")}
                  />
                </a>
              </li>
            ))}
          </ul>

          <motion.div
            aria-hidden
            style={{ x: sx, y: sy, rotate: reduce ? 0 : rotate, width: CARD.w, height: CARD.h }}
            className="pointer-events-none absolute top-0 left-0 z-10 hidden overflow-hidden rounded-2xl shadow-2xl ring-1 shadow-black/25 ring-white/20 md:block"
          >
            <AnimatePresence initial={false}>
              <motion.div
                key={active}
                initial={{ y: "100%" }}
                animate={{ y: 0 }}
                exit={{ y: "-30%", opacity: 0 }}
                transition={{ type: "spring", stiffness: 260, damping: 28 }}
                className="absolute inset-0"
              >
                <Artwork post={posts[active]} />
              </motion.div>
            </AnimatePresence>
          </motion.div>
        </div>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "Blog List Hover Preview" component from PixelPapa UI to this project.

What it is: A typographic list of posts; a floating cover card trails the cursor on a spring, tilts with its speed and flips to the hovered post's artwork.
Category: Blog Sections. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/blog-list-hover-preview.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/blog-list-hover-preview.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/blog-list-hover-preview" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More blog sections

- [Blog Category Filter](https://pixelpapa.com/components/blog/blog-category-filter.md): Filter chips with a sliding active pill and live counts; the post grid reflows with shared layout animation as cards pop in and out.
- [Blog Changelog Feed](https://pixelpapa.com/components/blog/blog-changelog-feed.md) (Pro): A versioned changelog with a sticky date rail, colour-coded tags you can filter by, and entries that collapse out of the timeline with layout animation.
- [Blog Featured Plus Grid](https://pixelpapa.com/components/blog/blog-featured-plus-grid.md) (Pro): One large featured story beside its drawn cover, followed by a three-up grid; covers zoom and a reading arrow slides in on hover.
- [Blog Magazine Layout](https://pixelpapa.com/components/blog/blog-magazine-layout.md): A newspaper-style front page: masthead, a serif lead story with drawn cover art, a column of hairline-ruled briefs and a numbered most-read list.
