Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VU6qqzefQ2TxgZetJ7beKU
242 lines
10 KiB
Markdown
242 lines
10 KiB
Markdown
# 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
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```tsx
|
||
<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.
|
||
|
||
```html
|
||
<figure class="card" style="--accent:#FFC24B; --rating:4.5; --max:5">
|
||
<div class="qmark" aria-hidden="true">“</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>
|
||
```
|
||
|
||
```css
|
||
.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](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=testimonial-video)
|