Logo Grid Hover Reveal
Logos lift on hover to reveal a Learn More link.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/logo-grid.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/logo-grid/logo-grid.tsx"use client"; import { useId, useState, type ComponentProps, type ReactNode } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { ArrowRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { spring } from "@/lib/motion"; export interface LogoGridItem { /** Company name; the cell is announced as "Learn more about {name}". */ name: string; /** Monochrome logo, sized by font-size (about 24–28px tall at the default size). */ logo: ReactNode; /** Where the cell links to. */ href: string; } export interface LogoGridProps extends Omit<ComponentProps<"section">, "children"> { /** Logos to show, each linking somewhere. */ items: LogoGridItem[]; /** Small line centered above the grid, e.g. "Trusted by fast-growing startups". */ eyebrow?: string; /** Text of the revealed link. @default "Learn More" */ linkLabel?: string; /** Columns on wider containers (2 below a 34rem container). @default 4 */ columns?: 2 | 3 | 4 | 5; /** "grid": hairline grid (default). "plain": no lines, more air. "fill": a white highlight glides between cells. @default "grid" */ variant?: "grid" | "plain" | "fill"; } const COLUMNS = { 2: "@[34rem]:grid-cols-2", 3: "@[34rem]:grid-cols-3", 4: "@[34rem]:grid-cols-4", 5: "@[34rem]:grid-cols-5", } as const; export function LogoGrid({ items, eyebrow, linkLabel = "Learn More", columns = 4, variant = "grid", className, ...props }: LogoGridProps) { const reduceMotion = useReducedMotion() ?? false; const id = useId(); const [active, setActive] = useState<number | null>(null); // Filler cells complete the last row at each breakpoint, so the hairlines never show a gap. const mobileFill = (2 - (items.length % 2)) % 2; const wideFill = (columns - (items.length % columns)) % columns; const fillers = Math.max(mobileFill, wideFill); return ( <section className={cn("@container w-full bg-surface py-12", className)} {...props}> {eyebrow ? <p className="mb-8 px-4 text-center text-sm text-muted-foreground">{eyebrow}</p> : null} <LayoutGroup id={id}> <ul onPointerLeave={() => setActive(null)} className={cn( "grid grid-cols-2", COLUMNS[columns], variant === "grid" && "gap-px border-y border-border bg-border", variant === "plain" && "gap-x-4 gap-y-6 px-4", variant === "fill" && "gap-2 px-3", )} > {items.map((item, index) => ( <li key={item.name} className={cn(variant === "grid" && "bg-surface")}> <a href={item.href} onPointerEnter={() => setActive(index)} onFocus={() => setActive(index)} onBlur={() => setActive(null)} className={cn( "group relative flex h-[108px] items-center justify-center overflow-hidden outline-none", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", variant === "plain" && "h-[120px] rounded-xl", variant === "fill" && "rounded-2xl", )} > {variant === "fill" ? ( <AnimatePresence> {active === index ? ( <motion.span layoutId="logo-grid-fill" aria-hidden="true" className="absolute inset-0 rounded-2xl bg-background shadow-sm ring-1 ring-border" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.15 } }} transition={reduceMotion ? { duration: 0 } : { ...spring.smooth, opacity: { duration: 0.15 } }} /> ) : null} </AnimatePresence> ) : null} <span className="sr-only">Learn more about {item.name}</span> <span aria-hidden="true" className={cn( "relative text-[22px] text-foreground transition-transform duration-200 ease-out [&>img]:h-7 [&>img]:w-auto [&>svg]:h-7 [&>svg]:w-auto", // Enter springs up with a slight overshoot; leaving is a quicker ease-out. "group-hover:-translate-y-2.5 group-hover:duration-500 group-hover:ease-[cubic-bezier(0.34,1.4,0.64,1)]", "group-focus-visible:-translate-y-2.5 group-focus-visible:duration-500 group-focus-visible:ease-[cubic-bezier(0.34,1.4,0.64,1)]", "[@media(hover:none)]:-translate-y-2.5 motion-reduce:!translate-y-0 motion-reduce:transition-none", )} > {item.logo} </span> <span aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-[18px] flex justify-center"> <span className={cn( "flex translate-y-1.5 items-center gap-1 text-[13px] font-medium text-foreground opacity-0 blur-[4px]", "transition-[opacity,transform,filter] duration-150 ease-out", "group-hover:translate-y-0 group-hover:opacity-100 group-hover:blur-none group-hover:duration-300", "group-focus-visible:translate-y-0 group-focus-visible:opacity-100 group-focus-visible:blur-none group-focus-visible:duration-300", "[@media(hover:none)]:translate-y-0 [@media(hover:none)]:opacity-100 [@media(hover:none)]:blur-none", "motion-reduce:translate-y-0 motion-reduce:blur-none", )} > {linkLabel} <ArrowRight className={cn( "size-3.5 transition-transform duration-150 ease-out", "group-hover:translate-x-0.5 group-hover:delay-200 group-hover:duration-300", "group-focus-visible:translate-x-0.5 group-focus-visible:delay-200 group-focus-visible:duration-300", "motion-reduce:!translate-x-0", )} /> </span> </span> </a> </li> ))} {Array.from({ length: fillers }, (_, i) => ( <li key={`filler-${i}`} aria-hidden="true" className={cn(variant === "grid" && "bg-surface", i < mobileFill ? "block" : "hidden", i < wideFill ? "@[34rem]:block" : "@[34rem]:hidden")} /> ))} </ul> </LayoutGroup> </section> ); }
Usage
import { LogoGrid } from "@/components/ui/logo-grid/logo-grid";
<LogoGrid
eyebrow="Trusted by fast-growing startups"
items={[{ name: "Acme", logo: <AcmeLogo />, href: "/customers/acme" }]}
columns={4}
variant="grid" // "grid" | "plain" | "fill"
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | LogoGridItem[] | – | Logos to show, each linking somewhere. |
eyebrow | string | – | Small line centered above the grid, e.g. "Trusted by fast-growing startups". |
linkLabel | string | "Learn More" | Text of the revealed link. |
columns | 2 | 3 | 4 | 5 | 4 | Columns on wider containers (2 below a 34rem container). |
variant | "grid" | "plain" | "fill" | "grid" | "grid": hairline grid (default). "plain": no lines, more air. "fill": a white highlight glides between cells. |
Also accepts every native <section> attribute, including ref and className.
- Hover and focus are pure CSS transitions on transform, opacity and filter, so only the hovered cell does any work. The fill variant adds a shared-layout highlight with Motion.
- Hairlines come from a 1px grid gap over the border color, with filler cells completing the last row at each breakpoint. Columns switch at a 34rem container width, not the viewport.
- Pass logos that use currentColor (or monochrome SVGs and images); the cell sets them about 28px tall.
Accessibility
- Each cell is one link announced as "Learn more about {name}"; the logo and the revealed label are decorative.
- Keyboard focus (focus-visible) plays the same lift-and-reveal as hover, with a ring inside the cell.
- On touch screens (hover: none) the label is always visible, since there's no hover to reveal it.
- With reduced motion the logo stays put and the label only fades in, with no slide, blur or arrow nudge.