Welcome Hero Entrance
The first screen assembles in beats: the mark settles, then the headline, the supporting line, and finally the action.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Welcome Hero Entrance
*
* The first screen assembles itself in beats: the product mark settles,
* then the headline, then the supporting line, then the action. Nothing
* arrives at the same instant, so the eye is walked down the screen in
* the order the words should be read.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals are mixed from the inherited text color, so the hero lands
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `title`, `body`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type WelcomeHeroEntranceProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Name shown under the mark. */
product?: string;
title?: string;
body?: string;
/** Primary button label. */
actionLabel?: string;
/** Quiet second action — pass an empty string to drop it. */
secondaryLabel?: string;
/** Mark, glow and primary button color. */
accent?: string;
/** Fires when the primary action is pressed. */
onStart?: () => void;
/** Fires when the quiet action is pressed. */
onSecondary?: () => void;
};
type VariantConfig = {
/** Seconds between one element landing and the next starting. */
beat: number;
/** How far text rises into place, in px. */
rise: number;
/** Scale the mark grows from — the mark is a shape, never the text. */
markFrom: number;
markSpring: { type: "spring"; stiffness: number; damping: number };
/** Text moves on a tween: a heading that springs reads as a toy. */
textSeconds: number;
};
// Quality rule: every spring here sits at or above a 0.8 damping ratio
// (damping / 2√stiffness), and only the mark scales. Variants change the
// spacing between beats and the distance travelled, never the bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Tight beats, short travel. For a returning user who sees this often.
subtle: {
beat: 0.06,
rise: 4,
markFrom: 0.96,
markSpring: { type: "spring", stiffness: 550, damping: 47 },
textSeconds: 0.24,
},
// Beats far enough apart to be felt as a sequence. All-purpose.
default: {
beat: 0.09,
rise: 10,
markFrom: 0.9,
markSpring: { type: "spring", stiffness: 460, damping: 40 },
textSeconds: 0.32,
},
// A longer curtain-up for the very first launch.
playful: {
beat: 0.13,
rise: 16,
markFrom: 0.82,
markSpring: { type: "spring", stiffness: 350, damping: 33 },
textSeconds: 0.38,
},
};
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function WelcomeHeroEntrance({
variant = "default",
product = "Meridian",
title = "Everything in one workspace",
body = "Projects, notes and the people who work on them. Set it up once and the rest follows.",
actionLabel = "Take a look around",
secondaryLabel = "Import existing work",
accent = "#5B5BD6",
onStart,
onSecondary,
}: WelcomeHeroEntranceProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: the beats survive, the travel does not. The order
// elements arrive in is information — it says what to read first.
const rise = reduceMotion ? 0 : cfg.rise;
const beat = reduceMotion ? cfg.beat * 0.6 : cfg.beat;
/** One shared entrance for every text block, offset by its beat. */
const textStep = (step: number) => ({
initial: { opacity: 0, y: rise },
animate: { opacity: 1, y: 0 },
transition: {
duration: reduceMotion ? 0.2 : cfg.textSeconds,
delay: beat * step,
ease: "easeOut" as const,
},
});
return (
<div
style={{
position: "relative",
width: 320,
boxSizing: "border-box",
padding: "30px 24px 24px",
borderRadius: 20,
border: `1px solid ${tone(12)}`,
background: tone(5),
overflow: "hidden",
textAlign: "center",
}}
>
{/* A wash of accent behind the mark. Purely opacity and scale, so
it never touches layout — and it is a shape, so it may scale. */}
<motion.div
aria-hidden
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.86 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { duration: cfg.textSeconds + 0.2, ease: "easeOut" }
}
style={{
position: "absolute",
top: -54,
left: "50%",
marginLeft: -110,
width: 220,
height: 160,
borderRadius: "50%",
background: `radial-gradient(circle at 50% 55%, color-mix(in srgb, ${accent} 30%, transparent), transparent 68%)`,
pointerEvents: "none",
}}
/>
<motion.div
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: cfg.markFrom, y: cfg.rise * 0.6 }
}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={
reduceMotion ? { duration: 0.2, ease: "easeOut" } : cfg.markSpring
}
style={{
position: "relative",
width: 52,
height: 52,
margin: "0 auto",
borderRadius: 15,
display: "grid",
placeItems: "center",
background: `linear-gradient(145deg, color-mix(in srgb, ${accent} 78%, #ffffff), ${accent})`,
boxShadow: `0 10px 24px color-mix(in srgb, ${accent} 34%, transparent)`,
}}
>
<ProductMark />
</motion.div>
<motion.div
{...textStep(1)}
style={{
position: "relative",
marginTop: 14,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.6,
textTransform: "uppercase",
opacity: 0.5,
}}
>
{product}
</motion.div>
<motion.h2
{...textStep(2)}
style={{
position: "relative",
margin: "6px 0 0",
fontSize: 21,
lineHeight: 1.25,
fontWeight: 680,
letterSpacing: -0.3,
}}
>
{title}
</motion.h2>
<motion.p
{...textStep(3)}
style={{
position: "relative",
margin: "10px auto 0",
maxWidth: 250,
fontSize: 13,
lineHeight: 1.6,
opacity: 0.62,
}}
>
{body}
</motion.p>
<motion.div
{...textStep(4)}
style={{
position: "relative",
marginTop: 20,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<button
type="button"
onClick={onStart}
style={{
padding: "11px 16px",
fontSize: 13.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 11,
cursor: "pointer",
}}
>
{actionLabel}
</button>
{secondaryLabel ? (
<button
type="button"
onClick={onSecondary}
style={{
padding: "9px 16px",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: "none",
borderRadius: 11,
opacity: 0.6,
cursor: "pointer",
}}
>
{secondaryLabel}
</button>
) : null}
</motion.div>
</div>
);
}
/** Invented mark for the sample product — an inline SVG so the file
* stays a single copyable unit with no asset to fetch. */
function ProductMark() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="8.4" stroke="#ffffff" strokeWidth="1.7" />
<ellipse cx="12" cy="12" rx="3.9" ry="8.4" stroke="#ffffff" strokeWidth="1.7" />
<path
d="M3.9 9.3h16.2M3.9 14.7h16.2"
stroke="#ffffff"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}About this pattern
The opening screen of a product, staged rather than dropped. The mark arrives first on a spring and is the only thing allowed to scale, because it is a shape; every line of copy after it rises a few pixels and fades on a tween, one beat behind the last. The order is the message — it walks the eye from the mark to the words to the button in the order they should be read, and the button arriving last is what makes the screen feel finished rather than merely loaded. Reduced motion keeps the beats and drops the travel, so the reading order still survives.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Mark, headline and primary action fading up a beat apart on first load.
Related patterns
- Value Prop CarouselIntro slides advance on their own, with the artwork crossfading a beat behind the copy.
- Template Gallery PickThe chosen thumbnail grows in place into a preview of the workspace it would create.
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.