Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:36Get Lifetime →
loopkit
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.