Visual
Marquee
Scrolling row of names that pauses on hover and respects reduced motion.
npx -y github:anwarlemu/agentcard-brand-cli#release add marqueeAgents buy at Amazon, Walmart, Target and more.
Amazon, Walmart, Target, Best Buy, Home Depot, Lowe’s, Macy’s, Wayfair, Staples, Kohl’s, B&H Photo, DoorDash, Good Eggs, Locale, Flights.
Usage
Use for a merchant, customer or partner list. Use at most one per page.
- Logos in color on the line
- A fast loop (under 30s a lap)
- Two running lines on one page
- Text a reader must read to act
Example
import { Marquee } from "@/components/visuals/marquee";
import { Surface } from "@/lib/surface";
const MERCHANTS = ["Amazon", "Walmart", "Target", "Best Buy", "Home Depot", "Lowe’s", "Macy’s", "Wayfair", "Staples", "Kohl’s", "B&H Photo", "DoorDash", "Good Eggs", "Locale", "Flights"];
export function Site() {
return (
<div className="grid gap-6">
<Surface ground="blue" className="overflow-hidden rounded-panel py-10">
<Marquee items={MERCHANTS} label="Agents buy at Amazon, Walmart, Target and more." />
</Surface>
<div className="overflow-hidden rounded-panel border border-hairline py-10">
<Marquee items={MERCHANTS} ground="white" duration={60} />
</div>
</div>
);
}Source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
// The running line (agentcard.sh, .v7-foot-line): names in the display face, each followed by a small star, between two
// rules, sliding sideways forever. Two copies of the line sit end to end and the track moves by one copy's width, so the
// loop has no seam. It is linear and slow (48s a lap), stops under the pointer so a name can be read, and stands still
// with reduced motion. The moving line is hidden from screen readers; `label` says it once instead.
const GROUNDS = {
blue: { line: "border-line-on-brand-strong", star: "text-fg-on-brand-label" },
white: { line: "border-line-strong", star: "text-fg-label" },
} as const;
function useReducedMotion() {
const [reduce, setReduce] = React.useState(false);
React.useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduce(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return reduce;
}
type MarqueeProps = Omit<React.ComponentProps<"div">, "children"> & {
/** The names on the line, in order. */
items: React.ReactNode[];
/** What a screen reader hears instead of the moving line. Defaults to the names joined, when they are text. */
label?: string;
/** The mark between names. */
separator?: React.ReactNode;
/** Seconds for one lap (one copy of the line). */
duration?: number;
/** The ground it sits on: white rules and sky stars on the blue, ink rules and blue stars on white. */
ground?: keyof typeof GROUNDS;
};
function Marquee({ items, label, separator = "✳", duration = 48, ground = "blue", className, ...props }: MarqueeProps) {
const trackRef = React.useRef<HTMLDivElement>(null);
const animationRef = React.useRef<Animation | null>(null);
const reduce = useReducedMotion();
const tone = GROUNDS[ground];
const spoken = label ?? (items.every((item) => typeof item === "string") ? `${items.join(", ")}.` : undefined);
React.useEffect(() => {
const el = trackRef.current;
if (!el || reduce || typeof el.animate !== "function") return;
const animation = el.animate([{ transform: "translateX(0)" }, { transform: "translateX(-50%)" }], { duration: duration * 1000, iterations: Infinity, easing: "linear" });
animationRef.current = animation;
return () => {
animation.cancel();
animationRef.current = null;
};
}, [reduce, duration]);
const pause = (e: React.PointerEvent) => {
if (e.pointerType === "mouse") animationRef.current?.pause();
};
const play = () => animationRef.current?.play();
return (
<div data-slot="marquee" className={cn("flex overflow-hidden border-y font-display text-caption leading-body", tone.line, className)} onPointerEnter={pause} onPointerLeave={play} {...props}>
<div ref={trackRef} aria-hidden className="flex flex-none will-change-transform">
{[0, 1].map((copy) => (
<span key={copy} className="flex-none py-3 whitespace-nowrap">
{items.map((item, i) => (
<span key={i}>
{item}{" "}
<i className={cn("mx-4.5 inline-block not-italic", tone.star)}>{separator}</i>
</span>
))}
</span>
))}
</div>
{spoken && <span className="sr-only">{spoken}</span>}
</div>
);
}
export { Marquee };
export type { MarqueeProps };Reference: agentcard-website src/components/v7/site-footer.tsx (.v7-foot-line); src/app/globals.css:5073; agentcard.sh / (computed).