First Action Nudge
After a pause with nothing pressed, a slow halo starts breathing out of the one button worth pressing.
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 · First Action Nudge
*
* An empty workspace waits, and after a decent pause a soft halo starts
* breathing out of the one button worth pressing. The loop is slow on
* purpose and stops the moment the person engages.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `actionLabel`, `hint`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FirstActionNudgeProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Label of the action being nudged. */
actionLabel?: string;
/** Label of the quieter alternative beside it. */
secondaryLabel?: string;
/** Line that appears with the nudge. */
hint?: string;
/** Seconds of inactivity before the nudge begins. */
hesitationSeconds?: number;
/** Halo and button color. */
accent?: string;
/** Fires when the nudged action is taken. */
onAction?: () => void;
};
type VariantConfig = {
/** How far the halo grows past the button. 1.2 is already generous. */
spread: number;
/** Opacity the halo starts each breath at. */
peak: number;
/** Length of one breath out, in seconds. */
breathSeconds: number;
/** Silence between breaths, in seconds. */
restSeconds: number;
};
// Quality rule: a loop that never ends must stay under the reader's
// attention, so this one is slow, low-contrast and mostly rest — the
// halo is out longer than it is in. No springs: a nudge that overshoots
// is a twitch. And the button itself never scales, because it contains
// text and text that pulses is unreadable exactly when it is being read.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost subliminal, long silences. For a nudge that may run for
// minutes while someone reads the rest of the page.
subtle: { spread: 1.08, peak: 0.18, breathSeconds: 1.8, restSeconds: 2.8 },
// Visible from the corner of the eye. The all-purpose setting.
default: { spread: 1.14, peak: 0.24, breathSeconds: 1.5, restSeconds: 2.1 },
// A wider, closer-spaced breath, for a one-screen empty state where
// the action is the entire point.
playful: { spread: 1.2, peak: 0.3, breathSeconds: 1.3, restSeconds: 1.6 },
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function FirstActionNudge({
variant = "default",
actionLabel = "New document",
secondaryLabel = "Import",
hint = "Start here — everything else can wait",
hesitationSeconds = 2.2,
accent = "#5B5BD6",
onAction,
}: FirstActionNudgeProps) {
// Any engagement ends the nudge. A hint that keeps pulsing after it
// has been noticed stops being a hint and becomes an advertisement.
const [engaged, setEngaged] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const nudging = !engaged;
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.4,
textTransform: "uppercase",
opacity: 0.4,
}}
>
<span>Documents</span>
<span>Empty</span>
</div>
<div
style={{
display: "grid",
placeItems: "center",
gap: 8,
height: 128,
marginTop: 10,
borderRadius: 14,
border: `1px dashed ${tone(14)}`,
background: tone(4),
}}
>
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" aria-hidden>
<rect
x="11"
y="7"
width="22"
height="28"
rx="4"
stroke="currentColor"
strokeWidth="1.4"
opacity="0.3"
/>
<path
d="M16 15h12M16 20h12M16 25h7"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
opacity="0.22"
/>
</svg>
<span style={{ fontSize: 12.5, opacity: 0.45 }}>
Nothing written here yet
</span>
</div>
<div style={{ position: "relative", marginTop: 14, height: 20 }}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: nudging ? 0.6 : 0 }}
transition={{
duration: 0.4,
delay: nudging && !reduceMotion ? hesitationSeconds : 0,
ease: "easeOut",
}}
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 3v9M4.6 8.6 8 12l3.4-3.4"
stroke={accent}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{hint}
</motion.div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
<span
// Reaching the button is engagement enough — the nudge has
// done its job before the click happens.
onPointerEnter={() => setEngaged(true)}
style={{ position: "relative", display: "inline-flex" }}
>
{/* The breath: a halo behind the button, never the button
itself. It grows out of the button's own silhouette and
fades before it gets far, which reads as attention rather
than as an alarm. */}
{nudging && !reduceMotion && (
<motion.span
aria-hidden
initial={{ opacity: 0, scale: 1 }}
animate={{ opacity: [0, cfg.peak, 0], scale: [1, 1, cfg.spread] }}
transition={{
duration: cfg.breathSeconds,
times: [0, 0.22, 1],
ease: "easeOut",
repeat: Infinity,
repeatDelay: cfg.restSeconds,
delay: hesitationSeconds,
}}
style={{
position: "absolute",
inset: -3,
borderRadius: 12,
background: accent,
pointerEvents: "none",
}}
/>
)}
{/* Reduced motion: the same emphasis, held still. */}
{nudging && reduceMotion && (
<span
aria-hidden
style={{
position: "absolute",
inset: -3,
borderRadius: 12,
border: `1.5px solid ${accent}`,
opacity: 0.45,
pointerEvents: "none",
}}
/>
)}
<button
type="button"
onClick={() => {
setEngaged(true);
onAction?.();
}}
onFocus={() => setEngaged(true)}
style={{
position: "relative",
padding: "9px 15px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
cursor: "pointer",
}}
>
{actionLabel}
</button>
</span>
<button
type="button"
onClick={() => setEngaged(true)}
style={{
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: `1px solid ${tone(18)}`,
borderRadius: 9,
cursor: "pointer",
}}
>
{secondaryLabel}
</button>
</div>
</div>
);
}About this pattern
The empty workspace problem: everything is ready and nobody has done anything. After a hesitation of a couple of seconds a halo begins to grow out of the primary button, once every few seconds, at a low enough contrast that it reads as attention rather than as an alarm. The button itself never moves — it holds a label, and a label that pulses is unreadable exactly when it is being read — so the loop lives entirely in a shape behind it. Engagement ends it: reaching the button with the pointer, focusing it or pressing it retires the halo permanently, because a hint that keeps going after it has been noticed has stopped being a hint.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
An empty list keeps a single soft emphasis on the one control that resolves it.
Related patterns
- Notification Opt-inAn example alert drops in and two more fan out behind it, then the permission question reads underneath.
- Permission PrimerThe page dims and an explanation rises, with a small diagram filling in to show what the permission would actually put on screen.
- Setup Checklist CompleteA finished task fills its box, draws its mark, sweeps a rule through the label, and moves the counter up by one.