An expanding-panels gallery — hovering a tile grows its column while the rest compress and dim, via animated grid track fractions.
npx shadcn@latest add @paragon/focus-grid"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>
);
}