All patterns

First Action Nudge

After a pause with nothing pressed, a slow halo starts breathing out of the one button worth pressing.

onboardingfriendlysubtleautomatic · looping · starter · ~1.5s
Variant

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.

264 lines · react + motion only
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.

Empty workspaceOnboarding flowIdle first-run statePrimary action emphasis

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    An empty list keeps a single soft emphasis on the one control that resolves it.

Related patterns