arc
componentlogo-carouselsource: stale · stale
Logo Carousel
Animated logo carousel with staggered cycling, image preloading, and reduced-motion support.
npx shadcn add "https://witharc.co/r/logo-carousel.json"free
This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.
Interaction
carousel
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
frameworknext
Files1 · source cached 9/28/2026, 6:12:51 PM
src/components/ui/logo-carousel.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
// ── Types ───────────────────────────────────────────────────────────
interface LogoDef {
name: string;
/** Image logo; absent for text-only slides (e.g. "& more"). */
src?: string;
url: string;
width: number;
/** Drawn box height; for text slides, the font size. */
height: number;
}
// ── Logo data ───────────────────────────────────────────────────────
/**
* Order is display order: a row shows `slots` logos at a time and cycles
* through the list in rounds, so the three-slot hero shows Automattic, Axiom,
* AgentMail, then Sim, Orchid, "& more".
*
* width/height are the drawn box at a nominal scale where the marks read as
* one size. Bounding boxes can't be equalized directly — each SVG's box spans
* a different mix of icon, ascenders and descenders — so the letterforms are
* matched instead and the box follows from the viewBox:
* box height = viewBox height × target letterform ÷ letterform in viewBox.
* Mixed-case wordmarks (AgentMail, Orchid, "& more") share a 27 cap height.
* The all-caps wordmarks (Automattic, Axiom) take 0.88 of that: a word set
* entirely in caps reads larger than its cap height. Sim, lowercase-only,
* matches on x-height at 0.78 of the cap (mixed-case x-heights here run
* ~0.72). Widths follow each viewBox's aspect ratio; "& more" is its measured
* advance at that font size.
*/
const LOGOS: LogoDef[] = [
{
name: "Automattic",
src: "/brands/automattic.svg",
url: "https://automattic.com",
width: 331,
height: 25.5,
},
{
name: "Axiom",
src: "/brands/axiom.svg",
url: "https://axiom.trade",
width: 148,
height: 30.7,
},
{
name: "AgentMail",
src: "/brands/agentmail.svg",
url: "https://agentmail.to",
width: 219,
height: 40,
},
{
name: "Sim",
src: "/brands/sim.svg",
url: "https://sim.ai",
width: 55,
height: 27,
},
{
name: "Orchid",
src: "/brands/orchid.svg",
url: "https://orchid.ai",
width: 172,
height: 41.3,
},
{ name: "& more", url: "/works", width: 126, height: 40 },
];
// ── Constants ───────────────────────────────────────────────────────
/** Gap between neighbouring logos, in the same nominal units as the boxes. */
const GAP = 50;
const INITIAL_DELAY = 2500;
const CYCLE_INTERVAL = 3000;
/** Seconds between neighbours starting their transition. */
const STAGGER = 0.15;
const TRANSITION = { duration: 0.5, ease: "easeInOut" } as const;
const LOGO_SRCS = LOGOS.flatMap((l) => (l.src ? [l.src] : []));
// ── Hooks ───────────────────────────────────────────────────────────
/** Returns responsive slot count: 1 on mobile, 2 on tablet, 3 on desktop. */
function useSlotCount(): number {
const [count, setCount] = useState(3);
useEffect(() => {
const mqMd = window.matchMedia("(min-width: 768px)");
const mqLg = window.matchMedia("(min-width: 1024px)");
const update = () => {
if (mqLg.matches) setCount(3);
else if (mqMd.matches) setCount(2);
else setCount(1);
};
update();
mqMd.addEventListener("change", update);
mqLg.addEventListener("change", update);
return () => {
mqMd.removeEventListener("change", update);
mqLg.removeEventListener("change", update);
};
}, []);
return count;
}
/** Resolves `true` once every image in `srcs` has loaded (or errored). */
function useImagesPreloaded(srcs: readonly string[]): boolean {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
Promise.all(
srcs.map(
(src) =>
new Promise<void>((resolve) => {
const img = new window.Image();
img.onload = () => resolve();
img.onerror = () => resolve();
img.src = src;
}),
),
).then(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, [srcs]);
return loaded;
}
/**
* Counts up every CYCLE_INTERVAL after an initial delay.
* Pauses when the tab is hidden so the cadence resumes where it left off.
*/
function useCycle(enabled: boolean): number {
const [step, setStep] = useState(0);
useEffect(() => {
if (!enabled) return;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let startedAt = 0;
let remaining = step === 0 ? INITIAL_DELAY : CYCLE_INTERVAL;
const schedule = (delay: number) => {
remaining = delay;
startedAt = Date.now();
timeoutId = setTimeout(() => setStep((s) => s + 1), delay);
};
const pause = () => {
if (timeoutId != null) {
clearTimeout(timeoutId);
timeoutId = null;
remaining = Math.max(0, remaining - (Date.now() - startedAt));
}
};
const onVisibilityChange = () => {
if (document.hidden) pause();
else schedule(remaining);
};
document.addEventListener("visibilitychange", onVisibilityChange);
if (!document.hidden) schedule(remaining);
return () => {
if (timeoutId != null) clearTimeout(timeoutId);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [enabled, step]);
return step;
}
// ── Styles ──────────────────────────────────────────────────────────
type CarouselVariant = "muted" | "dark" | "light";
const variantStyles: Record<
CarouselVariant,
{ base: string; interactive: string }
> = {
muted: {
base: "brightness-0 opacity-40 dark:invert",
interactive: "transition-opacity duration-200 hover:opacity-60",
},
dark: {
base: "brightness-0 dark:invert",
interactive: "transition-opacity duration-200 opacity-80 hover:opacity-100",
},
/* White logos for blue/dark banners */
light: {
base: "brightness-0 invert opacity-55",
interactive: "transition-opacity duration-200 hover:opacity-90",
},
};
/* Text slides ("& more") mirror each variant's filtered-image color. */
const textVariantStyles: Record<
CarouselVariant,
{ base: string; interactive: string }
> = {
muted: {
base: "text-black opacity-40 dark:text-white",
interactive: "transition-opacity duration-200 hover:opacity-60",
},
dark: {
base: "text-black dark:text-white",
interactive: "transition-opacity duration-200 opacity-80 hover:opacity-100",
},
light: {
base: "text-white opacity-55",
interactive: "transition-opacity duration-200 hover:opacity-90",
},
};
// ── Motion ──────────────────────────────────────────────────────────
/* Rows only orchestrate: neighbours start STAGGER apart, on the way in and out. */
const rowVariants = {
enter: { transition: { staggerChildren: STAGGER } },
exit: { transition: { staggerChildren: STAGGER } },
};
const slideVariants = {
initial: { y: 20, opacity: 0, filter: "blur(8px)" },
enter: { y: 0, opacity: 1, filter: "blur(0px)", transition: TRANSITION },
exit: { y: -20, opacity: 0, filter: "blur(8px)", transition: TRANSITION },
};
const reducedSlideVariants = {
initial: { opacity: 0 },
enter: { opacity: 1, transition: TRANSITION },
exit: { opacity: 0, transition: TRANSITION },
};
// ── Slide ───────────────────────────────────────────────────────────
function Slide({
logo,
unit,
disableLinks,
variant,
}: {
logo: LogoDef;
/** cqw per nominal unit. */
unit: number;
disableLinks?: boolean;
variant: CarouselVariant;
}) {
const reducedMotion = useReducedMotion();
const styles = variantStyles[variant];
const textStyles = textVariantStyles[variant];
const content = logo.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo.src}
alt={disableLinks ? logo.name : ""}
width={Math.round(logo.width)}
height={Math.round(logo.height)}
className={cn("h-auto", styles.base, !disableLinks && styles.interactive)}
style={{ width: `${(logo.width * unit).toFixed(3)}cqw` }}
/>
) : (
<span
className={cn(
"whitespace-nowrap font-normal leading-none tracking-[-0.01em]",
textStyles.base,
!disableLinks && textStyles.interactive,
)}
style={{ fontSize: `${(logo.height * unit).toFixed(3)}cqw` }}
>
{logo.name}
</span>
);
return (
<motion.div
role="group"
aria-roledescription="slide"
aria-label={logo.name}
variants={reducedMotion ? reducedSlideVariants : slideVariants}
className="flex shrink-0 items-center will-change-[filter] backface-hidden"
>
{disableLinks ? (
content
) : logo.src ? (
<Link
href={`${logo.url}?ref=arc`}
target="_blank"
rel="noopener noreferrer"
aria-label={`${logo.name} (opens in new tab)`}
className="flex"
>
{content}
</Link>
) : (
<Link
href={logo.url}
aria-label={`${logo.name} — view all works`}
className="flex"
>
{content}
</Link>
)}
</motion.div>
);
}
// ── LogoCarousel ────────────────────────────────────────────────────
/**
* Fills its container and cycles through LOGOS a round at a time. Every logo
* is sized as a share of the container width (cqw) with one scale for the
* whole set — the one that lets the widest round fit — so ink heights stay
* uniform across rounds and nothing ever reflows. Narrower rounds are shorter
* than the container; align them with a `justify-*` class on `className`
* (rows are centered by default). Cap the size with a `max-w-*`.
*/
export function LogoCarousel({
className,
disableLinks,
variant = "muted",
slots,
}: {
className?: string;
disableLinks?: boolean;
variant?: CarouselVariant;
/** Logos per round on every breakpoint (overrides responsive counting). */
slots?: number;
}) {
const allLoaded = useImagesPreloaded(LOGO_SRCS);
const responsiveSlots = useSlotCount();
const slotCount = Math.min(LOGOS.length, slots ?? responsiveSlots);
const rounds = useMemo(
() =>
Array.from({ length: Math.ceil(LOGOS.length / slotCount) }, (_, i) =>
LOGOS.slice(i * slotCount, (i + 1) * slotCount),
),
[slotCount],
);
// One scale for every round: the widest row, gaps included, spans 100cqw.
const unit = useMemo(() => {
const widest = Math.max(
...rounds.map(
(round) =>
round.reduce((sum, l) => sum + l.width, 0) + GAP * (round.length - 1),
),
);
return 100 / widest;
}, [rounds]);
// Rows share one height — the tallest box in the set — so the row never
// moves when a round with shorter marks takes over.
const rowHeight = Math.max(...LOGOS.map((l) => l.height)) * unit;
const step = useCycle(allLoaded && rounds.length > 1);
const round = step % rounds.length;
return (
<motion.div
role="region"
aria-roledescription="carousel"
aria-label="Companies we've partnered with"
initial={{ opacity: 0 }}
animate={{ opacity: allLoaded ? 1 : 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
// The query container: cqw resolves against this box, so it needs a
// definite width from its parent. Rows shrink-wrap their logos and this
// flex box positions them, which is what makes `justify-*` work.
className={cn(
"relative flex w-full justify-center [container-type:inline-size]",
className,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={round}
variants={rowVariants}
initial="initial"
animate="enter"
exit="exit"
className="flex items-center"
style={{
columnGap: `${(GAP * unit).toFixed(3)}cqw`,
height: `${rowHeight.toFixed(3)}cqw`,
}}
>
{rounds[round].map((logo) => (
<Slide
key={logo.name}
logo={logo}
unit={unit}
disableLinks={disableLinks}
variant={variant}
/>
))}
</motion.div>
</AnimatePresence>
</motion.div>
);
}