Display

Key hints

Shows each action's shortcut key on it while a modifier is held, and runs the action when that key is pressed.

Built on
aria-keyshortcuts<kbd>KeyboardEvent.code
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.1 kB
Dependencies
React
Hold Alt (Option on Mac) to see keys
Draft, not saved
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { KeyHint, KeyHints } from "@/components/equip/key-hints";

export default function Demo() {
  const [status, setStatus] = useState("Draft, not saved");

  return (
    <KeyHints style={{ display: "grid", gap: 10, justifyItems: "center" }}>
      <span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Hold Alt (Option on Mac) to see keys</span>
      <div style={{ display: "flex", gap: 8 }}>
        <KeyHint keys="s">
          <Button size="s" variant="outline" onClick={() => setStatus("Draft saved")}>
            Save
          </Button>
        </KeyHint>
        <KeyHint keys="p">
          <Button size="s" variant="outline" onClick={() => setStatus("Preview opened")}>
            Preview
          </Button>
        </KeyHint>
        <KeyHint keys="b">
          <Button size="s" variant="accent" onClick={() => setStatus("Published to blog")}>
            Publish
          </Button>
        </KeyHint>
      </div>
      <span role="status" style={{ fontSize: 13, color: "var(--eq-ink)" }}>
        {status}
      </span>
    </KeyHints>
  );
}

Examples

Another modifier

With Control, a matching key overrides the browser's own shortcut while the hint is shown, so avoid keys people rely on.

Hold Control to see keys
3 unread from Priya Natarajan
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { KeyHint, KeyHints } from "@/components/equip/key-hints";

export default function Example() {
  const [status, setStatus] = useState("3 unread from Priya Natarajan");

  return (
    <KeyHints modifier="Control" style={{ display: "grid", gap: 10 }}>
      <span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Hold Control to see keys</span>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
        <KeyHint keys="e">
          <Button size="s" variant="outline" onClick={() => setStatus("Thread archived")}>
            Archive
          </Button>
        </KeyHint>
        <KeyHint keys="y">
          <Button size="s" variant="outline" onClick={() => setStatus("Replying to Priya")}>
            Reply
          </Button>
        </KeyHint>
        <KeyHint keys="s">
          <Button size="s" variant="outline" onClick={() => setStatus("Snoozed until 09:00 tomorrow")}>
            Snooze
          </Button>
        </KeyHint>
      </div>
      <span role="status" style={{ fontSize: 13 }}>
        {status}
      </span>
    </KeyHints>
  );
}

Links in a nav list

The shortcut lands on the link itself through aria-keyshortcuts, so assistive tech can announce it without the caps.

Code
"use client";

import { useState, type MouseEvent } from "react";
import { KeyHint, KeyHints } from "@/components/equip/key-hints";

const pages = [
  { id: "overview", name: "Overview", keys: "o" },
  { id: "deployments", name: "Deployments", keys: "d" },
  { id: "settings", name: "Settings", keys: "g" },
];

export default function Example() {
  const [current, setCurrent] = useState("overview");

  return (
    <KeyHints style={{ width: 200 }}>
      <nav aria-label="Project">
        <ul style={{ display: "grid", gap: 2, margin: 0, padding: 0, listStyle: "none" }}>
          {pages.map((page) => (
            <li key={page.id}>
              <KeyHint keys={page.keys} style={{ display: "flex" }}>
                <a
                  href={`#${page.id}`}
                  aria-current={current === page.id ? "page" : undefined}
                  onClick={(event: MouseEvent<HTMLAnchorElement>) => {
                    event.preventDefault();
                    setCurrent(page.id);
                  }}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    flex: 1,
                    height: 28,
                    paddingInline: 8,
                    borderRadius: "var(--eq-radius-s)",
                    background: current === page.id ? "var(--eq-solid)" : "transparent",
                    color: current === page.id ? "var(--eq-solid-ink)" : "var(--eq-ink)",
                    fontSize: 13,
                    textDecoration: "none",
                  }}
                >
                  {page.name}
                </a>
              </KeyHint>
            </li>
          ))}
        </ul>
      </nav>
    </KeyHints>
  );
}

A form row

Keys work while focus is in the text field, and a named key such as Enter shows its name on the cap.

Reviewers: dana@example.com
Code
"use client";

import { useId, useState } from "react";
import { Button } from "@/components/equip/button";
import { Field } from "@/components/equip/field";
import { KeyHint, KeyHints } from "@/components/equip/key-hints";

export default function Example() {
  const id = useId();
  const [email, setEmail] = useState("");
  const [reviewers, setReviewers] = useState<string[]>(["dana@example.com"]);

  return (
    <KeyHints style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 8, width: "100%", maxWidth: 380 }}>
      <Field
        id={id}
        type="email"
        label="Add a reviewer"
        value={email}
        placeholder="name@company.com"
        onChange={(event) => setEmail(event.target.value)}
        action={
          <>
            <KeyHint keys="Enter">
              <Button
                onClick={() => {
                  if (!email.trim()) return;
                  setReviewers((list) => [...list, email.trim()]);
                  setEmail("");
                }}
              >
                Add
              </Button>
            </KeyHint>
            <KeyHint keys="x">
              <Button variant="outline" onClick={() => setReviewers([])}>
                Clear
              </Button>
            </KeyHint>
          </>
        }
      />
      <span role="status" style={{ fontSize: 13, color: "var(--eq-ink-2)" }}>
        {reviewers.length ? `Reviewers: ${reviewers.join(", ")}` : "No reviewers yet"}
      </span>
    </KeyHints>
  );
}

Props

PropTypeDefaultWhat it does
modifier"Alt" | "Control" | "Meta""Alt"The key held to show the caps and use them. Alt is Option on a Mac keyboard.
childrenReactNodenoneAny content; actions inside are marked with KeyHint.
...restComponentProps<"div">noneEvery native div attribute, on the wrapper.
KeyHint keysstringnoneOne letter or digit, such as s, or a key name such as Enter.
KeyHint childrenReactNodenoneExactly one focusable element, such as a button or link. It gets aria-keyshortcuts.
KeyHint ...restComponentProps<"span">noneEvery native span attribute, on the target wrapper.

Anatomy

root.eq-key-hintsA div. Gains data-active while the modifier is held.
target.eq-key-hints-targetAn inline wrapper around one action; also the cap's anchor.
cap.eq-key-hints-capA kbd at the action's top-right corner, hidden from assistive tech.

Keyboard

AltHold to show every cap in the wrapper; release to hide them.
AltkeyFocuses and clicks the action with that key.

Notes

  • Each action's shortcut is in aria-keyshortcuts on the control itself, so assistive tech can announce it without the caps.
  • Letters and digits match the physical key, so Option on a Mac, which changes the typed character, still works on QWERTY-style layouts.
  • The caps only show when the modifier is the only one held, so Alt with Shift is left to other tools.
  • With several wrappers on a page, all with that modifier show their caps; the one holding focus handles the key, else the one the pointer entered last, else the first.
  • A matching key is stopped from reaching the page's own shortcuts and the browser's; other keys pass through untouched.
  • Disabled actions show their cap but do nothing when their key is pressed.
  • On Windows, pressing and releasing Alt alone can move focus to the browser menu in some browsers.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/key-hints.tsx214 lines
"use client";

import {
  cloneElement,
  createContext,
  isValidElement,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  type ComponentProps,
  type PointerEvent,
  type ReactElement,
  type ReactNode,
  type Ref,
} from "react";
import "./key-hints.css";

type Modifier = "Alt" | "Control" | "Meta";
type Entry = { keys: string; target: () => HTMLElement | null };
type Registry = {
  modifier: Modifier;
  register: (id: string, entry: Entry) => () => void;
};

const KeyHintsContext = createContext<Registry | null>(null);

/** True when the chosen modifier is the only one held, so Alt+Shift (or Ctrl+Alt) is left alone. */
function held(event: KeyboardEvent, modifier: Modifier) {
  const down = { Alt: event.altKey, Control: event.ctrlKey, Meta: event.metaKey };
  return down[modifier] && !event.shiftKey && (Object.keys(down) as Modifier[]).every((m) => m === modifier || !down[m]);
}

/**
 * Single characters match on the physical key (event.code), because a held Option on macOS
 * turns "s" into "ß". Named keys such as Enter match on event.key.
 */
function matches(event: KeyboardEvent, keys: string) {
  if (keys.length === 1) {
    const ch = keys.toUpperCase();
    if (/[A-Z]/.test(ch)) return event.code === `Key${ch}`;
    if (/[0-9]/.test(ch)) return event.code === `Digit${ch}` || event.code === `Numpad${ch}`;
  }
  return event.key.toLowerCase() === keys.toLowerCase();
}

function label(keys: string) {
  return keys.length === 1 ? keys.toUpperCase() : keys;
}

function assignRef<T>(ref: Ref<T> | undefined, node: T | null) {
  if (typeof ref === "function") ref(node);
  else if (ref) ref.current = node;
}

function usable(el: HTMLElement) {
  return !el.matches(":disabled") && el.getAttribute("aria-disabled") !== "true";
}

export type KeyHintsProps = ComponentProps<"div"> & {
  /** The key the reader holds to show the hints and use them. */
  modifier?: Modifier;
  children: ReactNode;
};

export function KeyHints({ modifier = "Alt", className, children, ref, onPointerEnter, ...rest }: KeyHintsProps) {
  const root = useRef<HTMLDivElement | null>(null);
  const entries = useRef(new Map<string, Entry>());
  // One registry per instance; hints register into this instance's own Map.
  const registry = useMemo<Registry>(
    () => ({
      modifier,
      register: (id, entry) => {
        entries.current.set(id, entry);
        return () => {
          entries.current.delete(id);
        };
      },
    }),
    [modifier],
  );

  useEffect(() => {
    const own = root.current;
    if (!own) return;

    const show = (on: boolean) => own.toggleAttribute("data-active", on);

    /*
     * Every KeyHints with this modifier shows its caps. Only one acts on a key: the one holding
     * focus, else the one the pointer entered last, else the first in the document.
     * Each instance works this out from the DOM, so nothing is shared between them.
     */
    const winner = (event: KeyboardEvent) => {
      const candidates = [...document.querySelectorAll<HTMLElement>(".eq-key-hints")].filter(
        (r) =>
          r.dataset.modifier === modifier &&
          [...r.querySelectorAll<HTMLElement>(".eq-key-hints-target")].some((t) => {
            const control = t.firstElementChild;
            return (
              t.dataset.keys != null &&
              matches(event, t.dataset.keys) &&
              control instanceof HTMLElement &&
              usable(control)
            );
          }),
      );
      const focus = document.activeElement;
      const focused = candidates.filter((r) => focus && r.contains(focus));
      if (focused.length) return focused[focused.length - 1];
      let best: HTMLElement | undefined;
      for (const r of candidates) {
        const stamp = Number(r.dataset.pointerAt ?? -1);
        if (!best || stamp > Number(best.dataset.pointerAt ?? -1)) best = r;
      }
      return best;
    };

    const onKeyDown = (event: KeyboardEvent) => {
      const on = held(event, modifier);
      show(on);
      if (!on || event.repeat || event.key === modifier) return;
      const hit = [...entries.current.values()].find((e) => matches(event, e.keys));
      const target = hit?.target();
      if (!target || !usable(target) || winner(event) !== own) return;
      // Capturing: stop the page's own single-key shortcuts from also seeing this key.
      event.preventDefault();
      event.stopPropagation();
      target.focus();
      target.click();
    };
    const onKeyUp = (event: KeyboardEvent) => show(held(event, modifier));
    const onBlur = () => show(false);

    window.addEventListener("keydown", onKeyDown, true);
    window.addEventListener("keyup", onKeyUp, true);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("keydown", onKeyDown, true);
      window.removeEventListener("keyup", onKeyUp, true);
      window.removeEventListener("blur", onBlur);
      own.removeAttribute("data-active");
    };
  }, [modifier]);

  return (
    <KeyHintsContext.Provider value={registry}>
      <div
        {...rest}
        ref={(node) => {
          root.current = node;
          assignRef(ref, node);
        }}
        className={["eq-key-hints", className].filter(Boolean).join(" ")}
        data-modifier={modifier}
        onPointerEnter={(event: PointerEvent<HTMLDivElement>) => {
          onPointerEnter?.(event);
          event.currentTarget.dataset.pointerAt = String(event.timeStamp);
        }}
      >
        {children}
      </div>
    </KeyHintsContext.Provider>
  );
}

export type KeyHintProps = ComponentProps<"span"> & {
  /** One character such as "s", or a key name such as "Enter". */
  keys: string;
  /** Exactly one focusable element, such as a button or a link. */
  children: ReactNode;
};

export function KeyHint({ keys, className, children, ref, ...rest }: KeyHintProps) {
  const id = useId();
  const wrap = useRef<HTMLSpanElement | null>(null);
  const context = useContext(KeyHintsContext);
  const modifier = context?.modifier ?? "Alt";
  const shortcut = `${modifier}+${label(keys)}`;

  useEffect(() => {
    if (!context) return;
    return context.register(id, {
      keys,
      target: () => {
        const el = wrap.current?.firstElementChild;
        return el instanceof HTMLElement && !el.classList.contains("eq-key-hints-cap") ? el : null;
      },
    });
  }, [context, id, keys]);

  const child = isValidElement<{ "aria-keyshortcuts"?: string }>(children)
    ? cloneElement(children as ReactElement<{ "aria-keyshortcuts"?: string }>, { "aria-keyshortcuts": shortcut })
    : children;

  return (
    <span
      {...rest}
      ref={(node) => {
        wrap.current = node;
        assignRef(ref, node);
      }}
      className={["eq-key-hints-target", className].filter(Boolean).join(" ")}
      data-keys={keys}
      aria-keyshortcuts={child === children ? shortcut : undefined}
    >
      {child}
      <kbd className="eq-key-hints-cap" aria-hidden="true">
        {label(keys)}
      </kbd>
    </span>
  );
}
components/equip/key-hints.css54 lines
@layer equip {
  .eq-key-hints {
    display: block;
  }

  .eq-key-hints-target {
    position: relative;
    display: inline-flex;
    vertical-align: middle;
  }

  /* A keycap pinned to the control's top-right corner. Solid so it reads over any control
     and any finish, including translucent surfaces. */
  .eq-key-hints-cap {
    box-sizing: border-box;
    position: absolute;
    top: 0;
    right: 0;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 18px;
    height: 18px;
    padding-inline: calc(var(--eq-space-1) - 1px);
    border: 1px solid var(--eq-solid);
    border-bottom: 2px solid color-mix(in srgb, var(--eq-solid) 70%, var(--eq-solid-ink));
    border-radius: var(--eq-radius-s);
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
    box-shadow: var(--eq-shadow);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    letter-spacing: 0;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    translate: 35% -35%;
    scale: 0.7;
    /* Hiding is quick and plain. */
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  /* Showing, each cap pops onto its corner on the spring. */
  .eq-key-hints[data-active] .eq-key-hints-cap {
    opacity: 1;
    scale: 1;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index