Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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

PropTypeDefaultDescription
items*LogoGridItem[]–Logos to show, each linking somewhere.
eyebrowstring–Small line centered above the grid, e.g. "Trusted by fast-growing startups".
linkLabelstring"Learn More"Text of the revealed link.
columns2 | 3 | 4 | 54Columns 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.