DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our ads are 28 React compositions, and the constraint is that any two must cut together

The social ads for Notifio are not screen recordings. They are 28 Remotion compositions: React components with a frame number, rendered to video at 1080x1920.

I have written about doing this for Munchable before, where the point was that the ad imports the real rules engine, so the verdict on screen is one the product would actually give. Notifio cannot do that, and the difference is instructive. Munchable's ad shows the product's own screen. Notifio's product is a notification, and the surfaces it shows up on, a macOS toast, an iOS banner, somebody's mail client, are surfaces Notifio does not render and cannot import. There is no engine to reuse.

So the honesty problem had to be solved a different way, and the interchangeability problem turned out to be the harder one anyway.

The library is organised by the slot a clip fills, not by what it shows

01-hook        5 compositions
02-problem     3
03-mechanism   3
04-payoff      4
05-offer       3
overlays      10
Enter fullscreen mode Exit fullscreen mode

The folder names are the slots an ad is assembled from, in order. The whole point of the library, as the registry file puts it, is that picking one clip from each folder cuts together into a complete spot.

Five hooks and three problems and three mechanisms is not eleven clips, it is a multiplication. That only holds if any hook genuinely cuts into any problem, which is a constraint on how each clip is written rather than a property you get from putting them in folders.

The variants-as-compositions decision is the small one that keeps paying. notif-banner-ios and notif-banner-ios-hold are two entries rather than one composition with a --hold flag. A flag lives in somebody's shell history. A composition shows up in remotion compositions, in the Studio sidebar, and in the batch renderer's target list, so the long-hold variant cannot be quietly forgotten by whoever renders next month.

What "any two must cut together" actually costs you

Three rules, each of which reads like a style preference until you try to violate one.

No exit animation. Every clip ends on a stable frame, so the edit can hold it for exactly as long as the voiceover line needs.

A clip that animates out has decided how long it is. The edit then has to either accept that length or cut into the animation, which looks like a mistake. Ending on a stable frame means the same four second clip can sit under a two second line or a six second one.

Text lives in the box the platforms leave you. Every platform draws its own chrome over your video, and they do not agree about where:

export const OVERLAY_ZONES = {
  /** Never place meaningful content outside this box. */
  critical: { top: 96, bottom: 340, left: 24, right: 132 },
  comfortable: { top: 168, bottom: 496, left: 72, right: 176 },
} as const;
Enter fullscreen mode Exit fullscreen mode

Deliberately pessimistic: worst case across TikTok's caption block, Reels, and Shorts, all at once. Per-platform safe zones would buy back some frame, at the cost of every clip having a platform attached to it, which is exactly the interchangeability you were trying to keep.

The camera is in the composition, not in the editor. This one has a real technical reason behind it:

/**
 * A camera rig over the whole frame.
 *
 * The point of doing zooms here rather than in the edit is resolution: this is
 * DOM, so scaling up re-rasterises text and borders at the new size and stays
 * razor sharp. The same push applied to a finished 1080p file in CapCut just
 * makes it soft.
 *
 * Keyframes interpolate with a slow-in/slow-out curve because a linear camera
 * move is the single fastest way to make motion graphics look amateur.
 */
Enter fullscreen mode Exit fullscreen mode

The tuning knob that must not be global

Everything felt too fast on a vertical feed, so there is a global slow-down. The interesting part is what it is not allowed to touch:

export const MOTION_SLOWDOWN = 1.4;
Enter fullscreen mode Exit fullscreen mode

This is deliberately applied only to the "snap" primitives, springs, the word-by-word headline reveal, and the fade-up / pull-away helpers, because those are what read as too fast on a vertical feed. It is NOT applied to ramp/rampEased, which drive timed sequences with fixed end points (the SpeedRace clock reaching +20:00, the DiscoveryScan sweep landing on the row). Stretching those would desync them from the beat constants they share.

There are two categories of motion in the library and they look identical in the source. One is decoration: a card arriving, a headline landing. Slow it down by 40% and it is just nicer. The other is information: a clock in one clip counts from +00:00 to +20:00 while a spinner spins and an alert fires at a specific beat, and those beats are pinned to each other. Scale that and the clock no longer reads +20:00 when the punchline lands.

A global speed multiplier is one of the most tempting knobs to add to an animation codebase, and it is only safe once you have split those two categories apart.

The other timing primitive exists because of a Remotion sharp edge worth knowing:

/**
 * A spring that starts on a given frame instead of frame 0.
 *
 * Remotion's `spring` always measures from frame 0, so staggering beats means
 * subtracting offsets by hand at every call site. This does that once and pins
 * the result to 0 beforehand, so a beat cannot leak backwards into the head
 * hold of a clip.
 */
Enter fullscreen mode Exit fullscreen mode

Pinning to 0 before the start frame is the bit that matters. Without it a spring evaluated at frame 3 for a beat that starts at frame 40 returns some small non-zero value, and your "nothing has happened yet" opening frames have a card sitting one pixel off where it should be.

The honesty rule, which is where the tokens come from

Since the clips cannot import the product, they reconstruct it, and the failure mode of reconstruction is an ad that shows a better app than the one people download:

/**
 * The Notifio desktop UI, rebuilt at design-space scale for `DesktopStage`.
 *
 * [Ported from the real renderer rather than designed fresh: the same #171717
 * cards on #262626 borders, the same 11px uppercase panel labels, the same
 * status dot colours, the same white primary button, the same "N of 15"
 * counter.]
 *
 * Keeping it faithful is the whole point. An ad that shows a prettier app than
 * the one people download is a promise the product then breaks on first launch.
 */
Enter fullscreen mode Exit fullscreen mode

That "N of 15" counter is not a detail I would have thought to carry over. It is there because 15 searches is a real product limit with a real reason, and an ad frame showing an unbounded list would be advertising a product we do not sell.

The colour tokens have the same origin:

/**
 * The Notifio visual language, in one place.
 *
 * These values are lifted from the landing page demos (HeroDemo, BrowserDemo,
 * NotificationDemo, AutoReplyDemo), which each hard-code them inline. Keeping a
 * single copy here means the clips cannot drift away from the site the way four
 * separate copies of the same hex codes eventually would.
 */
Enter fullscreen mode Exit fullscreen mode

Worth being precise about what that does and does not achieve. It is one copy inside the studio, not a shared package with the site, so the site could still move and leave the clips behind. What it buys is that the drift, when it happens, is one file to fix instead of fourteen clips to audit. That was the right trade for a video workspace that renders on my machine and ships nothing to a browser, and it would be the wrong trade for anything a user loads.

The detail I am most pleased with

The iOS banner is translucent, and it stays translucent all the way into the file:

/**
 * The fill is deliberately left translucent (0.80) rather than opaque. On the
 * solid renders the background shows through as real glass, and on the alpha
 * renders the translucency survives into the file[, so] when this is dropped
 * over someone's screen recording, their footage shows through the blur
 * exactly the way a real notification would.
 */
Enter fullscreen mode Exit fullscreen mode

Ten of the 28 compositions are overlays meant to composite over other footage, and that one property is the difference between an overlay that reads as a notification and one that reads as a sticker. Getting the alpha through the encoder turned out to be its own adventure, which is the next post.

Go and look

If you build an ad library rather than an ad, the question to keep asking is not "does this clip look good". It is "what have I just decided on behalf of every edit that uses it".

Top comments (0)