Product Launch CTA
A free product launch CTA section with release proof and a media reveal.
Atlas 4.2
Preview every release before your customers do
Atlas 4.2 gives every branch its own live preview environment, keeps the deploy log readable for the whole team, and rolls a bad release back in one click.
- Median time to first deploy
- 12 min
- Rolling ninety-day uptime
- 99.98%
- Teams shipping weekly
- 3,400+
Customise
Code
const proof = [
{ value: "12 min", label: "Median time to first deploy" },
{ value: "99.98%", label: "Rolling ninety-day uptime" },
{ value: "3,400+", label: "Teams shipping weekly" },
];
// Swap for your own product mock, or pass imageSrc and imageAlt instead of media.
function ReleaseConsole() {
return (
<div
role="img"
aria-label="Atlas deploy console rolling release 4.2 out to production"
className="flex h-full w-full flex-col justify-center gap-3 bg-background p-6"
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Production rollout
</span>
<span className="text-lg font-semibold tabular-nums text-foreground">
62%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-border">
<div className="h-full w-[62%] rounded-full bg-primary" />
</div>
</div>
);
}
<CtaProductLaunch
eyebrow="Atlas 4.2"
heading="Preview every release before your customers do"
description="Atlas 4.2 gives every branch its own live preview environment."
primaryAction={{ label: "Read the release notes", href: "/releases" }}
secondaryAction={{ label: "Compare plans", href: "/pricing" }}
proof={proof}
media={<ReleaseConsole />}
/>Product Launch CTA is a free product launch CTA section for Next.js and React, styled with Tailwind and animated with Motion. It pairs a release label, a launch heading, concrete proof points and two real destinations with a product visual that reveals once as the section comes into view. The media frame takes a screenshot through imageSrc, or any composed node through media, so a launch without press assets can stage a product mock instead of a stock photograph. The rule between the two is the plain one: media you supply is framed, and the image pair is what the frame shows when you supply no media.
The server renders the finished layout, so the announcement is readable before any JavaScript runs. The entrance only arms itself for a section that mounted below the fold, the accent drift and eyebrow marker pause whenever the section is offscreen or the tab is hidden, and reduced motion leaves the complete section with every link intact.
Install
Add the @te registry to your components.json once:
{
"registries": {
"@te": "https://templateempire.io/r/{name}.json"
}
}Then install the component:
npx shadcn@latest add @te/cta-product-launchUsage
"use client";
import { CtaProductLaunch } from "@/components/cta-product-launch";
const proof = [
{ value: "12 min", label: "Median time to first deploy" },
{ value: "99.98%", label: "Rolling ninety-day uptime" },
{ value: "3,400+", label: "Teams shipping weekly" },
];
export function Example() {
return (
<CtaProductLaunch
eyebrow="Atlas 4.2"
heading="Preview every release before your customers do"
description="Atlas 4.2 gives every branch its own live preview environment."
primaryAction={{ label: "Read the release notes", href: "/releases" }}
secondaryAction={{ label: "Compare plans", href: "/pricing" }}
proof={proof}
imageSrc="/release-4-2-console.png"
imageAlt="Atlas deploy console with release 4.2 rolling out to production"
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | required | Release label or version, rendered above the heading. |
| heading | string | required | Launch message, rendered as the section's h2. |
| description | string | required | Concise supporting copy rendered below the heading. |
| primaryAction | { label: string; href: string } | required | The primary destination, rendered as a real anchor. |
| secondaryAction | { label: string; href: string } | undefined | Optional secondary destination. Dropped entirely if its label or href is missing or unsafe, rather than rendered as a dead focus stop. |
| proof | ReadonlyArray<{ value: string; label: string }> | required | One to three concrete proof points. Values are stable text and never count up. |
| media | ReactNode, already resolved | undefined | Composed media rendered inside the frame in place of the image: a product mock, a chart, any node sized to fill. The media you supply is the media that is framed. It is mounted, untransformed, exactly where you put it, and nothing in this component looks at it afterwards to decide whether to keep it: if it renders nothing, you get an empty frame, because the component does not inspect your content. What an arbitrary subtree paints into a box is the browser's question, and this component does not try to answer it. So if you want the image, say so with the props rather than by composing a node and expecting it to be judged: supply the imageSrc and imageAlt pair and no media. Supply both and the media node wins, whatever it draws. Either media or the complete image pair must be supplied, and that requirement is a runtime one: a call with neither renders nothing rather than an empty frame. It is deliberately not a compile-time one, because the exported Props type has to stay a plain interface you can both extend and forward, and a type that accepts a forwarded CtaProductLaunchProps value, whose media fields are all optional, accepts a literal that supplies none of them. A promise is a compile error, in the value itself and in any collection you put it in, at any depth: an array, a readonly array, a Set, a generator. The one place the type cannot reach is a promise written as the child of an element, media={<>{loadMock()}</>} included, because a JSX expression's props are any; that one is mounted and suspends like any other. A section that fails validation never touches media at all, so a call whose heading or proof is unusable renders nothing and leaves a generator you passed exactly as full as you handed it over. Two values are settled before anything renders and are read as no media at all rather than as media: a boolean, which is what React itself renders as nothing, and a string with nothing but whitespace in it. They are the two spellings of one accident, media={showMock && <Mock />} and media={assetUrl && <Mock />} with the condition off, so each is treated as the absence it is: the image pair is required alongside it and a call with neither renders nothing instead of an empty frame. That is the whole list, so an empty array, an empty Map and a component that returns null are media you supplied, framed as you supplied them, and the frame is empty because they are. A portal is mounted like anything else and fills the container you gave it rather than this frame, so your content lands where you sent it and this frame is empty. The node is passed through by identity, so what you pass is what renders: a Set you mutate re-renders with the members it now holds, and items.map wrapped in keyed fragments keeps every key. A Map is the one collection read rather than passed on, because React walks one as key and value pairs and would print each key into the frame as a caption you never wrote: its values render, in insertion order, re-read on every render just as a Set is, and its keys stay your own index into your own media whatever their type. The other exception is an iterable that cannot be walked twice, which React would re-read spent on a later render and which is therefore copied once: a generator, an iterable that wraps one and hands every walk the same shared cursor, and one that permits a single acquisition and then throws or hands back something already spent. Each is read through the first cursor it gives up rather than through one acquired to classify it, so nothing you can hand over only once is lost to the question of what it is. That copy follows the cursor into anything React renders and this section's own semantics cover: a host element, a fragment, a portal, and a collection at any nesting of those, with the element cloned rather than rebuilt and the portal rebuilt onto the container and key you gave it, so each reconciles in place with its own state intact, and anything with no cursor beneath it passed through untouched. It stops at a component of your own. Your component's children are an argument, not markup, and nothing out here can tell whether you render them or read them, so a Map you pass as children stays the Map your component asked for rather than becoming the array a rewrite would have made of it. The cost is that a single-pass cursor passed as your component's children is not stabilised: it is spent by whichever render attempt reads it first, and because React discards render attempts and retries them, the usual result is that it never appears at all. Materialise it at the call site, media={<Wrapper>{[...rows()]}</Wrapper>}, which is where a value that can only be read once has to be fixed. Caller-supplied media sits outside the section's strict LazyMotion boundary, so a motion.* node of your own renders and animates normally, and inside its own Suspense boundary, so media that suspends shows the image pair you supplied until it resolves rather than suspending the section. That fallback is React reporting that the subtree cannot render yet, not an inference about what it will draw. Takes precedence over imageSrc, and carries its own accessibility, such as a labelled role of img. |
| imageSrc | string | undefined | Product or release media source. It is required, together with imageAlt, whenever media is not given, and required in the same way whenever the media given is a blank string; without it the section renders nothing rather than an empty frame. With no media node it is the media, and it renders in the frame from the server. Alongside a media node it is not in the frame at all, because the node you supplied is what the frame holds: the one place it still appears is the Suspense fallback for media that is genuinely suspended, so exactly one image is ever on screen and there is never a second request behind it. |
| imageAlt | string | undefined | Descriptive alternative text for the media. Required alongside imageSrc. |
| mediaSide | "left" | "right" | "right" | Media position on wide screens. |
| mediaReveal | "wipe" | "scale" | "rise" | "wipe" | One-shot media entrance treatment. All three settle on the identical final layout. |
| revealDuration | number | 0.75 | Entrance length in seconds, clamped to 0.25 through 1.4. |
| badgePulse | number | 2.8 | Eyebrow marker cadence in seconds, clamped to 1.5 through 8. Zero disables it. |
| backgroundDrift | number | 8 | Ambient accent travel in CSS pixels, clamped to 0 through 20. |
| radius | number | 30 | Section and media radius in CSS pixels, clamped to 0 through 52. |
| className | string | undefined | Additional classes merged onto the section root. |
Customisation
Product Launch CTA exposes three component tokens, declared in its registry item: --product-launch-accent colours the eyebrow marker and the ambient glow, --product-launch-panel is the section surface, and --product-launch-line is the border and media frame. All three default to theme tokens, so the section follows your light and dark themes rather than pinning a fixed surface. Override them from :root, or on any ancestor, to retheme without touching the code:
:root {
--product-launch-accent: var(--primary);
--product-launch-panel: var(--card);
--product-launch-line: var(--border);
}Frequently asked questions
- Is Product Launch CTA free for commercial use?
- Yes. Product Launch CTA is MIT licensed, so you can use it in personal and commercial projects without attribution.
- Does it affect performance or accessibility?
- The ambient glow and eyebrow marker are CSS animations that pause whenever the section scrolls offscreen or the browser tab is hidden, and the observer and listener are released on unmount. The entrance runs once and only for a section that mounted below the fold, so content the reader is already looking at is never animated backwards. The section is a labelled region with a real heading, real anchors and a semantic proof list; image media carries descriptive alt text, composed media carries its own accessible name, and the marker and glow are aria-hidden with every claim in visible text. Under prefers-reduced-motion the complete section renders immediately with no entrance, drift or pulse.
- How do I change the accent colour or the media position?
- Override the CSS custom properties --product-launch-accent, --product-launch-panel and --product-launch-line from :root or any ancestor, and set mediaSide to left or right to move the image on wide screens. The mediaReveal prop chooses between a wipe, a scale and a rise, and all three settle on the identical final layout so no reader loses content whichever one you pick.