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