Display

Split Screen Hover

Full-height panels that share one row and rebalance around the pointer — the hovered or focused panel takes the width and opens its copy, the rest compress and dim.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { cn } from "@/lib/utils"

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
const COARSE_POINTER = "(pointer: coarse)"

/** No panel is emphasised: every panel keeps an equal share and shows its full content. */
const BALANCED = -1

/** A pointer sweeping across the panels must not fire one live-region message per panel. */
const ANNOUNCE_DELAY = 220

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "SplitScreenHover" component
(lucide-react for the CTA arrow; no other dependencies).

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>;
  the root IS the flex row, so a consumer's className controls its height
  and rounding.
- panels: SplitScreenPanel[] where SplitScreenPanel =
  { id: string (React key + the identity handed back on select);
    title: string (always visible, even compressed);
    eyebrow?: string; description?: string; actionLabel?: string;
    href?: string; image?: string (decorative photo);
    accent?: 1 | 2 | 3 | 4 | 5 (which --chart-* token tints the panel,
    defaults to the panel's position) }.
- expandRatio?: number (default 2.4) — the width weight of the emphasised
  panel against 1 for every other, so it takes ratio / (ratio + n - 1) of
  the row. Clamp to >= 1; exactly 1 turns the rebalance off. Guard it with
  Number.isFinite and fall back to the default: a ratio derived from an
  input or an API can arrive as NaN or Infinity, and since the number is
  written into flex-grow through a custom property, an invalid one is
  computed as 0 — which with basis-0 collapses the emphasised panel to
  nothing.
- defaultIndex?: number (default 0) — the panel that is expanded while
  nothing is hovered or focused. Out of range (use -1) means "balanced":
  equal widths, every panel fully open.
- duration?: number ms (default 520) and easing?: string (default
  cubic-bezier(0.22, 1, 0.36, 1)).
- onPanelSelect?: (panel, index) => void — click handler for panels that
  have no href.
- No contract file and no data states: this is a layout/interaction leaf,
  not a data-driven view. Its meaningful variants are the resting panel,
  the emphasised panel, the balanced rest, coarse pointer and reduced
  motion.

Behavior
- Two pieces of state, never merged: hovered (number | null) and focused
  (number | null). emphasis = coarse ? BALANCED : hovered ?? focused ??
  restIndex, where restIndex is defaultIndex normalised into range and
  BALANCED is -1. Deriving instead of storing means a changing
  defaultIndex prop can never disagree with the current emphasis, and no
  reset effect is needed.
- The newer intent wins, and recency is made real rather than assumed:
  pointerenter latches hover, and focus CLEARS that latch before setting
  itself (onFocus => { setHovered(null); setFocused(i) }). Without the
  clear, a cursor sitting motionless over a panel since page load would
  outrank a fresh Tab and leave the focus ring on a compressed panel with
  its copy clipped to 0fr. Mouse users are unaffected: a pointer that
  actually moves into a panel fires pointerenter and takes the row back.
  Leaving the row with the pointer falls back to whatever still has focus,
  and only then to the resting panel.
- Enter/leave live at different levels on purpose: pointerenter and focus
  sit on each panel, pointerleave and blur sit on the ROOT. A root-level
  blur that checks currentTarget.contains(relatedTarget) is what stops
  panel-to-panel tabbing from flashing through the resting panel for one
  frame between blur and focus.
- Focus is not an afterthought: the panel itself is the interactive
  element — <a href> when the panel has one, otherwise <button
  type="button" onClick={() => onPanelSelect?.(panel, index)}>. One tab
  stop per panel, no interactive element nested inside another, and the
  focus-visible ring lands on the surface that visibly reacts. Never
  render a hoverable panel that a keyboard cannot reach.
- Coarse pointers (matchMedia "(pointer: coarse)"): emphasis is forced to
  BALANCED, so every panel stays open and tappable and nothing is hidden
  behind a hover that touch cannot perform. Also ignore pointerenter with
  pointerType === "touch" — a tap on a hybrid laptop emits it too.
- prefers-reduced-motion: duration becomes 0ms and easing becomes linear,
  so the layout JUMPS between states. Reduced motion must not disable the
  component or leave copy stuck invisible — the same content is reachable,
  it just arrives without a tween.
- Both media queries are read through useSyncExternalStore over
  matchMedia with a `false` server snapshot: SSR-safe, and an OS setting
  flipped mid-session re-renders instead of being read once at mount. The
  subscription removes its own listener on unmount.
- Politeness: a visually hidden role="status" region names the panel that
  just opened, but only after a ~220ms settle timer, so sweeping the
  pointer across three panels announces the destination rather than every
  panel on the way. The timer is cleared on every change and on unmount,
  and a ref holding the last announced title keeps a re-render from
  re-announcing the same state.
- No window/document listeners, no rAF loop, no observers: hover and focus
  are discrete events, so there is nothing to throttle and nothing to leak.
  Resist adding a pointermove parallax here — it would put a per-frame
  listener on a component whose whole job is one layout number.

Rendering & styling
- Root: relative isolate flex w-full flex-col overflow-hidden rounded-xl
  border bg-card md:h-[32rem] md:flex-row. duration and easing are written
  once onto the root as inherited custom properties (--ssh-duration,
  --ssh-ease) and every animated layer just wears
  duration-[var(--ssh-duration)] ease-[var(--ssh-ease)].
- Width: each panel gets --ssh-grow (ratio or 1) inline plus
  md:basis-0 md:[flex-grow:var(--ssh-grow)] and
  transition-[flex-grow]. Both the basis and the grow are md:-gated, which
  is the entire responsive story: below the breakpoint the panels are
  ordinary stacked blocks.
- The single styling rule that keeps that story honest: BASE CLASSES ARE
  THE EXPANDED STATE, and every compressed style is written as an md:
  variant applied when !expanded. A narrow viewport therefore cannot
  inherit a single collapse style, and no panel can be hidden on a phone.
- Layers per panel, all aria-hidden, in paint order: decorative <img
  alt="" aria-hidden> (object-cover, md:scale-105 when expanded,
  md:saturate-50 when not) -> legibility scrim bg-gradient-to-t
  from-background via-background/75 to-background/20 -> accent halo, a
  radial-gradient with color-mix(in oklab, var(--chart-N) 42%, transparent)
  anchored at the TOP so it can never eat text contrast -> a bg-background
  dim wash at opacity 0, md:opacity-50 when compressed -> a 4px accent
  rule on the bottom edge that scales in from origin-left. Content comes
  last and is `relative`, which is what floats it above the absolute
  layers without a z-index arms race.
- Content: eyebrow (uppercase tracking-[0.2em] text-muted-foreground),
  the title, then description + actionLabel chip inside a grid
  grid-rows-[1fr] wrapper that flips to
  md:grid-rows-[0fr] md:opacity-0 when compressed. Height must actually
  collapse, not just fade: a quarter-width panel would otherwise reserve
  room for copy that has re-wrapped into six lines. The whole content
  block also scales to md:scale-[0.92] from origin-bottom-left.
- The headline is sized against the PANEL, not the viewport: the panel
  carries @container (container-type: inline-size) and the title gets
  fontSize: clamp(1.125rem, 6cqi, 1.875rem) through a style object (a math
  function in an arbitrary text-[...] class risks being read as a colour)
  plus break-words as the floor under one very long word. A fixed 30px
  headline in a quarter-width panel is clipped by the panel's own
  overflow-hidden; sizing off the container makes the type shrink and grow
  with the rebalance on the same curve, with no breakpoint steps.
- Semantic tokens only: bg-card / bg-background / text-foreground /
  text-muted-foreground / border / ring / var(--chart-1..5) — no hex, no
  palette classes, so the panel inherits any theme and dark mode for free.
- Valid HTML in both branches: a <button>'s content model is phrasing
  content, so every structural wrapper inside a panel is a <span> carrying
  a display class (flex / grid / block), never a <div> or <p>. Only the
  <a> branch — whose content model is transparent — gets the real <h3>;
  the button branch renders the same styled text as a <span>.
- Accessibility: the panel's own text is its accessible name (keep
  descriptions to one line), decorative layers and the 01/02/03 index are
  aria-hidden, cursor-pointer appears only when there is an href or an
  onPanelSelect, focus-visible:ring-2 ring-inset ring-ring, and data-state
  "expanded" | "collapsed" is exposed on every panel for tests and for
  consumer styling.

Customization levers
- Feel: expandRatio is the character knob — 1.4-1.8 reads as a nudge that
  keeps every panel legible, 3-4 reads as a takeover that turns the others
  into spines. Pair a big ratio with a longer duration (800-900ms) and a
  small one with 200-260ms; swapping easing for a spring-ish
  cubic-bezier(0.34, 1.56, 0.64, 1) adds overshoot on the width. Above ~3,
  keep titles to one short word: a spine that is 60px of usable width can
  only ever hold one.
- Type: the clamp floor (1.125rem) is what a compressed panel gets and the
  ceiling (1.875rem) what a full-bleed one gets — raise the floor for
  short titles, raise the ceiling for a two-panel row that has the room.
- Rest state: defaultIndex picks the panel the page argues for; -1 stays
  neutral until the visitor commits. For an auto-tour, drive an "emphasis"
  prop from a timer in the parent instead of adding one inside.
- Density: change md:h-[32rem] through className (md:h-svh for a true
  full-bleed section), swap the panel padding p-6/md:p-8, and drop eyebrow
  or actionLabel per panel — every sub-block is optional and the layout
  reflows without empty gaps.
- Colour: accent per panel selects --chart-1..5; point it at --primary
  instead for a single-brand row, or delete the halo and rule entirely and
  let the photographs carry the colour.
- Media: pass image for photography, or leave it out and the token halo
  becomes the whole background. To animate the photo instead of the panel,
  move the scale to a Ken Burns keyframe gated behind motion-reduce.
- Direction: for a vertical split (stacked rows that rebalance in height),
  swap flex-row for flex-col above md and move the grow onto the block
  axis — the state machine is unchanged.
- Breakpoint: the md: prefix is the collapse point; move it to lg: for
  wordier panels that need more room before they can share a row.

Concepts

  • Width as the only state — one integer (emphasis) drives every layer: the flex weight, the dim wash, the saturation, whether the detail block has any height. Nothing is stored twice, so the panels can never disagree about which one is open.
  • Focus mirrors hover — the panel itself is the link or button, so tabbing produces exactly what hovering produces. The newer intent wins: entering a panel with the pointer latches hover, and focusing a panel drops that latch, so a parked cursor can never outrank a fresh Tab. Focus catches the fall when the pointer leaves, and the resting panel catches what is left.
  • Resting emphasis — a split screen at rest is not neutral by default: defaultIndex lets the page argue for one panel before anyone interacts, and -1 is the explicit opt-out into a balanced row.
  • Collapse by height, not by opacity — compressed copy animates grid-template-rows from 1fr to 0fr instead of just fading, because a quarter-width panel would otherwise keep reserving space for a paragraph that has re-wrapped into six lines.
  • Base is the open state — every compression style is an md: variant, so a phone (or any viewport below the breakpoint) is physically incapable of inheriting a hidden panel; the row degrades into a plain stack.
  • Hover consent — coarse pointers get a balanced row rather than a hover they cannot perform, and reduced motion gets the same layout with a 0ms duration: the interaction is removed or de-animated, never the content.

On This Page