Add ad-video skills (ad-creative, launch, testimonial)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VU6qqzefQ2TxgZetJ7beKU
This commit is contained in:
15 files changed
+1406
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: testimonial-video
|
||||
description: This skill should be used when the user asks to "make an animated testimonial", "create a quote video", "build a review video", "turn a customer testimonial into a graphic", "animate a customer review", "make a social proof video", "add an animated star rating", or "turn a list of reviews into videos". Covers quote typography, staggered line reveals, kinetic emphasis on key phrases, animated star ratings, the author block (name/role/avatar/company), and templating one card across many quotes.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Testimonial Video
|
||||
|
||||
Animate a written quote, review, or testimonial into a short, trustworthy social-proof clip. The craft is typographic, not flashy: a clean quote card where the words land in reading order, the key phrase gets emphasis exactly when it should, the stars fill to the real score, and the author signs off. One card design, driven by props, becomes a video for every review on the list.
|
||||
|
||||
## When to use
|
||||
|
||||
- Customer review → a 6–12s vertical/feed clip (the headline use case).
|
||||
- A pull-quote or testimonial graphic with author name, role, avatar, company.
|
||||
- An animated star rating (including half/fractional scores like 4.5).
|
||||
- A batch: a list/CSV of reviews → one video each, same template.
|
||||
|
||||
Do **not** reach here for sale/discount promos or generic ad creative — this skill is strictly the quote / social-proof angle.
|
||||
|
||||
## The one rule: trust before motion
|
||||
|
||||
A testimonial earns its job by feeling *real*, not produced. Restraint reads as credibility; over-animation reads as an ad people skip.
|
||||
|
||||
1. **Reveal in reading order.** The eye must finish a line before the next arrives. Stagger lines (or clauses), never all words at once, and never reorder for visual flair.
|
||||
2. **Quote the words exactly.** Do not paraphrase, trim mid-sentence, or invent a rating. If you emphasize a phrase, it must be the customer's phrase.
|
||||
3. **The author is the proof.** A quote with no attributable human is just a slogan. Name + role + company (and avatar if real) is non-negotiable.
|
||||
|
||||
## The anatomy of a quote card
|
||||
|
||||
| Element | Job | Timing |
|
||||
|---|---|---|
|
||||
| Quote mark motif | Signals "this is a quote" before words read | Frame 0, scales/fades in first |
|
||||
| Quote body | The testimonial, revealed line-by-line | Staggered, ~6–10 frames apart |
|
||||
| Emphasis span | The one phrase that sells | Lands *after* its line settles |
|
||||
| Star rating | The score, filled to the real value | Fills left→right after the quote |
|
||||
| Author block | Name / role / company / avatar | Signs off last, slides up |
|
||||
|
||||
Build in that order; reveal in that order. The viewer reads the quote, believes it, sees the score, then learns who said it.
|
||||
|
||||
## Staggered line reveal
|
||||
|
||||
Split the quote into lines (or clauses), then offset each one's entrance. In Remotion, drive every line off `useCurrentFrame()` so it renders deterministically — never a CSS transition or a JS timer, which desync from the frame renderer.
|
||||
|
||||
```jsx
|
||||
import { useCurrentFrame, spring, useVideoConfig } from "remotion";
|
||||
|
||||
const QuoteBody = ({ lines, startAt = 8, stagger = 6 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
return (
|
||||
<blockquote>
|
||||
{lines.map((line, i) => {
|
||||
const enter = spring({ frame: frame - startAt - i * stagger, fps,
|
||||
config: { damping: 200 } }); // 0→1, no overshoot
|
||||
return (
|
||||
<span key={i} style={{ display: "block",
|
||||
opacity: enter,
|
||||
transform: `translateY(${(1 - enter) * 14}px)` }}>
|
||||
{line}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Keep one motion language: same easing, same 14px rise, every line. Speed reads as confidence only when it's consistent. See `references/quote-card.md` for the full composition.
|
||||
|
||||
## Kinetic emphasis on the key phrase
|
||||
|
||||
Pick the single phrase that carries the testimonial ("saved us 10 hours a week") and let it arrive *after* its line has settled — a highlight sweep, a weight shift, or a color pop. Emphasis competing with the reveal cancels both.
|
||||
|
||||
```jsx
|
||||
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
// a highlighter wiping in behind the phrase, left→right
|
||||
<mark style={{ background:
|
||||
`linear-gradient(90deg, var(--accent) ${sweep}%, transparent ${sweep}%)`,
|
||||
color: "inherit", padding: "0 .08em" }}>{phrase}</mark>
|
||||
```
|
||||
|
||||
Emphasize at most one span per quote. Two highlights is no highlight.
|
||||
|
||||
## Animated star rating (fractional)
|
||||
|
||||
Render the real score, including halves. Use one full row of star glyphs as a mask, then wipe a filled layer to `rating / max` width — this handles 4.5 as cleanly as 5.0, with no half-star sprite.
|
||||
|
||||
```jsx
|
||||
const StarRating = ({ rating, max = 5, fillStart = 30, fillDur = 14 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const target = (rating / max) * 100;
|
||||
const w = interpolate(frame, [fillStart, fillStart + fillDur], [0, target],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<div className="stars" role="img" aria-label={`${rating} out of ${max} stars`}>
|
||||
<div className="track">★★★★★</div>
|
||||
<div className="fill" style={{ width: `${w}%` }}>★★★★★</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
```css
|
||||
.stars { position: relative; display: inline-block; font-size: 40px; letter-spacing: 4px; }
|
||||
.track { color: rgba(255,255,255,.22); }
|
||||
.fill { position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
|
||||
color: var(--accent); } /* clipped by width → fractional fill */
|
||||
```
|
||||
|
||||
Fill left→right after the quote reads. Never animate stars before the words — the score must feel earned, not pre-loaded. Always set `aria-label` to the real value.
|
||||
|
||||
## The author block
|
||||
|
||||
Sign off last. The block slides up under the stars and holds still — stillness is what makes attribution read as fact.
|
||||
|
||||
```jsx
|
||||
<figcaption className="author">
|
||||
{avatar && <img src={avatar} alt="" className="avatar" />}
|
||||
<div>
|
||||
<span className="name">{name}</span>
|
||||
<span className="role">{role}{company && `, ${company}`}</span>
|
||||
</div>
|
||||
</figcaption>
|
||||
```
|
||||
|
||||
Use a real avatar or none — a generic placeholder face actively destroys trust. Name is the loudest line in the block; role/company is secondary weight.
|
||||
|
||||
## Template × reviews — one video per quote
|
||||
|
||||
The payoff: make every field a prop, hardcode nothing, then render once per review.
|
||||
|
||||
```jsx
|
||||
export const Testimonial = ({ quote, highlight, rating, name, role, company, avatar }) => { /* … */ };
|
||||
```
|
||||
```bash
|
||||
for f in reviews/*.json; do
|
||||
npx remotion render Testimonial "out/$(basename "$f" .json).mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Keep colors/fonts/quote-mark/layout in one theme object so 50 testimonials stay brand-locked and only the words and score change. See `references/batch-and-typography.md` for the review schema, CSV→props parsing, quote-typography craft (smart quotes, hanging punctuation, line-splitting, orphans), and the batch script.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Lines reveal in reading order; one consistent enter motion, ~6–10 frame stagger.
|
||||
- The quote is verbatim; exactly one emphasized span, landing after its line settles.
|
||||
- Stars fill to the *real* score (halves supported) after the quote, with a correct `aria-label`.
|
||||
- Author block (name + role + company, real avatar or none) signs off last and holds still.
|
||||
- Every animated value derives from `useCurrentFrame()` — no CSS/JS timers.
|
||||
- Every field is a prop; one template renders every review in the folder.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion `Testimonial` registered as `<Composition>` (+ zod `schema` + `defaultProps`), every value frame-driven (no CSS transitions / JS timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` per review per aspect (plus the project + review data, so the user re-renders on new quotes).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Cheap PNGs first; render with the **shipped** review's props, not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across the reveal order: quote → stars → author
|
||||
npx remotion still Testimonial out/f-quote.png --frame=30 --props=reviews/r1.json # lines in reading order, emphasis span
|
||||
npx remotion still Testimonial out/f-stars.png --frame=48 --props=reviews/r1.json # stars filled to the REAL score (e.g. 4.5)
|
||||
npx remotion still Testimonial out/f-author.png --frame=215 --props=reviews/r1.json # author block signed off, holding still
|
||||
# inspect each: fidelity (quote VERBATIM, exactly one emphasized span, star fill matches rating, name/role/company exact)
|
||||
# AND artifacts (text overflow/orphans, off-canvas, author in the 9:16 bottom third, missing font, broken avatar)
|
||||
```
|
||||
|
||||
**Multi-aspect / batch — verify one review in EACH target aspect before batch-rendering the list.** A layout bug (overflowing quote, clipped author) repeats across every review × aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 9x16 1x1 16x9; do # one representative review, every aspect
|
||||
npx remotion still Testimonial "out/r1-${ar}.png" --frame=215 \
|
||||
--props=reviews/r1.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then batch-render the list:
|
||||
for f in reviews/*.json; do id=$(basename "$f" .json); for ar in 9x16 1x1 16x9; do
|
||||
npx remotion render Testimonial "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done; done
|
||||
npx remotion render Testimonial out/demo.gif --codec=gif --props=reviews/r1.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at quote / stars / author — no errors, no missing fonts/avatar.
|
||||
2. Quote is verbatim with one emphasized span; stars fill to the real score; name/role/company exact and inside the safe zone (clear of the 9:16 bottom third).
|
||||
3. Frame-driven only — no CSS/JS timers, `Date.now()`, or `Math.random()`.
|
||||
4. Shipped review's props render correctly (not just `defaultProps`); `aria-label` carries the real rating.
|
||||
5. One review verified in 9:16 + 1:1 + 16:9 before the batch; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/quote-card.md` — a complete runnable Remotion `Testimonial` composition: staggered line reveal, key-phrase highlight sweep, fractional star fill, author block, theme object, and the timing map for a 9:16 card. Plus a dependency-free HTML/CSS variant.
|
||||
- `references/batch-and-typography.md` — the review prop schema, CSV/JSON→props parsing and validation, quote typography craft (smart quotes, hanging quotation marks, line/clause splitting, orphan control, legibility in motion), star-rating accessibility, and the template×reviews batch render script for N videos.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Batch & Typography
|
||||
|
||||
How to feed many reviews into one card design, plus the quote-typography craft that separates a credible testimonial from a sloppy one.
|
||||
|
||||
## Review prop schema
|
||||
|
||||
One object per testimonial. Keep it flat so a CSV row maps to it directly.
|
||||
|
||||
```ts
|
||||
type Review = {
|
||||
quote: string; // verbatim — never paraphrased or trimmed mid-sentence
|
||||
highlight?: string; // EXACT substring of `quote`; one phrase only
|
||||
rating: number; // 0–max, halves allowed (4.5)
|
||||
max?: number; // default 5
|
||||
name: string; // required — a quote with no name is not social proof
|
||||
role?: string;
|
||||
company?: string;
|
||||
avatar?: string; // real photo URL, or omit (never a stock face)
|
||||
};
|
||||
```
|
||||
|
||||
### Validation (fail loud, before rendering 50 videos)
|
||||
|
||||
```ts
|
||||
function validate(r: Review): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!r.quote?.trim()) errs.push("quote is empty");
|
||||
if (!r.name?.trim()) errs.push("name is required (no anonymous testimonials)");
|
||||
const max = r.max ?? 5;
|
||||
if (r.rating < 0 || r.rating > max) errs.push(`rating ${r.rating} out of 0–${max}`);
|
||||
if (r.highlight && !r.quote.includes(r.highlight))
|
||||
errs.push(`highlight "${r.highlight}" is not a substring of the quote`);
|
||||
return errs;
|
||||
}
|
||||
```
|
||||
|
||||
The `highlight ⊂ quote` check is the important one: it guarantees you only ever emphasize words the customer actually wrote. If the highlight isn't an exact substring, the emphasis silently disappears in the card — so block the render instead.
|
||||
|
||||
## CSV → props
|
||||
|
||||
Reviews usually arrive as a spreadsheet export. Map columns to the schema and coerce the rating.
|
||||
|
||||
```js
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
// CSV columns: quote,highlight,rating,name,role,company,avatar
|
||||
const rows = parse(readFileSync("reviews.csv"), { columns: true, trim: true });
|
||||
mkdirSync("reviews", { recursive: true });
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const r = {
|
||||
quote: row.quote,
|
||||
highlight: row.highlight || undefined,
|
||||
rating: Number(row.rating),
|
||||
name: row.name,
|
||||
role: row.role || undefined,
|
||||
company: row.company || undefined,
|
||||
avatar: row.avatar || undefined,
|
||||
};
|
||||
const errs = validate(r);
|
||||
if (errs.length) { console.error(`Row ${i + 1} (${r.name}):`, errs); process.exit(1); }
|
||||
writeFileSync(`reviews/${String(i).padStart(3, "0")}-${slug(r.name)}.json`,
|
||||
JSON.stringify(r, null, 2));
|
||||
});
|
||||
|
||||
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
```
|
||||
|
||||
## Batch render — one video per review
|
||||
|
||||
```bash
|
||||
mkdir -p out
|
||||
for f in reviews/*.json; do
|
||||
name=$(basename "$f" .json)
|
||||
npx remotion render Testimonial "out/$name.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Everything visual lives in the single `theme` object in the composition (colors, fonts, quote-mark glyph, spacing). Because only the props change per render, 50 testimonials stay pixel-for-pixel brand-locked while the words and score vary. To re-theme the whole set, edit one object and re-run the loop.
|
||||
|
||||
For multi-aspect (9:16 feed + 1:1 post + 16:9 site), register the composition at each size with a shared center-safe layout and loop over both `reviews/*.json` and the aspect list.
|
||||
|
||||
## Quote typography craft
|
||||
|
||||
Motion is the easy part; legible, well-set type is what makes the quote trustworthy.
|
||||
|
||||
### Smart quotes, always
|
||||
|
||||
Straight quotes (`"`) read as un-proofed. Convert to typographic quotes before rendering.
|
||||
|
||||
```js
|
||||
const smarten = (s) => s
|
||||
.replace(/(^|[\s([{])"/g, "$1“").replace(/"/g, "”") // “ ”
|
||||
.replace(/(^|[\s([{])'/g, "$1‘").replace(/'/g, "’") // ‘ ’
|
||||
.replace(/--/g, "—"); // em dash
|
||||
```
|
||||
|
||||
### Hanging quotation marks
|
||||
|
||||
A leading quote glyph indents the first line and breaks the flush-left edge. Hang it into the margin so the text block stays optically aligned — the gold standard for set quotes.
|
||||
|
||||
```css
|
||||
blockquote { hanging-punctuation: first; } /* Safari/WebKit */
|
||||
/* Cross-engine fallback: pull the opening mark left by its own width */
|
||||
blockquote::before { content: "\201C"; margin-left: -0.55em; color: var(--muted); }
|
||||
```
|
||||
|
||||
When the card uses a large decorative quote mark (as in `quote-card.md`), drop the inline opening glyph so the mark isn't doubled.
|
||||
|
||||
### Splitting into lines / clauses
|
||||
|
||||
Reveal in reading order means splitting on meaning, not on a fixed character count. Prefer clause boundaries (`. ! ? , ; —`), then pack to a comfortable measure (~32–40 chars at card size). The `toLines` helper in `quote-card.md` does exactly this. Never split a short quote into one-word-per-line — that reads as a ransom note, not a testimonial.
|
||||
|
||||
### Orphans & widows
|
||||
|
||||
A single dangling word on the last line looks broken. Bind the last two words with a non-breaking space so they wrap together.
|
||||
|
||||
```js
|
||||
const noOrphan = (s) => s.replace(/ ([^ ]+)\s*$/, " $1");
|
||||
```
|
||||
|
||||
### Legibility in motion
|
||||
|
||||
- Use a font that stays readable while moving — avoid thin weights and tight tracking; a serif or a humanist sans at 500–600 weight holds up best on a rising line.
|
||||
- Animate **opacity + a small translate** only. Blur, rotation, and scale on body text hurt readability mid-reveal.
|
||||
- Contrast: body text ≥ 4.5:1 against the card; the highlight accent should pass against both the card and the ink it sits behind.
|
||||
- One emphasized span per quote. A second highlight halves the weight of the first.
|
||||
|
||||
## Star-rating accessibility & correctness
|
||||
|
||||
- The visual stars are decorative; expose the value to assistive tech with `role="img"` and `aria-label="4.5 out of 5 stars"` on the container (already in `quote-card.md`).
|
||||
- For fractional scores, the width-clip method (`rating / max * 100%`) is exact for any decimal — no rounding to the nearest half-star, so 4.3 renders as 4.3.
|
||||
- Never animate the stars before the quote is read, and never show more filled stars than the data supports. A faked rating is the fastest way to lose the trust the testimonial was meant to build.
|
||||
- If a review has no rating, omit the star row entirely rather than defaulting to 5 — an invented score is worse than none.
|
||||
|
||||
## Pacing notes
|
||||
|
||||
- Hold the finished card still for the back half of the clip (≥3s) so it's screenshot-able and re-shareable — shares are the whole point of social proof.
|
||||
- For a sequence of testimonials in one video, give each card a full read (~3–4s) and hard-cut between them; cross-dissolving quotes makes both unreadable.
|
||||
@@ -0,0 +1,241 @@
|
||||
# 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)
|
||||
Reference in new issue
Block a user