Blocks

Feature Hover Preview

A feature list where hovering or focusing a row cross-fades the preview panel beside it, with a measured accent rail on the active row and media inline on touch.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUpRight, ImageOff } from "lucide-react"
import { cn } from "@/lib/utils"
import type { FeatureHoverPreviewData } from "./feature-hover-preview.contract"

/**
 * The only keyframe in the file: a slow drift on the decorative halo behind the
 * preview frame. React 19 hoists <style href> into <head> and dedupes by href,
 * so several sections on one page still yield a single copy.
 */
const KEYFRAMES = `@keyframes fhp-halo{0%,100%{transform:translate3d(-4%,-3%,0) scale(1.04)}50%{transform:translate3d(4%,3%,0) scale(1.16)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/feature-hover-preview.json

Prompt

Build a React + TypeScript + Tailwind "FeatureHoverPreview" block (lucide-react
ArrowUpRight + ImageOff) with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; eyebrow?; heading?;
    description?;
    items: { id, title, blurb, media: { src, alt }, href }[] }.
- Props = z.infer of the schema plus aspect ("video" | "wide" | "square" |
  "portrait", default "video"), mediaSide ("start" | "end", default "end"),
  onRetry?: () => void and className. Which row is active is internal state:
  the consumer ships data, the component owns the pointer/focus machine.
- Media is a URL, never a React node — this copy usually comes from a CMS.

Behavior
- Four first-class branches: loading (heading bar + four row skeletons + a
  frame skeleton, same anatomy as the real thing), empty (also used when
  status is "ready" with zero items), error (message + "Try again" only when
  onRetry exists), ready (list + preview panel).
- The active row is DERIVED, not stored as an index: keep an activeId and
  look it up in items each render, falling back to the first row. Item one is
  therefore active on first paint, and a row disappearing from the data can
  never leave the panel pointing at nothing.
- Pointer and keyboard are equal inputs: onPointerEnter sets the active row
  and onFocus does exactly the same. Nothing is hover-only.
- Rows are real <a href> from the contract. Swapping the preview is a side
  effect of pointing at a row, never a replacement for navigating.
- Preloading, paid for only where hover exists: each item's media is its own
  absolutely positioned layer in the frame, but the layers BEHIND the active
  one mount only once matchMedia confirms "(hover: hover) and (min-width:
  64rem)" — and only then does loading flip from "lazy" to "eager"
  (fetchPriority "high" for the active one, "low" for the rest). That hook's
  server snapshot is false, so the SSR HTML carries the active layer alone
  and a phone's preload scanner is never handed a stack of images for a panel
  that is display:none there. Once the stack is up the swap is an opacity
  change on bytes that already arrived, so the first hover cannot flash an
  empty frame. The inline copies under the rows stay loading="lazy" in both
  directions. Track loaded/failed per id; an <img> that finished before
  hydration is caught by checking node.complete in the ref callback, and a
  failed one renders an ImageOff placeholder captioned with its alt text.
- Active-row rail: one absolutely positioned bar inside the list, positioned
  from a real measurement (row rect minus list rect, so it is
  scroll-independent) and moved with translate3d + height. Re-measure in a
  layout effect when the active row or the layout props change, and subscribe
  a ResizeObserver to the list AND to every row (one row growing while
  another shrinks leaves the list height unchanged). Both must also key on
  the list *mounting* — a consumer can hold items at a stable reference while
  status flips loading → ready, and keying on items alone would leave the
  observer unattached and the rail invisible. Throttle the observer through
  requestAnimationFrame, bail out of the state update when the box is
  unchanged so the observer cannot feed itself, and on unmount disconnect the
  observer and cancel any pending frame.
- Coarse pointers (matchMedia "(pointer: coarse)", read through
  useSyncExternalStore so a mid-session change is honoured and the listener is
  removed on unmount): drop the side panel entirely and render each item's
  media inline under its own row, so no item is unreachable without hover.
  Taps stay navigation — nothing captures the gesture or blocks scrolling.
  Narrow viewports get the same inline layout via lg: variants alone.
- aria-labelledby on the <section> points at the heading id only in the
  branch that actually renders the heading — ready *with* items. The
  ready-with-zero-items case falls through to the empty panel, so pointing at
  that id there would dangle.
- Reduced motion (same matchMedia hook): the halo's drift animation is never
  applied — it stays as a static glow — and every transition is cancelled with
  motion-reduce:. The active media, the rail and all copy stay fully visible:
  turning motion off never turns content off.
- Announce politely: a visually hidden role="status" region reads
  "Previewing {title}: {alt}", rendered only where the panel actually exists.

Rendering & styling
- Semantic tokens only: bg-card / border / shadow-sm for the active row,
  text-muted-foreground for blurbs, bg-muted for the frame, and
  var(--chart-1..5) cycled by index for the rail, the row number and the
  decorative halo. No hex, no rgb(), no raw palette classes.
- Layout: one grid, lg:grid-cols-2 lg:items-center; mediaSide="start" moves
  the panel with lg:order-first while the list keeps DOM (and tab) order.
- Decorative layers (halo, rail, inline media, caption chip, inactive image
  layers) are aria-hidden; the visible layer keeps its alt. Rows get
  aria-current when active and a focus-visible ring. cn() merges className.

Customization levers
- Frame ratio and side: `aspect` (video / wide / square / portrait) and
  `mediaSide` — both are pure layout, no logic depends on them.
- Row density: the list is flex + gap-1 with py-4 rows; tighten to gap-0 +
  py-3 for eight or more features, or add a divide-y for a table feel.
- Accent: swap the var(--chart-N) cycle for a single token (e.g. var(--chart-1)
  or `bg-primary`) if you want one brand colour instead of five hues.
- Motion strength: the cross-fade is duration-500 on opacity + a 1.04 scale
  settle, the rail duration-500, the halo a 9s drift — dial each down (or
  drop the halo block entirely) for a calmer section.
- Swap trigger: to require an explicit click instead of hover, replace
  onPointerEnter with onClick + preventDefault and turn the rows into buttons
  with role="tab" — but then wire arrow-key roving, because tab semantics
  promise it.
- Media type: to preview video, extend the contract's media with `type` and
  render a muted, playsInline <video> in the active layer only.

Concepts

  • Focus parity — every pointer input has a keyboard twin: onPointerEnter and onFocus set the same state, so tabbing through the rows tells the same story as sweeping the mouse down them.
  • Preloaded layer stack — all media mounts up front as stacked layers, so a "swap" is only an opacity change; nothing is fetched at hover time and the first hover can never flash an empty frame. The stack behind the active layer only mounts (and only goes eager) on clients reporting (hover: hover) and (min-width: 64rem), so a phone never downloads a panel it will not paint.
  • Derived active item — the component stores an id and looks it up each render instead of caching an index, so reordered or removed data resolves to the first row rather than to a ghost.
  • Measured rail — the accent bar's position comes from getBoundingClientRect differences (scroll-independent), refreshed by a rAF-throttled ResizeObserver that bails out when the box is unchanged, so it can never feed itself a loop.
  • Coarse-pointer fallback — devices without hover get the media inline under each row instead of a panel frozen on item one; the tap keeps its only job, which is following the link.
  • Motion off, content on — reduced motion stops the halo drifting and cancels every transition, yet leaves the active media, the rail and all copy exactly where they were.

On This Page