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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/feature-hover-preview.jsonPrompt
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:
onPointerEnterandonFocusset 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
getBoundingClientRectdifferences (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.
