freeNew254 lines · framer-motion
Logo Grid Hover
Grayscale marks that wake up on hover. A full-width 'Built on / Works with' grid of abstract, made-up wordmarks drawn as inline SVG. Marks sit muted and grayscale by default, then lift and bloom into colour on hover and keyboard focus.
- Use it for
- A social-proof strip under a hero, or an integrations/partners section, where you want motion-led trust signals without any real logos.
- Themes
- Light + dark
- License
- Commercial use
Live
Built on
Works with the stack you already run
tsx
"use client";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width "Social proof" grid of abstract, made-up wordmarks. Every mark is
* drawn inline as SVG (no real or invented-to-resemble-real logos). The grid sits
* muted/grayscale by default; hovering or tab-focusing a mark animates it to full
* colour with a small lift via framer-motion, while the colour crossfade itself is
* a plain CSS transition on two stacked layers (robust across themes, no colour
* interpolation through motion values).
*/
export type MarkGlyph =
| "halo"
| "shard"
| "braid"
| "pivot"
| "crest"
| "ledge"
| "coil"
| "fork"
| "tilt"
| "nest";
export interface LogoMark {
name: string;
glyph: MarkGlyph;
/** Which design-system accent lights up on hover/focus. */
accent?: "signal" | "info" | "lime" | "ok" | "warn";
}
export interface LogoGridHoverProps {
eyebrow?: string;
title?: string;
marks?: LogoMark[];
/** Controls the minimum tile width used by the responsive grid. */
tileSize?: "compact" | "cozy" | "spacious";
/** When false, marks are already shown in full colour (no grayscale default state). */
accentOnHover?: boolean;
/** Seconds between each tile's entrance; respects reduced motion. */
staggerDelay?: number;
className?: string;
}
const DEFAULT_MARKS: LogoMark[] = [
{ name: "Vonn", glyph: "halo", accent: "signal" },
{ name: "Iska", glyph: "shard", accent: "info" },
{ name: "Tezra", glyph: "braid", accent: "lime" },
{ name: "Orun", glyph: "pivot", accent: "ok" },
{ name: "Kelvo", glyph: "crest", accent: "warn" },
{ name: "Nyra", glyph: "ledge", accent: "signal" },
{ name: "Plexa", glyph: "coil", accent: "info" },
{ name: "Dravo", glyph: "fork", accent: "lime" },
{ name: "Somu", glyph: "tilt", accent: "ok" },
{ name: "Yelix", glyph: "nest", accent: "warn" },
];
const ACCENT_CLASS: Record<NonNullable<LogoMark["accent"]>, string> = {
signal: "text-signal",
info: "text-info",
lime: "text-lime",
ok: "text-ok",
warn: "text-warn",
};
const TILE_SIZE: Record<NonNullable<LogoGridHoverProps["tileSize"]>, { min: string; pad: string; icon: string }> = {
compact: { min: "7.5rem", pad: "px-3 py-4", icon: "size-6" },
cozy: { min: "9rem", pad: "px-4 py-5", icon: "size-7" },
spacious: { min: "11rem", pad: "px-5 py-6", icon: "size-8" },
};
function Glyph({ kind, className }: { kind: MarkGlyph; className?: string }) {
const common = {
viewBox: "0 0 32 32",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (kind) {
case "halo":
return (
<svg {...common} className={className} aria-hidden>
<circle cx="16" cy="16" r="9" />
<circle cx="16" cy="16" r="3" fill="currentColor" stroke="none" />
<path d="M16 3v5M16 24v5M3 16h5M24 16h5" />
</svg>
);
case "shard":
return (
<svg {...common} className={className} aria-hidden>
<path d="M16 3 26 16 16 29 6 16Z" />
<path d="M16 3v26" />
</svg>
);
case "braid":
return (
<svg {...common} className={className} aria-hidden>
<path d="M5 9c5 0 5 14 11 14s6-14 11-14" />
<path d="M5 23c5 0 5-14 11-14s6 14 11 14" />
</svg>
);
case "pivot":
return (
<svg {...common} className={className} aria-hidden>
<path d="M8 24 16 6l8 18" />
<path d="M11.5 17h9" />
<circle cx="16" cy="6" r="2" fill="currentColor" stroke="none" />
</svg>
);
case "crest":
return (
<svg {...common} className={className} aria-hidden>
<path d="M4 22c4-10 8-14 12-14s8 4 12 14" />
<path d="M9 22c2.5-6 5-9 7-9s4.5 3 7 9" />
</svg>
);
case "ledge":
return (
<svg {...common} className={className} aria-hidden>
<path d="M4 12h24M4 20h17" />
<path d="M4 12V7h11v5M4 20v5h24v-5" />
</svg>
);
case "coil":
return (
<svg {...common} className={className} aria-hidden>
<path d="M16 16c3.5 0 5.5-2.2 5.5-5.4 0-3-2.4-5.1-5.9-5.1-4.1 0-7.6 2.8-7.6 7.5s4 9 9 9 9.5-3.8 9.5-9.3" />
</svg>
);
case "fork":
return (
<svg {...common} className={className} aria-hidden>
<path d="M9 5v9a7 7 0 0 0 7 7v8" />
<path d="M23 5v9a7 7 0 0 1-7 7" />
</svg>
);
case "tilt":
return (
<svg {...common} className={className} aria-hidden>
<rect x="7" y="7" width="18" height="18" rx="3" transform="rotate(-12 16 16)" />
<circle cx="16" cy="16" r="2.2" fill="currentColor" stroke="none" />
</svg>
);
case "nest":
return (
<svg {...common} className={className} aria-hidden>
<circle cx="11" cy="12" r="5" />
<circle cx="21" cy="12" r="5" />
<path d="M8 23c0-4 3.6-6 8-6s8 2 8 6" />
</svg>
);
}
}
const tileVariants: Variants = {
hidden: { opacity: 0, scale: 0.88, y: 10 },
show: { opacity: 1, scale: 1, y: 0, transition: { duration: 0.45, ease: [0.16, 1, 0.3, 1] } },
};
export function LogoGridHover({
eyebrow = "Built on",
title = "Works with the stack you already run",
marks = DEFAULT_MARKS,
tileSize = "cozy",
accentOnHover = true,
staggerDelay = 0.05,
className,
}: LogoGridHoverProps) {
const reduce = useReducedMotionSafe();
const size = TILE_SIZE[tileSize];
const stagger: Variants = {
hidden: {},
show: { transition: { staggerChildren: reduce ? 0 : staggerDelay } },
};
return (
<section className={cn("w-full bg-background py-16 sm:py-20", className)}>
<div className="mx-auto max-w-6xl px-4 text-center sm:px-6 lg:px-10">
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(1.6rem,3.4vw,2.3rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">
{title}
</h2>
</div>
<motion.ul
initial={reduce ? false : "hidden"}
animate="show"
variants={stagger}
className="mx-auto mt-10 grid max-w-6xl list-none gap-3 px-4 sm:px-6 lg:px-10"
style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${size.min}, 1fr))` }}
>
{marks.map((mark, i) => {
const accentClass = ACCENT_CLASS[mark.accent ?? "signal"];
return (
<motion.li key={`${mark.name}-${i}`} variants={reduce ? undefined : tileVariants} className="min-w-0">
<motion.button
type="button"
aria-label={`${mark.name} — abstract mark`}
whileHover={reduce ? undefined : { y: -4, scale: 1.045 }}
whileFocus={reduce ? undefined : { y: -4, scale: 1.045 }}
transition={{ type: "spring", stiffness: 320, damping: 22 }}
className={cn(
"group relative flex w-full flex-col items-center justify-center gap-2 rounded-xl border border-border bg-card/60 text-muted-foreground outline-none transition-colors duration-200 hover:border-foreground/30 focus-visible:border-foreground/30",
size.pad,
)}
>
<span className="relative inline-flex items-center justify-center">
<Glyph
kind={mark.glyph}
className={cn(
size.icon,
accentOnHover
? "opacity-55 grayscale transition-all duration-300 group-hover:opacity-0 group-focus-visible:opacity-0"
: cn(accentClass, "opacity-100"),
)}
/>
{accentOnHover && (
<Glyph
kind={mark.glyph}
className={cn(
size.icon,
accentClass,
"absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100",
)}
/>
)}
</span>
<span className="font-display text-xs font-semibold tracking-[-0.01em] text-foreground/70 transition-colors duration-200 group-hover:text-foreground group-focus-visible:text-foreground">
{mark.name}
</span>
</motion.button>
</motion.li>
);
})}
</motion.ul>
</section>
);
}
export default LogoGridHover;
How it behaves
- →Ten abstract glyphs (no letterforms, no resemblance to real or invented company logos) are drawn inline as SVG, each paired with a short coined one/two-syllable name rendered as styled text.
- →Default state: the glyph is grayscale and at reduced opacity; a second full-colour copy of the same glyph is stacked on top at opacity 0.
- →On hover and on focus-visible (keyboard tabbing), the grayscale layer fades out and the colour layer fades in via a CSS opacity transition on two stacked SVG layers, while framer-motion drives a spring lift (y: -4, scale: 1.045) on the tile itself through whileHover/whileFocus.
- →On mount, tiles fade/scale in with a staggered framer-motion entrance (staggerChildren); staggerDelay controls the gap between tiles.
- →The grid uses CSS Grid repeat(auto-fit, minmax(...)) so it reflows from 2 columns at 360px up to 6+ columns on very wide screens with no JS breakpoint logic.
- →accentOnHover=false renders every mark already in its accent colour (no grayscale default), useful inside a section that already carries its own colour story.
- →prefers-reduced-motion disables the entrance stagger and the hover spring lift; the colour crossfade (a plain CSS transition) still runs since it carries no motion-sickness risk.
Accessibility
- ✓Each mark is a real `<button>` with an `aria-label` naming the mark, so it is reachable and announced when tabbing through the grid.
- ✓Hover and focus states share the same visual treatment (`group-hover` / `group-focus-visible`), so keyboard users see the identical colour/lift feedback mouse users get.
- ✓Decorative SVG glyphs are `aria-hidden`; the visible name text is the only content read alongside the button's label.
- ✓Focus is shown with the design system's default focus-visible ring plus the same lift as hover, so focus is never visually silent.
- ✓Honours prefers-reduced-motion.