All patterns

Shortcut Teaching Moment

A card rises with the shortcut and presses the keys itself, and the control it operates answers a beat later.

onboardingsubtlefuturisticautomatic · finite · intermediate · ~2.2s
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.

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

/**
 * Vibary · Shortcut Teaching Moment
 *
 * After the same thing has been done the slow way a few times, a card
 * rises with the shortcut and presses the keys itself — the caps sink
 * onto their own base, and the control they operate answers a beat
 * later.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `keys`, `eyebrow`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type KeyboardShortcutTeachProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Keys of the shortcut, in press order. */
  keys?: string[];
  /** Small line above the shortcut. */
  eyebrow?: string;
  /** What the shortcut does. */
  action?: string;
  /** Dismiss button label. */
  dismissLabel?: string;
  /** Key highlight and target ring color. */
  accent?: string;
  /** Fires when the card is dismissed. */
  onDismiss?: () => void;
};

type VariantConfig = {
  /** When the card arrives, in seconds. */
  cardDelay: number;
  /** How far the card rises, in px. */
  rise: number;
  /** Gap between the first key and the second going down, in seconds. */
  keyStagger: number;
  /** How many times the shortcut demonstrates itself. */
  demos: number;
  /** Rest between demonstrations, in seconds. */
  demoRest: number;
};

// Quality rule: a key press is 2px of travel, because that is what a key
// press is. Nothing scales — the caps carry labels, and a label that
// grows while being pressed reads as a cartoon. There are no springs in
// this file: a keycap that rebounds is a keycap nobody has ever used.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // One quiet demonstration, quick card. For a tip shown often.
  subtle: { cardDelay: 0.2, rise: 8, keyStagger: 0.1, demos: 1, demoRest: 0 },
  // Two demonstrations with a pause between. The all-purpose setting.
  default: { cardDelay: 0.3, rise: 12, keyStagger: 0.14, demos: 2, demoRest: 1.1 },
  // Slower hands and a longer look, for a first-run coach card.
  playful: { cardDelay: 0.4, rise: 16, keyStagger: 0.2, demos: 2, demoRest: 1.4 },
};

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

/** Length of one press-and-release, in seconds. */
const PRESS_SECONDS = 0.36;

export default function KeyboardShortcutTeach({
  variant = "default",
  keys = ["Ctrl", "K"],
  eyebrow = "You opened this from the menu three times",
  action = "Jump to search",
  dismissLabel = "Got it",
  accent = "#5B5BD6",
  onDismiss,
}: KeyboardShortcutTeachProps) {
  const [visible, setVisible] = useState(true);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const firstPressAt = cfg.cardDelay + 0.45;
  const cycle = PRESS_SECONDS + cfg.demoRest;
  const repeat = Math.max(0, cfg.demos - 1);

  return (
    <div
      style={{
        position: "relative",
        width: 320,
        height: 206,
        padding: 16,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        boxSizing: "border-box",
        overflow: "hidden",
      }}
    >
      {/* The surface the shortcut operates on. It answers the press, so
          the tip demonstrates a result and not just a gesture. */}
      <div style={{ position: "relative" }}>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            height: 34,
            padding: "0 11px",
            borderRadius: 10,
            border: `1px solid ${tone(14)}`,
            background: tone(5),
            fontSize: 12.5,
            opacity: 0.55,
          }}
        >
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
            <circle
              cx="7.2"
              cy="7.2"
              r="4.2"
              stroke="currentColor"
              strokeWidth="1.5"
            />
            <path
              d="m10.4 10.4 2.6 2.6"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
            />
          </svg>
          Search documents, people and settings
        </div>
        {!reduceMotion && (
          <motion.span
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: [0, 0.9, 0.9, 0] }}
            transition={{
              duration: 0.9,
              times: [0, 0.18, 0.55, 1],
              ease: "easeOut",
              delay: firstPressAt + cfg.keyStagger + 0.12,
              repeat,
              // Same cycle length as the caps, so the answer stays a
              // fixed beat behind the press on every demonstration.
              repeatDelay: Math.max(0, cycle - 0.9),
            }}
            style={{
              position: "absolute",
              inset: -2,
              borderRadius: 12,
              border: `1.5px solid ${accent}`,
              pointerEvents: "none",
            }}
          />
        )}
      </div>

      <div aria-hidden style={{ marginTop: 12, opacity: 0.4 }}>
        {[86, 64, 74].map((width, index) => (
          <div
            key={index}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 9,
              height: 26,
            }}
          >
            <span
              style={{
                width: 16,
                height: 16,
                borderRadius: 5,
                background: tone(14),
              }}
            />
            <span
              style={{
                width: `${width}%`,
                height: 6,
                borderRadius: 3,
                background: tone(12),
              }}
            />
          </div>
        ))}
      </div>

      <AnimatePresence>
        {visible && (
          <motion.div
            key="tip"
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
            transition={{
              duration: 0.3,
              delay: cfg.cardDelay,
              ease: "easeOut",
            }}
            style={{
              position: "absolute",
              left: 14,
              right: 14,
              bottom: 14,
              padding: "11px 12px",
              borderRadius: 13,
              boxSizing: "border-box",
              // Floats over the surface, so it must be opaque.
              // `Canvas`/`CanvasText` are the CSS system colors for page
              // background and text — light app, light card; dark app,
              // dark card.
              background: "Canvas",
              color: "CanvasText",
              border: `1px solid ${tone(14)}`,
              boxShadow: "0 14px 30px rgba(0,0,0,0.2)",
            }}
          >
            <div style={{ fontSize: 10.5, opacity: 0.45 }}>{eyebrow}</div>
            <div
              style={{
                display: "flex",
                alignItems: "center",
                justifyContent: "space-between",
                gap: 10,
                marginTop: 7,
              }}
            >
              <span
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 6,
                  fontSize: 12.5,
                }}
              >
                {keys.map((key, index) => (
                  <KeyCap
                    key={key}
                    label={key}
                    accent={accent}
                    press={
                      reduceMotion
                        ? null
                        : {
                            delay: firstPressAt + index * cfg.keyStagger,
                            repeat,
                            repeatDelay: cycle,
                          }
                    }
                  />
                ))}
                <span style={{ marginLeft: 3, opacity: 0.65 }}>{action}</span>
              </span>
              <button
                type="button"
                onClick={() => {
                  setVisible(false);
                  onDismiss?.();
                }}
                style={{
                  flex: "none",
                  padding: "5px 10px",
                  fontSize: 11.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: "transparent",
                  border: `1px solid ${tone(16)}`,
                  borderRadius: 8,
                  cursor: "pointer",
                }}
              >
                {dismissLabel}
              </button>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

/** A cap sitting on a fixed base. Pressing moves only the cap, so the
 *  2px lip disappears the way a real key's does — pure transform, no
 *  shadow animation. */
function KeyCap({
  label,
  accent,
  press,
}: {
  label: string;
  accent: string;
  press: { delay: number; repeat: number; repeatDelay: number } | null;
}) {
  return (
    <span
      style={{
        position: "relative",
        display: "inline-block",
        paddingBottom: 2,
      }}
    >
      <span
        aria-hidden
        style={{
          position: "absolute",
          left: 0,
          right: 0,
          top: 2,
          bottom: 0,
          borderRadius: 7,
          background: tone(20),
        }}
      />
      <motion.span
        animate={press ? { y: [0, 2, 2, 0] } : undefined}
        transition={
          press
            ? {
                duration: PRESS_SECONDS,
                times: [0, 0.2, 0.62, 1],
                ease: "easeOut",
                delay: press.delay,
                repeat: press.repeat,
                repeatDelay: press.repeatDelay,
              }
            : undefined
        }
        style={{
          position: "relative",
          display: "block",
          minWidth: 18,
          padding: "3px 7px",
          textAlign: "center",
          fontSize: 11,
          fontWeight: 650,
          lineHeight: 1.35,
          borderRadius: 7,
          // `color` is set first, so the two mixes below resolve against
          // the legend color: the cap face is a faint wash of its own
          // letter rather than a hardcoded grey. The base underneath it
          // stays neutral, mixed from the inherited text color.
          color: accent,
          background: tone(9),
          border: `1px solid ${tone(22)}`,
        }}
      >
        {label}
      </motion.span>
    </span>
  );
}

About this pattern

The tip that appears once someone has done the same thing the slow way a few times. A small card rises over the surface, and rather than describing the shortcut it performs it: each cap sinks two pixels onto its own fixed base — the way a real key loses its lip — and a moment after the last one lands, the field the shortcut operates lights up. Demonstrating the result is what makes the tip stick; a diagram of two keys does not. The caps are pure transform with no shadow animation and nothing scales, because a legend that grows while being pressed reads as a cartoon. The whole thing repeats once, then stops.

Power-user educationOnboarding flowCommand hintContextual tip

Where it shows up

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

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    Repeating a mouse action surfaces the keyboard equivalent in a small card.

Related patterns