Collection 01 / Interactive
Spotlight Bento.
An editorial feature grid with a restrained cursor spotlight that stays calm for keyboard and touch.
Live playground
The details make it
Good ideas, with room to breathe.
01 / A clear point of view
Know what makes you, you.
A considered identity gives every detail a place to belong.
02 / Made with intention
The little things add up.
From the first hello to the final hover, every moment feels considered.
03 / Ready to go further
Beautiful, then usable.
A smooth handoff keeps the good work moving into the real world.
04 / Built around real people
A thoughtful way to move forward.
Make a useful promise clear, then give people a confident next step.
import { SpotlightBento } from "@/components/gear5/spotlight-bento";
export default function Example() {
return <SpotlightBento accent={"#d9fc87"} />;
}Make it yours.
npx shadcn@latest add https://gear5-ui.vercel.app/r/spotlight-bento.jsonCopies editable source into your project. Setup guide · React and Tailwind CSS 4 required.
Put it to work.
import { SpotlightBento, type BentoFeature } from "@/components/gear5/spotlight-bento";
const features: BentoFeature[] = [
{ id: "direction", eyebrow: "01 / POINT OF VIEW",
title: "Make it unmistakably yours.",
description: "A considered identity that travels across every detail.",
size: "wide", visual: <div aria-hidden="true">Your brand artwork</div> },
{ id: "launch", eyebrow: "02 / READY TO SHIP",
title: "From idea to open doors.",
description: "Show people what changes when they choose your product.",
href: "/get-started" },
];
export default function Story() {
return <SpotlightBento features={features} title="Good work, made visible." />;
}Good to know.
- Use standard, wide, and tall sizes to give important stories more space.
- Artwork is a caller-supplied React node, so add lightweight SVG or existing UI without a runtime dependency.
- Pointer lighting stops for touch and reduced-motion preferences; every feature remains ordinary readable content.
Source & props.
Installer-ready imports. For manual installation, copy this file into components/gear5/spotlight-bento.tsx and add the helpers below at their shown paths.
"use client";
import {
useEffect,
useId,
useRef,
type CSSProperties,
type ReactNode,
type PointerEvent,
} from "react";
import { sanitizeHref } from "@/lib/gear5/sanitize";
export interface BentoFeature {
id: string;
eyebrow: string;
title: string;
description: string;
size?: "standard" | "wide" | "tall";
visual?: ReactNode;
href?: string;
linkLabel?: string;
}
const defaults: BentoFeature[] = [
{
id: "direction",
eyebrow: "01 / A clear point of view",
title: "Know what makes you, you.",
description: "A considered identity gives every detail a place to belong.",
size: "wide",
visual: (
<div
aria-hidden="true"
className="flex items-center gap-3 overflow-hidden"
>
<span className="font-serif text-6xl tracking-[-.08em]">Aa</span>
<span className="h-px flex-1 bg-current/30" />
<span className="font-mono text-xs">FIND YOUR FORM</span>
</div>
),
},
{
id: "details",
eyebrow: "02 / Made with intention",
title: "The little things add up.",
description:
"From the first hello to the final hover, every moment feels considered.",
size: "tall",
visual: (
<div aria-hidden="true" className="flex gap-2">
{["#d9fc87", "#d2ccef", "#e8cdb5"].map((color, index) => (
<span
key={color}
style={{
backgroundColor: color,
transform: `translateY(${index * -5}px)`,
}}
className="size-10 rounded-xl"
/>
))}
</div>
),
},
{
id: "delivery",
eyebrow: "03 / Ready to go further",
title: "Beautiful, then usable.",
description:
"A smooth handoff keeps the good work moving into the real world.",
visual: (
<div
aria-hidden="true"
className="flex items-center gap-2 font-mono text-sm"
>
<span className="rounded-full border border-current/20 px-3 py-2">
Brief
</span>
<span className="text-[var(--g5-accent)]">→</span>
<span className="rounded-full border border-current/20 px-3 py-2">
Launch
</span>
</div>
),
},
{
id: "people",
eyebrow: "04 / Built around real people",
title: "A thoughtful way to move forward.",
description:
"Make a useful promise clear, then give people a confident next step.",
visual: (
<div
aria-hidden="true"
className="flex items-center gap-3 font-mono text-xs text-white/60"
>
<span className="size-8 rounded-full border border-white/20 bg-[var(--g5-accent)]/20" />
MADE FOR PEOPLE
</div>
),
},
];
export interface SpotlightBentoProps {
features?: BentoFeature[];
title?: string;
eyebrow?: string;
accent?: string;
className?: string;
}
/** Unequal content tiles with a restrained mouse spotlight and static touch fallback. */
export function SpotlightBento({
features = defaults,
title = "Good ideas, with room to breathe.",
eyebrow = "The details make it",
accent = "#d9fc87",
className = "",
}: SpotlightBentoProps) {
const id = useId();
const reducedMotion = useRef(false);
useEffect(() => {
const preference = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
);
if (!preference) return;
const update = () => {
reducedMotion.current = preference.matches;
};
update();
preference.addEventListener?.("change", update);
return () => preference.removeEventListener?.("change", update);
}, []);
return (
<section
style={{ "--g5-accent": accent } as CSSProperties}
aria-labelledby={`${id}-title`}
className={`rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
>
<p className="font-mono text-[11px] tracking-[.12em] text-[#b4b6af] uppercase">
{eyebrow}
</p>
<h2
id={`${id}-title`}
className="mt-3 max-w-2xl text-3xl font-medium tracking-[-.05em] sm:text-4xl"
>
{title}
</h2>
{features.length === 0 ? (
<p className="mt-6 text-sm text-[#b4b6af]">
Add a feature to start your grid.
</p>
) : (
<div className="mt-8 grid auto-rows-[minmax(12rem,auto)] gap-3 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => {
const Tag = feature.href ? "a" : "article";
const linkProps = feature.href
? { href: sanitizeHref(feature.href) }
: {};
return (
<Tag
key={feature.id}
{...linkProps}
onPointerMove={(event: PointerEvent<HTMLElement>) => {
if (event.pointerType === "touch" || reducedMotion.current)
return;
const box = event.currentTarget.getBoundingClientRect();
event.currentTarget.style.setProperty(
"--g5-x",
`${event.clientX - box.left}px`,
);
event.currentTarget.style.setProperty(
"--g5-y",
`${event.clientY - box.top}px`,
);
}}
className={`group relative isolate flex min-w-0 flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-[#181a1c] p-6 transition-[border-color] hover:border-white/30 focus-visible:border-[var(--g5-accent)] motion-reduce:transition-none ${
feature.href ? "focus-visible:outline-offset-4" : ""
} ${
feature.size === "wide"
? "sm:col-span-2"
: feature.size === "tall"
? "sm:row-span-2"
: ""
}`}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100 motion-reduce:transition-none"
style={{
backgroundImage:
"radial-gradient(340px circle at var(--g5-x, 50%) var(--g5-y, 50%), color-mix(in srgb, var(--g5-accent) 12%, transparent), transparent 80%)",
}}
/>
<div>
<p className="font-mono text-[10px] text-[var(--g5-accent)] uppercase">
{feature.eyebrow}
</p>
<h3 className="mt-5 text-2xl font-medium tracking-[-.04em]">
{feature.title}
</h3>
<p className="mt-3 max-w-sm text-sm leading-7 text-[#b4b6af]">
{feature.description}
</p>
</div>
<div className="mt-8 flex min-h-12 items-end justify-between gap-4">
{feature.visual && <div>{feature.visual}</div>}
{feature.href && (
<span className="ms-auto inline-flex items-center gap-2 text-xs text-[#c2c4bd]">
{feature.linkLabel ?? "Explore"}
<span
aria-hidden="true"
className="text-[var(--g5-accent)]"
>
↗
</span>
</span>
)}
</div>
</Tag>
);
})}
</div>
)}
</section>
);
}
Helper: sanitize → lib/gear5/sanitize.ts
/**
* Security primitive: neutralise the one class of URL that turns "render a
* link" into "run arbitrary script" — `javascript:` and other executable
* schemes reaching an `href`/`src` from data the app did not author itself
* (a CMS field, a query param, a user's own profile link).
*/
const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:", "sms:"]);
/**
* Returns `url` unchanged if its scheme is safe to navigate to, or `"#"`
* otherwise. Relative URLs (no scheme) are always safe and pass through.
*/
export function sanitizeHref(url: string): string {
const trimmed = url.trim();
if (!trimmed) return "#";
// Browsers discard tabs and newlines when parsing a URL scheme.
const schemeProbe = trimmed.replace(/[\t\r\n]/g, "");
if (!/^[a-z][a-z0-9+.-]*:/i.test(schemeProbe)) return trimmed;
try {
const scheme = new URL(trimmed, "https://example.com").protocol;
return SAFE_SCHEMES.has(scheme) ? trimmed : "#";
} catch {
return "#";
}
}
VERIFIED: AXE FIXTURES / SERVER RENDER / SOURCE SCANS / 3300 B GZIP CEILING
Test your own content and screen-reader flows before shipping.