Website Sections
FeatureGrid
A grid of features divided by hairlines. A single light follows the pointer and picks out the lines and cells near it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/feature-grid.tsx
"use client";
import { useId, useRef, type ElementType, type PointerEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
export type FeatureGridItem = {
title: string;
text: string;
icon?: ReactNode;
/** Makes the whole cell a link. */
href?: string;
};
type FeatureGridProps = {
items: FeatureGridItem[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Smallest cell width in pixels. Sets how many columns fit. */
minWidth?: number;
/** Colour of the light and the icons. */
color?: string;
/** Cell colour. Match it to the page behind. */
cell?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
/* FeatureGrid: a grid of features divided by hairlines. A single light follows the pointer and picks out the lines and cells near it. */
export function FeatureGrid({
items,
title,
eyebrow,
subtitle,
minWidth = 240,
color = "#a78bfa",
cell = "#0b0a14",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: FeatureGridProps) {
const reduce = useReducedMotion();
const id = useId();
const grid = useRef<HTMLDivElement>(null);
const move = (e: PointerEvent) => {
const box = grid.current;
if (!box || e.pointerType !== "mouse") return;
const rect = box.getBoundingClientRect();
box.style.setProperty("--fg-x", `${e.clientX - rect.left}px`);
box.style.setProperty("--fg-y", `${e.clientY - rect.top}px`);
box.style.setProperty("--fg-on", "1");
};
const leave = () => grid.current?.style.setProperty("--fg-on", "0");
// The cells are opaque, so the layer under them shows only in the 1px gaps. The layer over them tints the cells.
const light = (radius: number, tint: string) => ({
opacity: "var(--fg-on, 0)",
background: `radial-gradient(${radius}px circle at var(--fg-x, 50%) var(--fg-y, 50%), ${tint}, transparent 70%)`,
});
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`}>
{title && (
<header className="mx-auto mb-8 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<div
ref={grid}
onPointerMove={move}
onPointerLeave={leave}
className="relative mx-auto max-w-6xl overflow-hidden rounded-2xl border border-current/15 bg-current/15"
>
<div aria-hidden className="pointer-events-none absolute inset-0 transition-opacity duration-300" style={light(260, color)} />
<ul className="relative grid gap-px" style={{ gridTemplateColumns: `repeat(auto-fit, minmax(min(${minWidth}px, 100%), 1fr))` }}>
{items.map((item, i) => {
const body = (
<>
{item.icon && (
<span className="mb-3 block w-fit" style={{ color }}>
{item.icon}
</span>
)}
<span className="block font-semibold">{item.title}</span>
<span className="mt-1 block text-sm text-pretty opacity-70">{item.text}</span>
</>
);
return (
<li key={item.title} style={{ backgroundColor: cell }}>
<motion.div
className="h-full"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14 }}
whileInView={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.5, delay: Math.min(i, 8) * 0.05, ease: [0.16, 1, 0.3, 1] }}
>
{item.href ? (
<a href={item.href} className="block h-full p-5">
{body}
</a>
) : (
<div className="h-full p-5">{body}</div>
)}
</motion.div>
</li>
);
})}
</ul>
<div aria-hidden className="pointer-events-none absolute inset-0 transition-opacity duration-300" style={light(240, `${color}1f`)} />
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/feature-grid.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/feature-grid-demo.tsx
import { Gauge, Layers, Lock, Palette, Sparkles, Wand2 } from "lucide-react"; import { FeatureGrid } from "@/components/ui/feature-grid"; const FEATURES = [ { title: "Fast by default", text: "Pages load before anyone notices.", icon: <Gauge className="size-5" /> }, { title: "Layers that stack", text: "Mix blocks without fighting the CSS.", icon: <Layers className="size-5" /> }, { title: "Private by design", text: "Your data stays on your side.", icon: <Lock className="size-5" /> }, { title: "Your colours", text: "One token file changes the lot.", icon: <Palette className="size-5" /> }, { title: "Small touches", text: "Motion that helps, never shouts.", icon: <Sparkles className="size-5" /> }, { title: "Quick fixes", text: "Undo anything, at any time.", icon: <Wand2 className="size-5" /> }, ]; export default function FeatureGridExample() { return ( <div className="h-full overflow-y-auto"> <FeatureGrid title="Built for busy teams" items={FEATURES} minWidth={300} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | FeatureGridItem[] | โ | Each has title and text, plus optional icon and href. |
| title | string | โ | Heading above the grid. |
| eyebrow | string | โ | Small line above the title. |
| subtitle | string | โ | Line under the title. |
| minWidth | number | 240 | Smallest cell width in pixels. Sets how many columns fit. |
| color | string | "#a78bfa" | Colour of the light and the icons. |
| cell | string | "#0b0a14" | Cell colour. Match it to the page behind. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |