All patterns

Keyboard Shortcut Flash

A key cap depresses briefly to teach the shortcut that just fired.

feedbacksubtlefuturisticautomatic · finite · intermediate · ~3.0s
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.

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

/**
 * Vibary · Keyboard Shortcut Flash
 *
 * The action already happened — someone reached for the row menu. A
 * strip surfaces in the status bar, the caps sink once in chord order,
 * and it retires on its own clock. Nothing to click, nothing to dismiss.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `keys`, `action`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type KeyboardShortcutFlashProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Caps of the chord. Everything before the last one is held. */
  keys?: string[];
  /** What the chord does. */
  action?: string;
  /** Left-hand status text. */
  status?: string;
  /** Legend color and press tint. */
  accent?: string;
  /** Fires once the strip has retired itself. */
  onRetire?: () => void;
};

type VariantConfig = {
  /** When the strip surfaces, in seconds. */
  arrive: number;
  /** How far the strip travels, in px. */
  rise: number;
  /** How long it stays after the caps release, in seconds. */
  dwell: number;
};

// Quality rule: a press is 2px of travel and nothing else. No scale, no
// spring, no repeat — a keycap that rebounds is a keycap nobody has ever
// used, and a hint that plays twice is a hint that has stopped being
// optional. The one spring here carries the strip, damped to 0.97 so it
// arrives without announcing itself.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely surfaces, leaves quickly. For an action done many times a day.
  subtle: { arrive: 0.25, rise: 4, dwell: 1.1 },
  // The all-purpose setting.
  default: { arrive: 0.35, rise: 6, dwell: 1.6 },
  // A longer look, for a chord worth a second glance.
  playful: { arrive: 0.45, rise: 9, dwell: 2.2 },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields a surface or border correctly toned in
 *  either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** One press-and-release, in seconds. */
const PRESS_SECONDS = 0.5;

/** Held caps sink first and stay down; the last cap taps inside that
 *  hold and lifts first. That is the shape of a real chord, and it is
 *  why this reads as a press rather than as a queue of presses. */
const HELD_TIMES = [0, 0.02, 0.09, 0.78, 0.9, 1];
const TAP_TIMES = [0, 0.24, 0.31, 0.6, 0.72, 1];

const ROWS = [
  { title: "Q3 roadmap", meta: "Edited 2h ago", fresh: false },
  { title: "Q3 roadmap copy", meta: "Edited just now", fresh: true },
  { title: "Pricing tiers", meta: "Edited yesterday", fresh: false },
  { title: "Team offsite notes", meta: "Edited Mar 4", fresh: false },
];

export default function KeyboardShortcutFlash({
  variant = "default",
  keys = ["Ctrl", "D"],
  action = "Duplicate",
  status = "4 documents",
  accent = "#4C8FD8",
  onRetire,
}: KeyboardShortcutFlashProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [showing, setShowing] = useState(true);

  const pressAt = cfg.arrive + 0.28;
  const lifeMs = Math.round((pressAt + PRESS_SECONDS + cfg.dwell) * 1000);

  useEffect(() => {
    // The strip retires itself. setState is scheduled from the timeout
    // callback, never run synchronously in the effect body.
    const timer = setTimeout(() => {
      setShowing(false);
      onRetire?.();
    }, lifeMs);
    return () => clearTimeout(timer);
  }, [lifeMs, onRetire]);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        width: 324,
        height: 208,
        borderRadius: 14,
        border: `1px solid ${tone(12)}`,
        background: tone(4),
        color: "inherit",
        boxSizing: "border-box",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          flex: "none",
          padding: "11px 13px",
          fontSize: 12.5,
          fontWeight: 650,
          borderBottom: `1px solid ${tone(10)}`,
        }}
      >
        Documents
      </div>

      <div style={{ flex: 1, minHeight: 0, padding: "4px 6px" }}>
        {ROWS.map((row) => (
          <div
            key={row.title}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 9,
              padding: "6px 7px",
              borderRadius: 8,
              // The duplicate is already on the list — it is what the
              // person just did. It is marked, not animated: a second
              // moving thing would compete with the only one that matters.
              background: row.fresh ? tone(6) : "transparent",
              boxShadow: row.fresh ? `inset 2px 0 0 ${accent}` : "none",
            }}
          >
            <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
              <path
                d="M4 2h5l3 3v9H4z"
                stroke="currentColor"
                strokeWidth="1.4"
                strokeLinejoin="round"
                opacity={0.5}
              />
              <path
                d="M9 2v3.2h3"
                stroke="currentColor"
                strokeWidth="1.4"
                strokeLinejoin="round"
                opacity={0.5}
              />
            </svg>
            <span style={{ flex: 1, fontSize: 12 }}>{row.title}</span>
            <span style={{ fontSize: 10.5, opacity: 0.4 }}>{row.meta}</span>
          </div>
        ))}
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 10,
          // Never shrink: the caps are 22px tall and the strip is the
          // only thing in this component that moves.
          flex: "none",
          height: 36,
          padding: "0 11px",
          borderTop: `1px solid ${tone(10)}`,
          background: tone(3),
        }}
      >
        <span style={{ fontSize: 10.5, opacity: 0.42 }}>{status}</span>

        <AnimatePresence>
          {showing && (
            <motion.span
              key="flash"
              // Not a live region: this is an aside about a thing that
              // already happened and already announced itself.
              aria-hidden
              initial={
                reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }
              }
              animate={{ opacity: 1, y: 0 }}
              exit={{
                opacity: 0,
                y: reduceMotion ? 0 : cfg.rise * 0.5,
                transition: { duration: 0.26, ease: "easeIn" },
              }}
              transition={
                reduceMotion
                  ? { duration: 0.2, delay: cfg.arrive, ease: "easeOut" }
                  : {
                      type: "spring",
                      stiffness: 520,
                      damping: 44,
                      delay: cfg.arrive,
                      opacity: { duration: 0.22, delay: cfg.arrive },
                    }
              }
              style={{
                display: "inline-flex",
                alignItems: "center",
                gap: 7,
                padding: "3px 7px 3px 9px",
                borderRadius: 8,
                background: tone(7),
              }}
            >
              <span style={{ fontSize: 10.5, opacity: 0.55 }}>{action}</span>
              <span
                aria-hidden
                style={{ width: 1, height: 11, background: tone(16) }}
              />
              {keys.map((cap, index) => (
                <KeyCap
                  key={cap}
                  label={cap}
                  accent={accent}
                  delay={
                    reduceMotion ? null : pressAt
                  }
                  held={index < keys.length - 1}
                />
              ))}
            </motion.span>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

/** A cap on a fixed base. Only the cap moves, so the 2px lip closes the
 *  way a real key's does — pure transform, no shadow animation. The
 *  accent tint is a separate layer fading in and out, because a
 *  `color-mix()` value is not something an animation can interpolate. */
function KeyCap({
  label,
  accent,
  delay,
  held,
}: {
  label: string;
  accent: string;
  delay: number | null;
  held: boolean;
}) {
  const times = held ? HELD_TIMES : TAP_TIMES;
  const press =
    delay === null
      ? undefined
      : {
          duration: PRESS_SECONDS,
          times,
          ease: "easeOut" as const,
          delay,
        };

  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: 6,
          background: tone(18),
        }}
      />
      <motion.span
        animate={press ? { y: [0, 0, 2, 2, 0, 0] } : undefined}
        transition={press}
        style={{
          position: "relative",
          display: "block",
          minWidth: 15,
          padding: "2px 5px",
          borderRadius: 6,
          background: tone(8),
          border: `1px solid ${tone(20)}`,
          overflow: "hidden",
        }}
      >
        <motion.span
          aria-hidden
          initial={{ opacity: 0 }}
          animate={press ? { opacity: [0, 0, 0.16, 0.16, 0, 0] } : { opacity: 0 }}
          transition={press}
          style={{
            position: "absolute",
            inset: 0,
            background: accent,
          }}
        />
        <span
          style={{
            position: "relative",
            display: "block",
            textAlign: "center",
            // A monospaced legend is what is printed on the key. It also
            // keeps every cap the same optical weight beside the label.
            fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
            fontSize: 10,
            fontWeight: 600,
            lineHeight: 1.4,
            letterSpacing: "0.01em",
            color: accent,
          }}
        >
          {label}
        </span>
      </motion.span>
    </span>
  );
}

About this pattern

The acknowledgement for something already finished. Someone reached for the row menu, the action landed, and a strip surfaces in the status bar naming the chord that would have done it in one gesture. The caps sink 2px onto their own fixed base in chord order — the held cap goes down first and comes up last, the letter taps inside that hold — so the strip performs the combination instead of listing it. Then it retires on its own clock, with no button to press and nothing asking to be acknowledged. That is the whole design constraint: this fires after a task the person completed successfully, so it has to be ignorable. Nothing scales, nothing repeats, and the press tint is a separate layer fading over the cap rather than an interpolated color.

Shortcut hint after a menu actionStatus bar acknowledgementProgressive discoverabilityIgnorable in-product hint

Where it shows up

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

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    Doing something through the interface surfaces the equivalent key combination in passing chrome.

Related patterns