Files

10 KiB
Raw Permalink Blame History

Quote Card — complete runnable component

A full testimonial card you can drop into a Remotion project, plus a zero-dependency HTML/CSS variant for non-Remotion pipelines. Both reveal in reading order, emphasize one phrase, fill stars to the real score, and sign off with the author.

Timing map (9:16, 30fps, ~10s)

Frame Beat
0–8 Quote mark scales in; card settles
8–48 Quote body, line-by-line (8 + i×6)
44–54 Key-phrase highlight sweep (after its line settles)
56–70 Stars fill left→right to rating/max
72–88 Author block slides up, holds
88–300 Everything holds still (screenshot-able)

The card holds motionless for the back half — stillness is what makes a testimonial read as a fact rather than an ad.

Remotion composition

import {
  AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img,
} from "remotion";

export type Review = {
  quote: string;          // verbatim testimonial
  highlight?: string;     // exact substring of `quote` to emphasize
  rating: number;         // e.g. 4.5
  max?: number;           // default 5
  name: string;
  role?: string;
  company?: string;
  avatar?: string;        // real photo URL, or omit
};

const theme = {
  bg: "#0E1116",
  ink: "#F5F7FA",
  muted: "rgba(245,247,250,.55)",
  accent: "#FFC24B",
  font: "Inter, system-ui, sans-serif",
  quoteFont: "'Newsreader', Georgia, serif",
  quoteMark: "“",     // left double quotation mark
};

// Split a quote into clauses for reading-order reveal.
// Prefer sentence/clause boundaries; fall back to ~32-char wraps.
const toLines = (q: string): string[] => {
  const parts = q.match(/[^.!?,;]+[.!?,;]?\s*/g) ?? [q];
  const lines: string[] = [];
  let buf = "";
  for (const p of parts) {
    if ((buf + p).length > 34 && buf) { lines.push(buf.trim()); buf = p; }
    else buf += p;
  }
  if (buf.trim()) lines.push(buf.trim());
  return lines;
};

// Render a line, wrapping the highlight substring in <mark> if present.
const Line: React.FC<{ text: string; highlight?: string; sweep: number }> = ({
  text, highlight, sweep,
}) => {
  if (!highlight || !text.includes(highlight)) return <>{text}</>;
  const [before, after] = text.split(highlight);
  return (
    <>
      {before}
      <mark
        style={{
          background: `linear-gradient(90deg, ${theme.accent} ${sweep}%, transparent ${sweep}%)`,
          color: "inherit",
          padding: "0 .08em",
          borderRadius: 3,
          WebkitBoxDecorationBreak: "clone",
        }}
      >
        {highlight}
      </mark>
      {after}
    </>
  );
};

export const Testimonial: React.FC<Review> = ({
  quote, highlight, rating, max = 5, name, role, company, avatar,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const lines = toLines(quote);

  // Quote mark
  const markIn = spring({ frame, fps, config: { damping: 14 } });

  // Highlight sweep, after the body has revealed
  const hlStart = 8 + lines.length * 6 + 4;
  const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100], {
    extrapolateLeft: "clamp", extrapolateRight: "clamp",
  });

  // Stars fill, after the quote
  const fillStart = hlStart + 14;
  const target = (rating / max) * 100;
  const starW = interpolate(frame, [fillStart, fillStart + 14], [0, target], {
    extrapolateLeft: "clamp", extrapolateRight: "clamp",
  });

  // Author block slides up last
  const authStart = fillStart + 16;
  const auth = spring({ frame: frame - authStart, fps, config: { damping: 200 } });

  return (
    <AbsoluteFill style={{ background: theme.bg, fontFamily: theme.font,
      padding: 120, justifyContent: "center", color: theme.ink }}>
      <div
        aria-hidden
        style={{ fontFamily: theme.quoteFont, fontSize: 200, lineHeight: .6,
          color: theme.accent, opacity: markIn, transform: `scale(${markIn})`,
          transformOrigin: "left top" }}
      >
        {theme.quoteMark}
      </div>

      <blockquote style={{ fontFamily: theme.quoteFont, fontSize: 64,
        lineHeight: 1.25, margin: "24px 0 48px", fontWeight: 500 }}>
        {lines.map((line, i) => {
          const enter = spring({ frame: frame - (8 + i * 6), fps,
            config: { damping: 200 } });
          return (
            <span key={i} style={{ display: "block", opacity: enter,
              transform: `translateY(${(1 - enter) * 14}px)` }}>
              <Line text={line} highlight={highlight} sweep={sweep} />
            </span>
          );
        })}
      </blockquote>

      {/* Stars */}
      <div role="img" aria-label={`${rating} out of ${max} stars`}
        style={{ position: "relative", display: "inline-block", fontSize: 52,
          letterSpacing: 6, marginBottom: 56 }}>
        <div style={{ color: "rgba(245,247,250,.20)" }}>{"★".repeat(max)}</div>
        <div style={{ position: "absolute", inset: 0, overflow: "hidden",
          whiteSpace: "nowrap", width: `${starW}%`, color: theme.accent }}>
          {"★".repeat(max)}
        </div>
      </div>

      {/* Author */}
      <figcaption style={{ display: "flex", alignItems: "center", gap: 20,
        opacity: auth, transform: `translateY(${(1 - auth) * 16}px)` }}>
        {avatar && (
          <Img src={avatar} alt=""
            style={{ width: 88, height: 88, borderRadius: "50%", objectFit: "cover" }} />
        )}
        <div style={{ display: "flex", flexDirection: "column" }}>
          <span style={{ fontSize: 38, fontWeight: 700 }}>{name}</span>
          <span style={{ fontSize: 30, color: theme.muted }}>
            {[role, company].filter(Boolean).join(", ")}
          </span>
        </div>
      </figcaption>
    </AbsoluteFill>
  );
};

Register it with a duration long enough for the back-half hold:

<Composition id="Testimonial" component={Testimonial}
  durationInFrames={300} fps={30} width={1080} height={1920}
  defaultProps={{
    quote: "We shipped in days, not months. Onboarding saved us 10 hours a week.",
    highlight: "saved us 10 hours a week",
    rating: 4.5, name: "Dana Reyes", role: "Head of Ops", company: "Northwind",
  }} />

Why these choices

  • spring with damping: 200 gives a clean rise with no overshoot — testimonials should feel composed, not bouncy. The quote mark uses lighter damping (14) for a touch of life on the one decorative element.
  • Highlight as a gradient <mark> wipes a real highlighter behind the phrase without a second element or a mask, and survives line wraps via box-decoration-break: clone.
  • Stars as two stacked glyph rows clipped by width render any fractional score (4.5, 4.3) with no half-star asset, and the filled row inherits the brand accent.
  • Author after stars matches reading psychology: claim → proof → who. Reordering weakens trust.

Zero-dependency HTML/CSS variant

For pipelines that capture a headless-browser timeline (or just a web embed). Animations are CSS @keyframes with per-line animation-delay; if rendering frame-exact video, prefer the Remotion version above so frames stay deterministic.

<figure class="card" style="--accent:#FFC24B; --rating:4.5; --max:5">
  <div class="qmark" aria-hidden="true">&ldquo;</div>
  <blockquote>
    <span style="--i:0">We shipped in days, not months.</span>
    <span style="--i:1">Onboarding <mark>saved us 10 hours a week.</mark></span>
  </blockquote>
  <div class="stars" role="img" aria-label="4.5 out of 5 stars">
    <div class="track">★★★★★</div><div class="fill">★★★★★</div>
  </div>
  <figcaption>
    <img src="dana.jpg" alt="" class="avatar">
    <div><b>Dana Reyes</b><small>Head of Ops, Northwind</small></div>
  </figcaption>
</figure>
.card { --bg:#0E1116; --ink:#F5F7FA; background:var(--bg); color:var(--ink);
  font-family:Inter,system-ui,sans-serif; padding:120px; max-width:1080px; }
.qmark { font:200px/.6 'Newsreader',Georgia,serif; color:var(--accent);
  animation:pop .4s cubic-bezier(.22,1,.36,1) both; }
blockquote { font:500 64px/1.25 'Newsreader',Georgia,serif; margin:24px 0 48px; }
blockquote span { display:block; opacity:0;
  animation:rise .5s cubic-bezier(.22,1,.36,1) both;
  animation-delay:calc(.27s + var(--i) * .2s); }
mark { background:linear-gradient(90deg,var(--accent) 100%,transparent 100%);
  color:inherit; padding:0 .08em; border-radius:3px;
  -webkit-box-decoration-break:clone; box-decoration-break:clone;
  animation:sweep .35s ease-out both; animation-delay:1s; }
@keyframes sweep { from { background-size:0 100% } to { background-size:100% 100% } }
.stars { position:relative; display:inline-block; font-size:52px; letter-spacing:6px; }
.track { color:rgba(245,247,250,.20); }
.fill { position:absolute; inset:0; overflow:hidden; white-space:nowrap;
  color:var(--accent); width:0; animation:fill .5s ease-out 1.3s forwards; }
@keyframes fill { to { width:calc(var(--rating) / var(--max) * 100%) } }
figcaption { display:flex; align-items:center; gap:20px; margin-top:56px;
  opacity:0; animation:rise .5s ease-out 1.8s both; }
.avatar { width:88px; height:88px; border-radius:50%; object-fit:cover; }
figcaption b { font-size:38px; } figcaption small { display:block; font-size:30px;
  color:rgba(245,247,250,.55); }
@keyframes pop  { from { opacity:0; transform:scale(.7) } to { opacity:1; transform:none } }
@keyframes rise { from { opacity:0; transform:translateY(14px) } to { opacity:1; transform:none } }

The CSS mark sweep uses background-size (animatable) rather than a gradient color-stop, since color-stop percentages don't animate in CSS. The star --rating/--max math is the same fractional fill as the Remotion version.


Built by the team behind iart.ai

This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can turn a list of reviews/quotes into testimonial videos, swap text, and re-export. → iart.ai