All patterns

Skip Tour Dismiss

Leaving the tour early resolves it in order: the tip drops away, the ring opens and dissolves, the dim lifts last.

onboardingcalmsubtleinteraction · finite · starter · ~0.4s
Interactive · click to play
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.

440 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Skip Tour Dismiss
 *
 * Leaving a tour early, without slamming a door. The tip drops away
 * first, the ring opens and dissolves, the dim lifts last, and the
 * control that was raised above it settles flat back into the page.
 *
 * Self-contained: depends only on `react` and `motion`. The tip sits
 * above a scrim, so it uses the CSS system colors `Canvas`/`CanvasText`
 * and lands light in a light app and dark in a dark one.
 * Works with zero props; tune via `variant`, `title`, `body`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SkipTourDismissProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  title?: string;
  body?: string;
  skipLabel?: string;
  nextLabel?: string;
  /** Stage size. */
  width?: number;
  height?: number;
  /** Ring and primary button color. */
  accent?: string;
  /** Fires when the tour is left early. */
  onSkip?: () => void;
  /** Fires when the tour is continued instead. */
  onNext?: () => void;
};

type VariantConfig = {
  /** Seconds the dim takes to lift — the last thing to go. */
  scrimSeconds: number;
  /** Seconds the tip takes to drop away. */
  tipSeconds: number;
  /** px the tip drops as it leaves. */
  tipDrop: number;
  /** Scale the ring opens to before it dissolves. A shape, so it may. */
  ringTo: number;
  ringSeconds: number;
  /** px the raised control settles back down. */
  settle: number;
};

// Quality rule: this is the calm exit, so every curve is an ease-out and
// nothing springs. The order carries the meaning — tip, then ring, then
// dim — and variants change only the pace and the distance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a gesture. For a tour that can be left at any time.
  subtle: {
    scrimSeconds: 0.22,
    tipSeconds: 0.13,
    tipDrop: 3,
    ringTo: 1.03,
    ringSeconds: 0.2,
    settle: 1,
  },
  // Enough separation to read as three things resolving. All-purpose.
  default: {
    scrimSeconds: 0.3,
    tipSeconds: 0.16,
    tipDrop: 5,
    ringTo: 1.06,
    ringSeconds: 0.28,
    settle: 2,
  },
  // A slower release, for a tour that covered the whole screen.
  playful: {
    scrimSeconds: 0.38,
    tipSeconds: 0.2,
    tipDrop: 7,
    ringTo: 1.1,
    ringSeconds: 0.34,
    settle: 3,
  },
};

/** The control the tour is pointing at, in stage coordinates. */
const TARGET = { x: 226, y: 16, width: 78, height: 30, radius: 9 };
const TIP_WIDTH = 196;

/** 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)`;

const TIP_EDGE = "1px solid color-mix(in srgb, currentColor 14%, transparent)";

export default function SkipTourDismiss({
  variant = "default",
  title = "Share what you see",
  body = "Send a live link. Permissions stay where you set them.",
  skipLabel = "Skip tour",
  nextLabel = "Next",
  width = 320,
  height = 232,
  accent = "#5B5BD6",
  onSkip,
  onNext,
}: SkipTourDismissProps) {
  const [running, setRunning] = useState(true);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const tipLeft = Math.min(
    Math.max(TARGET.x + TARGET.width / 2 - TIP_WIDTH / 2, 12),
    width - TIP_WIDTH - 12
  );
  const tipTop = TARGET.y + TARGET.height + 12;

  const skip = () => {
    setRunning(false);
    onSkip?.();
  };

  return (
    <div
      style={{
        position: "relative",
        width,
        height,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
        overflow: "hidden",
      }}
    >
      <SampleSurface />

      <AnimatePresence>
        {running && (
          <motion.div
            key="dim"
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            // The dim is the last thing to go: the page coming back is
            // the reassurance, so it is given the longest curve.
            exit={{
              opacity: 0,
              transition: { duration: cfg.scrimSeconds, ease: "easeOut" },
            }}
            transition={{ duration: 0.26, ease: "easeOut" }}
            style={{
              position: "absolute",
              left: TARGET.x - 5,
              top: TARGET.y - 5,
              width: TARGET.width + 10,
              height: TARGET.height + 10,
              borderRadius: TARGET.radius + 5,
              // One element is the whole dim: a huge shadow spread
              // darkens everything outside this box while the box itself
              // stays clear. Only opacity animates, so it never touches
              // layout.
              boxShadow: "0 0 0 9999px rgba(8, 8, 12, 0.5)",
              pointerEvents: "none",
            }}
          />
        )}
      </AnimatePresence>

      {/* The control lives above the dim, which is what makes the hole
          read as the page showing through rather than a bright rectangle
          painted on top. */}
      <motion.div
        initial={false}
        animate={{
          y: running && !reduceMotion ? -cfg.settle : 0,
          boxShadow: running
            ? "0 8px 20px rgba(8,8,12,0.30)"
            : "0 0px 0px rgba(8,8,12,0)",
        }}
        transition={{ duration: 0.28, ease: "easeOut" }}
        style={{
          position: "absolute",
          left: TARGET.x,
          top: TARGET.y,
          width: TARGET.width,
          height: TARGET.height,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: 5,
          borderRadius: TARGET.radius,
          background: accent,
          color: "#ffffff",
          fontSize: 12,
          fontWeight: 650,
        }}
      >
        <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
          <path
            d="M6 8V1.8M6 1.8 3.7 4.1M6 1.8l2.3 2.3M2 7.6v1.6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V7.6"
            stroke="#ffffff"
            strokeWidth="1.3"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
        Share
      </motion.div>

      <AnimatePresence>
        {running && (
          <motion.div
            key="ring"
            aria-hidden
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 1.04 }}
            animate={{ opacity: 1, scale: 1 }}
            // The ring opens outward as it goes, so the dismissal reads
            // as release rather than retraction.
            exit={{
              opacity: 0,
              scale: reduceMotion ? 1 : cfg.ringTo,
              transition: { duration: cfg.ringSeconds, ease: "easeOut" },
            }}
            transition={{ duration: 0.24, ease: "easeOut" }}
            style={{
              position: "absolute",
              left: TARGET.x - 5,
              top: TARGET.y - 5,
              width: TARGET.width + 10,
              height: TARGET.height + 10,
              boxSizing: "border-box",
              borderRadius: TARGET.radius + 5,
              border: `2px solid ${accent}`,
              pointerEvents: "none",
            }}
          />
        )}
      </AnimatePresence>

      <AnimatePresence>
        {running && (
          <motion.div
            key="tip"
            role="dialog"
            aria-label={title}
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.tipDrop }}
            animate={{ opacity: 1, y: 0 }}
            // First out: the words stop applying the moment the tour is
            // left, so they should not linger over the returning page.
            exit={{
              opacity: 0,
              y: reduceMotion ? 0 : cfg.tipDrop,
              transition: { duration: cfg.tipSeconds, ease: "easeIn" },
            }}
            transition={{ duration: 0.26, delay: 0.06, ease: "easeOut" }}
            style={{
              position: "absolute",
              left: tipLeft,
              top: tipTop,
              width: TIP_WIDTH,
              boxSizing: "border-box",
              padding: 13,
              borderRadius: 12,
              // Above a scrim, so it cannot be translucent.
              // `Canvas`/`CanvasText` are the CSS system colors for page
              // background and page text: they follow the host app's
              // color scheme and always land as a legible pair.
              background: "Canvas",
              color: "CanvasText",
              border: TIP_EDGE,
              boxShadow: "0 14px 34px rgba(0,0,0,0.22)",
            }}
          >
            <div style={{ fontSize: 13, fontWeight: 650 }}>{title}</div>
            <p style={{ margin: "5px 0 0", fontSize: 11.5, lineHeight: 1.5, opacity: 0.66 }}>
              {body}
            </p>
            <div
              style={{
                display: "flex",
                alignItems: "center",
                gap: 8,
                marginTop: 12,
              }}
            >
              <button
                type="button"
                onClick={skip}
                style={{
                  padding: "6px 10px",
                  fontSize: 11.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: "transparent",
                  border: "none",
                  borderRadius: 8,
                  opacity: 0.55,
                  cursor: "pointer",
                }}
              >
                {skipLabel}
              </button>
              <button
                type="button"
                onClick={onNext}
                style={{
                  marginLeft: "auto",
                  padding: "6px 12px",
                  fontSize: 11.5,
                  fontWeight: 650,
                  fontFamily: "inherit",
                  color: "#ffffff",
                  background: accent,
                  border: "none",
                  borderRadius: 8,
                  cursor: "pointer",
                }}
              >
                {nextLabel}
              </button>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Sample-only: a real app would remember the choice. This keeps
          the dismissal watchable more than once. */}
      <AnimatePresence>
        {!running && (
          <motion.div
            key="left"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: 0.12 } }}
            transition={{ duration: 0.26, delay: cfg.scrimSeconds, ease: "easeOut" }}
            style={{
              position: "absolute",
              left: 0,
              right: 0,
              bottom: 14,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              gap: 9,
              fontSize: 11.5,
            }}
          >
            <span style={{ opacity: 0.5 }}>Tour skipped</span>
            <button
              type="button"
              onClick={() => setRunning(true)}
              style={{
                padding: "5px 10px",
                fontSize: 11.5,
                fontWeight: 600,
                fontFamily: "inherit",
                color: "inherit",
                background: tone(8),
                border: `1px solid ${tone(14)}`,
                borderRadius: 8,
                cursor: "pointer",
              }}
            >
              Restart tour
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

/** Stand-in product surface — the page the tour was covering. */
function SampleSurface() {
  const bars = [58, 34, 72, 46, 64];

  return (
    <div aria-hidden style={{ position: "absolute", inset: 0 }}>
      <div
        style={{
          position: "absolute",
          left: 16,
          top: 18,
          fontSize: 14,
          fontWeight: 680,
        }}
      >
        Weekly report
      </div>

      <div
        style={{
          position: "absolute",
          left: 16,
          top: 62,
          right: 16,
          height: 150,
          borderRadius: 13,
          border: `1px solid ${tone(11)}`,
          background: tone(4),
        }}
      />
      <div
        style={{
          position: "absolute",
          left: 32,
          top: 80,
          width: 92,
          height: 8,
          borderRadius: 4,
          background: tone(18),
        }}
      />
      <div
        style={{
          position: "absolute",
          left: 32,
          top: 96,
          width: 58,
          height: 6,
          borderRadius: 3,
          background: tone(12),
        }}
      />
      {bars.map((barHeight, index) => (
        <div
          key={index}
          style={{
            position: "absolute",
            left: 32 + index * 52,
            top: 192 - barHeight,
            width: 30,
            height: barHeight,
            borderRadius: 7,
            background: tone(13),
          }}
        />
      ))}
    </div>
  );
}

About this pattern

Most tours are dismissed, so the exit deserves as much care as the entrance. Three things leave in a deliberate order rather than at once: the tip goes first because its words stop applying the moment the tour is left, the ring opens outward as it dissolves so the dismissal reads as release rather than retraction, and the dim is given the longest curve because the page coming back is the reassurance. The control that had been raised above the overlay settles flat at the same time, which is what returns it to the page instead of leaving it stranded. Everything is an ease-out; nothing springs, because a bouncy exit argues with a decision already made.

Dismissing a product tourClosing coach marksOpting out of a walkthroughOverlay teardown

Where it shows up

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

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

    Guidance that releases the highlighted control gently rather than cutting away.

Related patterns