freeNew107 lines · framer-motion
Quote Marquee
Short statements, scrolling, with no one's name attached. Two rows of short product statements or principles, scrolling in opposite directions, with no attributed people, roles or companies — a marquee for what you believe, not who said so.
- Use it for
- A section that wants the rhythm of a testimonial wall — short lines, constant motion — without manufacturing quotes from people who don't exist.
- Themes
- Light + dark
- License
- Commercial use
Live
What we build by
A few lines we don't ship without
Principle
Ship the thing that works, then make it fast.
Default
Every state has a visible state — loading, empty, error, done.
Principle
No screen without a way back.
Principle
Ship the thing that works, then make it fast.
Default
Every state has a visible state — loading, empty, error, done.
Principle
No screen without a way back.
Default
If it can fail silently, it will fail loudly instead.
Principle
Motion explains what changed; it never decorates.
Default
Dark mode is not an afterthought, it's the other half of the design.
Default
If it can fail silently, it will fail loudly instead.
Principle
Motion explains what changed; it never decorates.
Default
Dark mode is not an afterthought, it's the other half of the design.
tsx
"use client";
import * as React from "react";
import { useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
export interface Statement {
text: string;
/** Short mono tag shown above the statement, e.g. "Principle", "Default". */
tag?: string;
}
export interface QuoteMarqueeProps {
eyebrow?: string;
title?: string;
statements?: Statement[];
speedSeconds?: number;
className?: string;
}
const DEFAULT_STATEMENTS: Statement[] = [
{ text: "Ship the thing that works, then make it fast.", tag: "Principle" },
{ text: "Every state has a visible state — loading, empty, error, done.", tag: "Default" },
{ text: "No screen without a way back.", tag: "Principle" },
{ text: "If it can fail silently, it will fail loudly instead.", tag: "Default" },
{ text: "Motion explains what changed; it never decorates.", tag: "Principle" },
{ text: "Dark mode is not an afterthought, it's the other half of the design.", tag: "Default" },
];
function Card({ s }: { s: Statement }) {
return (
<figure className="flex w-[20rem] shrink-0 flex-col gap-3 rounded-3xl border border-border bg-card p-6">
{s.tag && <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-signal">{s.tag}</p>}
<blockquote className="text-sm leading-relaxed text-foreground">{s.text}</blockquote>
</figure>
);
}
function Row({ statements, duration, reverse }: { statements: Statement[]; duration: number; reverse?: boolean }) {
const doubled = [...statements, ...statements];
return (
<div className="group overflow-hidden [mask-image:linear-gradient(90deg,transparent,black_8%,black_92%,transparent)]">
<div
className={cn(
"flex w-max gap-4",
reverse
? "[animation-name:lk-quote-marquee-rev] [animation-timing-function:linear] [animation-iteration-count:infinite] group-hover:[animation-play-state:paused] motion-reduce:[animation-play-state:paused]"
: "[animation-name:lk-quote-marquee] [animation-timing-function:linear] [animation-iteration-count:infinite] group-hover:[animation-play-state:paused] motion-reduce:[animation-play-state:paused]",
)}
style={{ animationDuration: `${duration}s` }}
>
{doubled.map((s, i) => (
<Card key={i} s={s} />
))}
</div>
</div>
);
}
export function QuoteMarquee({
eyebrow = "What we build by",
title = "A few lines we don't ship without",
statements = DEFAULT_STATEMENTS,
speedSeconds = 38,
className,
}: QuoteMarqueeProps) {
const reduce = useReducedMotionSafe();
const half = Math.ceil(statements.length / 2);
const top = statements.slice(0, half);
const bottom = statements.slice(half).length ? statements.slice(half) : top;
return (
<section className={cn("w-full overflow-hidden bg-background py-20", className)}>
<div className="mx-auto max-w-3xl 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(2rem,5vw,3.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
</div>
<div className="mt-12 space-y-4">
<Row statements={top} duration={speedSeconds} />
<Row statements={bottom} duration={speedSeconds * 1.15} reverse />
</div>
{!reduce && (
<style>{`
@keyframes lk-quote-marquee {
from { transform: translateX(0%); }
to { transform: translateX(-50%); }
}
@keyframes lk-quote-marquee-rev {
from { transform: translateX(-50%); }
to { transform: translateX(0%); }
}
`}</style>
)}
</section>
);
}
export default QuoteMarquee;
How it behaves
- →Two rows scroll in opposite directions at slightly different speeds, each on an infinite CSS keyframe animation.
- →Both rows pause on hover and fade at the left/right edges via a mask so cards never pop in or out abruptly.
- →prefers-reduced-motion stops both rows.
Accessibility
- ✓Cards are static text in `<blockquote>`s inside `<figure>`s; nothing here depends on motion to be read.
- ✓Scrolling pauses under prefers-reduced-motion and on hover/focus-within.
- ✓Honours prefers-reduced-motion.