Galleries & Media

Focus Grid

An expanding-panels gallery — hovering a tile grows its column while the rest compress and dim, via animated grid track fractions.

Install

npx shadcn@latest add @paragon/focus-grid

focus-grid.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * FocusGrid — an expanding-panels gallery. Hovering (or focusing) a tile grows
 * its column while the rest compress, using animated `grid-template-columns`
 * fractions on a spring-like ease so one tile takes the spotlight and the
 * others gracefully yield. Each tile fades in a caption as it expands. This is
 * the one place CSS grid track sizing earns an eased transition (tracks aren't
 * width/height on the element).
 *
 * Pass `items`; otherwise deterministic gradient tiles render. Fully keyboard
 * operable via focus. Reduced motion drops the ease (instant reflow).
 */
export interface FocusGridProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  items?: React.ReactNode[];
  /** Number of generated tiles when `items` is omitted. */
  count?: number;
  /** Optional captions rendered over each tile when expanded. */
  captions?: string[];
  /** How much larger the active tile is vs. a resting tile (>1). */
  expand?: number;
  /** How dim the non-active tiles become when one is active (0–1). */
  dim?: number;
  /** Strip height, px. */
  height?: number;
}

const TILE_GRADIENTS = [
  "linear-gradient(135deg, oklch(0.72 0.16 250), oklch(0.5 0.2 285))",
  "linear-gradient(135deg, oklch(0.78 0.15 165), oklch(0.55 0.16 200))",
  "linear-gradient(135deg, oklch(0.82 0.16 70), oklch(0.64 0.19 38))",
  "linear-gradient(135deg, oklch(0.75 0.17 18), oklch(0.55 0.18 348))",
  "linear-gradient(135deg, oklch(0.76 0.13 300), oklch(0.52 0.16 262))",
  "linear-gradient(135deg, oklch(0.8 0.14 200), oklch(0.6 0.14 236))",
];

const CAPTIONS = [
  "Aurora",
  "Meadow",
  "Ember",
  "Rose",
  "Iris",
  "Tide",
];

export function FocusGrid({
  items,
  count = 5,
  captions,
  expand = 2.6,
  dim = 0.55,
  height = 240,
  className,
  style,
  ...props
}: FocusGridProps) {
  const [active, setActive] = React.useState<number | null>(null);

  const tiles = React.useMemo<React.ReactNode[]>(() => {
    if (items && items.length) return items;
    return Array.from({ length: count }, (_, i) => (
      <div
        key={i}
        className="size-full"
        style={{ background: TILE_GRADIENTS[i % TILE_GRADIENTS.length] }}
      />
    ));
  }, [items, count]);

  const cols = tiles
    .map((_, i) => (active === i ? expand : 1))
    .map((v) => `${v}fr`)
    .join(" ");

  return (
    <div
      className={cn("w-full", className)}
      style={style}
      onPointerLeave={() => setActive(null)}
      {...props}
    >
      <div
        className="grid gap-2 [transition-property:grid-template-columns] [transition-timing-function:var(--ease-out)] [transition-duration:450ms] motion-reduce:[transition-duration:0ms]"
        style={{
          gridTemplateColumns: cols,
          height,
        }}
      >
        {tiles.map((tile, i) => {
          const label = captions?.[i] ?? CAPTIONS[i % CAPTIONS.length];
          const isActive = active === i;
          const dimmed = active !== null && !isActive;
          return (
            <button
              key={i}
              type="button"
              aria-label={label}
              onPointerEnter={() => setActive(i)}
              onFocus={() => setActive(i)}
              onClick={() => setActive(i)}
              className="group relative min-w-0 overflow-hidden rounded-xl shadow-border outline-none [transition-property:opacity,filter] [transition-duration:300ms] [transition-timing-function:var(--ease-out)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              style={{
                opacity: dimmed ? dim : 1,
                filter: dimmed ? "saturate(0.7)" : "none",
              }}
            >
              {tile}
              {/* Caption reveals as the tile expands. */}
              <span
                aria-hidden
                className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end bg-gradient-to-t from-black/50 to-transparent p-3"
                style={{ height: "50%" }}
              >
                <span
                  className="translate-y-2 text-sm font-semibold text-white opacity-0 [transition-property:transform,opacity] [transition-duration:300ms] [transition-timing-function:var(--ease-out)] group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100"
                >
                  {label}
                </span>
              </span>
              {/* Compact index while resting. */}
              <span
                aria-hidden
                className="absolute left-2 top-2 text-xs font-semibold tabular-nums text-white/80 opacity-100 transition-opacity duration-200 group-hover:opacity-0 group-focus-visible:opacity-0"
              >
                {String(i + 1).padStart(2, "0")}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}