Coming Soon
A locked preview of an unreleased area, crossed once by a gloss and dated by a badge that lands last.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Coming Soon
*
* An area that exists but is not open yet. The layout behind is real
* enough to recognise and dim enough not to read, a single gloss travels
* across it once — once, not on a loop, because a surface that keeps
* gleaming is asking for attention it cannot repay — and the date lands
* last. Asking to be told swaps the label in place and leaves the rest
* of the card exactly as it was.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ComingSoonPlaceholderProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Badge over the headline. */
badge?: string;
/** Headline of the unreleased area. */
title?: string;
/** One line about what is coming. */
message?: string;
/** Button labels, before and after asking to be notified. */
notifyLabel?: string;
notifiedLabel?: string;
/** Fires when the reader asks to be told. */
onNotify?: () => void;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Seconds the gloss takes to cross the card. */
glossSeconds: number;
/** Peak opacity of the gloss. */
glossPeak: number;
/** px the copy travels on its way in. */
rise: number;
/** Seconds between the badge, the headline and the rest. */
stagger: number;
fadeSeconds: number;
};
// Quality rule: nothing springs and nothing scales — the gloss is a
// translation, the copy is a fade with a few pixels of travel, and the
// button swaps text inside a fixed slot. A locked card that bounces
// promises a product that will not.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a highlight, for a disabled tab inside a working page.
subtle: {
glossSeconds: 1.1,
glossPeak: 0.06,
rise: 5,
stagger: 0.05,
fadeSeconds: 0.26,
},
// The all-purpose setting: one clear pass, then stillness.
default: {
glossSeconds: 1.4,
glossPeak: 0.1,
rise: 8,
stagger: 0.07,
fadeSeconds: 0.32,
},
// A slower, wider pass, for a marketing page teasing a launch.
playful: {
glossSeconds: 1.8,
glossPeak: 0.14,
rise: 12,
stagger: 0.1,
fadeSeconds: 0.38,
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` gives a card, a gloss and a schematic layout that read
* correctly on light and dark pages alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ComingSoonPlaceholder({
variant = "default",
badge = "Coming in September",
title = "Insights is almost ready",
message = "Usage trends, retention and cohorts in one place.",
notifyLabel = "Notify me",
notifiedLabel = "We'll let you know",
onNotify,
width = 320,
}: ComingSoonPlaceholderProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [notified, setNotified] = useState(false);
const rise = reduceMotion ? 0 : cfg.rise;
const fade = (delay: number) => ({
duration: cfg.fadeSeconds,
ease: "easeOut" as const,
delay,
});
const notify = () => {
if (notified) return;
setNotified(true);
onNotify?.();
};
return (
<div
style={{
position: "relative",
width,
boxSizing: "border-box",
overflow: "hidden",
borderRadius: 14,
background: tone(4),
border: `1px solid ${tone(11)}`,
}}
>
{/* The shape of the feature, recognisable and unreadable. Static:
scenery that animates invites a second look at something that
does not exist yet. */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
padding: 16,
opacity: 0.4,
userSelect: "none",
}}
>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
{[0, 1, 2].map((tile) => (
<div
key={tile}
style={{
flex: 1,
height: 34,
borderRadius: 8,
background: tone(8),
}}
/>
))}
</div>
{["88%", "64%", "76%"].map((barWidth) => (
<div
key={barWidth}
style={{
width: barWidth,
height: 8,
borderRadius: 4,
background: tone(8),
marginBottom: 9,
}}
/>
))}
</div>
{/* A scrim between the scenery and the words: enough to read the
copy against, not enough to hide what is behind it. */}
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: cfg.fadeSeconds * 1.3, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, background: tone(6) }}
/>
{/* One pass, then gone. Skewed so it reads as light across glass
rather than a bar sliding by. The skew is part of the animated
transform, not a style override Motion would discard. */}
{reduceMotion ? null : (
<motion.div
aria-hidden
initial={{ x: "-140%", skewX: -14, opacity: 0 }}
animate={{ x: "240%", skewX: -14, opacity: [0, cfg.glossPeak, 0] }}
transition={{
duration: cfg.glossSeconds,
ease: "easeInOut",
delay: 0.25,
opacity: { duration: cfg.glossSeconds, delay: 0.25, times: [0, 0.4, 1] },
}}
style={{
position: "absolute",
top: 0,
bottom: 0,
width: "46%",
background: `linear-gradient(90deg, transparent, ${tone(70)}, transparent)`,
pointerEvents: "none",
}}
/>
)}
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
padding: "26px 22px 22px",
}}
>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={fade(0.1)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 10.5,
fontWeight: 600,
letterSpacing: 0.4,
textTransform: "uppercase",
padding: "4px 9px",
borderRadius: 999,
background: tone(8),
border: `1px solid ${tone(14)}`,
}}
>
<StackGlyph />
{badge}
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={fade(0.1 + cfg.stagger)}
style={{ fontSize: 15, fontWeight: 650, marginTop: 12 }}
>
{title}
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 0.55, y: 0 }}
transition={fade(0.1 + cfg.stagger * 2)}
style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5, maxWidth: 232 }}
>
{message}
</motion.div>
<motion.button
type="button"
onClick={notify}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={fade(0.1 + cfg.stagger * 3)}
style={{
font: "inherit",
fontSize: 12.5,
fontWeight: 600,
color: "inherit",
background: tone(7),
border: `1px solid ${tone(16)}`,
borderRadius: 10,
padding: "8px 14px",
marginTop: 16,
cursor: notified ? "default" : "pointer",
}}
>
{/* Both labels share one cell, so the card keeps its width when
the answer changes. */}
<span style={{ display: "grid", placeItems: "center" }}>
<motion.span
animate={{ opacity: notified ? 0 : 1 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
>
{notifyLabel}
</motion.span>
<motion.span
animate={{ opacity: notified ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
>
{notifiedLabel}
</motion.span>
</span>
</motion.button>
</div>
</div>
);
}
/** Line art authored inline: three offset plates, a feature still being
* assembled. Stroked in `currentColor` so it inherits the page theme. */
function StackGlyph() {
return (
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M6 1.4l4.2 2.3L6 6 1.8 3.7z"
stroke="currentColor"
strokeWidth="1.1"
strokeLinejoin="round"
opacity="0.75"
/>
<path
d="M1.8 6.6L6 8.9l4.2-2.3"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.45"
/>
</svg>
);
}About this pattern
An area that exists in the navigation but is not open yet. The layout behind the card is real enough to recognise and dim enough not to read, so the reader learns roughly what is coming; a single gloss travels across it once and stops, because a surface that keeps gleaming is asking for attention it cannot repay yet. The badge, headline and date arrive in order, and asking to be notified swaps the button label inside a fixed slot so nothing about the card moves. Reduced motion drops the gloss entirely and keeps the sequence.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
An upcoming area appears in the sidebar as a dimmed card with a short note about timing.
Related patterns
- Permission NeededThe shape of the content stays visible out of focus while a lock closes over it and offers a way to ask.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.