Shortcut Teaching Moment
A card rises with the shortcut and presses the keys itself, and the control it operates answers a beat later.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
Repeating a mouse action surfaces the keyboard equivalent in a small card.
Related patterns
- Keyboard Shortcut FlashA key cap depresses briefly to teach the shortcut that just fired.
- First Action NudgeAfter a pause with nothing pressed, a slow halo starts breathing out of the one button worth pressing.
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.