curio.
no. 21 · projects

Hover preview

Plain rows with a screenshot that follows the cursor.

Install

npx shadcn@latest add https://curio-components.vercel.app/r/project-hover-preview.json

First curio component? Also add @import "./curio.css"; to your global CSS. See installation.

Usage

import { ProjectHoverPreview } from '@/components/curio/projects/project-hover-preview'

const projects = [
  { name: 'lumen', description: 'A calm reading app.', url: 'https://...', year: '2026', tags: ['next.js'] },
  { name: 'pocketlog', description: 'A cli for daily logs.', url: 'https://...', year: '2025', tags: ['rust'] },
]

export function Work() {
  return <ProjectHoverPreview items={projects} />
}

Shows item.image while hovering; without one it shows a lettered tile.

Props

PropTypeDefaultDescription
itemsProject[]-Name, description, url, year, tags?, image?, status?

Source

This is exactly what the command copies into your project.

components/curio/projects/project-hover-preview.tsx
'use client'

import { useState } from 'react'
import type { Project } from '../lib/types'
import { external, ProjectThumb } from './project-parts'

// plain rows; a screenshot follows the cursor while you hover one
export function ProjectHoverPreview({ items }: { items: Project[] }) {
  const [active, setActive] = useState<Project | null>(null)
  const [pos, setPos] = useState({ x: 0, y: 0 })

  return (
    <div onPointerMove={(e) => setPos({ x: e.clientX, y: e.clientY })} onPointerLeave={() => setActive(null)}>
      {items.map((p) => (
        <a
          key={p.name}
          {...external(p)}
          onPointerEnter={() => setActive(p)}
          className="flex items-baseline gap-3 border-b py-2.5 text-[15px] transition-colors hover:text-[var(--curio-accent)]"
          style={{ borderColor: 'var(--curio-border)', color: 'var(--curio-fg)' }}
        >
          <span className="shrink-0">{p.name}</span>
          <span className="min-w-0 truncate text-[13px]" style={{ color: 'var(--curio-muted)' }}>
            {p.tags?.join(', ')}
          </span>
          <span className="ml-auto shrink-0 text-xs" style={{ color: 'var(--curio-muted)' }}>
            {p.year}
          </span>
        </a>
      ))}
      <div
        className="pointer-events-none fixed z-50 h-[150px] w-[240px] overflow-hidden rounded-lg border shadow-[0_20px_40px_-12px_rgba(0,0,0,0.35)] transition-[opacity,transform] duration-200"
        style={{
          left: pos.x + 18,
          top: pos.y + 18,
          opacity: active ? 1 : 0,
          transform: active ? 'scale(1) rotate(-2deg)' : 'scale(0.9) rotate(-2deg)',
          borderColor: 'var(--curio-border)',
          background: 'var(--curio-surface)',
        }}
      >
        {active && <ProjectThumb project={active} className="h-full w-full" />}
      </div>
    </div>
  )
}
components/curio/projects/project-parts.tsx
import type { Project } from '../lib/types'

export function ProjectThumb({ project: p, className = '' }: { project: Project; className?: string }) {
  if (p.image) {
    // eslint-disable-next-line @next/next/no-img-element
    return <img src={p.image} alt="" className={`object-cover object-top ${className}`} />
  }
  return (
    <span
      className={`flex items-center justify-center text-[28px] font-medium ${className}`}
      style={{ background: 'var(--curio-accent)', color: 'var(--curio-on-accent)' }}
    >
      {p.name[0]}
    </span>
  )
}

export function ProjectTags({ project: p, className = '' }: { project: Project; className?: string }) {
  if (!p.tags?.length) return null
  return (
    <span className={`flex flex-wrap gap-1.5 text-[11px] ${className}`} style={{ color: 'var(--curio-muted)' }}>
      {p.tags.map((t) => (
        <span key={t} className="rounded-md border px-1.5 py-px" style={{ borderColor: 'var(--curio-border)' }}>
          {t}
        </span>
      ))}
    </span>
  )
}

export const external = (p: Project) => ({ href: p.url, target: '_blank', rel: 'noopener noreferrer' })

export function Arrow() {
  return (
    <svg viewBox="0 0 10 10" width={10} height={10} fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
      <path d="M2.5 7.5 7.5 2.5M3.5 2.5h4v4" />
    </svg>
  )
}