"use client";
import { useEffect, useRef, useState } from "react";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
export type KeycastCorner = "bottom-left" | "bottom-center" | "bottom-right";
export type KeycastPlatform = "mac" | "pc";
export interface KeycastProps {
lifetime?: number;
maxVisible?: number;
corner?: KeycastCorner;
combineRepeats?: boolean;
platform?: KeycastPlatform;
demo?: boolean;
capSize?: number;
accent?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
type Mod = "ctrl" | "alt" | "shift" | "meta";
interface Entry {
id: number;
mods: Mod[];
key: string;
count: number;
born: number;
}
const MOD_ORDER: Mod[] = ["ctrl", "alt", "shift", "meta"];
const MOD_GLYPHS: Record<KeycastPlatform, Record<Mod, string>> = {
mac: { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" },
pc: { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" },
};
const KEY_LABELS: Record<string, string> = {
" ": "Space",
Escape: "Esc",
Enter: "⏎",
Backspace: "⌫",
Delete: "⌦",
Tab: "⇥",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
CapsLock: "⇪",
};
const MODIFIER_KEYS = new Set(["Control", "Alt", "Shift", "Meta"]);
const DEMO: Array<{ mods: Mod[]; key: string }> = [
{ mods: ["meta"], key: "K" },
{ mods: ["shift", "meta"], key: "P" },
{ mods: [], key: "G" },
{ mods: [], key: "G" },
{ mods: [], key: "D" },
{ mods: [], key: "D" },
{ mods: ["alt"], key: "⇥" },
{ mods: ["meta"], key: "Z" },
];
const DEMO_AFTER = 3.5;
const signature = (mods: Mod[], key: string) => `${mods.join("+")}·${key}`;
const Keycast = ({
lifetime = 2.4,
maxVisible = 5,
corner = "bottom-center",
combineRepeats = true,
platform = "mac",
demo = true,
capSize = 34,
accent = "#c4b5fd",
paused = false,
reducedMotion = false,
className = "",
}: KeycastProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const rowRefs = useRef(new Map<number, HTMLDivElement>());
const [entries, setEntries] = useState<Entry[]>([]);
const entriesRef = useRef(entries);
entriesRef.current = entries;
const live = useRef({ lifetime, maxVisible, combineRepeats, demo, paused, reducedMotion });
live.current = { lifetime, maxVisible, combineRepeats, demo, paused, reducedMotion };
const clock = useRef({ t: 0, quiet: 0, demoIdx: 0, demoWait: 1.2, nextId: 1 });
const addChord = (mods: Mod[], key: string) => {
const c = clock.current;
const cfg = live.current;
setEntries((prev) => {
const last = prev[prev.length - 1];
if (cfg.combineRepeats && last && signature(last.mods, last.key) === signature(mods, key)) {
return [...prev.slice(0, -1), { ...last, count: last.count + 1, born: c.t }];
}
const next = [...prev, { id: c.nextId++, mods, key, count: 1, born: c.t }];
return next.length > cfg.maxVisible ? next.slice(next.length - cfg.maxVisible) : next;
});
loop.start();
};
const loop = useAnimationLoop({
target: containerRef,
halted: paused,
onFrame: ({ dt }) => {
const c = clock.current;
const cfg = live.current;
c.t += dt;
c.quiet += dt;
if (cfg.demo && c.quiet > DEMO_AFTER) {
c.demoWait -= dt;
if (c.demoWait <= 0) {
const chord = DEMO[c.demoIdx % DEMO.length];
c.demoIdx++;
c.demoWait = 0.85 + (c.demoIdx % 3) * 0.4;
addChord(chord.mods, chord.key);
}
}
const list = entriesRef.current;
let expired = false;
for (const entry of list) {
const el = rowRefs.current.get(entry.id);
const age = c.t - entry.born;
if (age > cfg.lifetime) {
expired = true;
continue;
}
if (!el) continue;
if (cfg.reducedMotion) {
if (el.style.opacity !== "1") {
el.style.opacity = "1";
el.style.transform = "none";
}
continue;
}
const enter = Math.min(age / 0.16, 1);
const exit = Math.min(Math.max((cfg.lifetime - age) / 0.45, 0), 1);
const opacity = Math.min(enter, exit);
const scale = 0.85 + 0.15 * (1 - (1 - enter) * (1 - enter));
const rise = (1 - exit) * -6;
const o = opacity.toFixed(3);
if (el.style.opacity !== o) {
el.style.opacity = o;
el.style.transform = `translateY(${rise.toFixed(1)}px) scale(${scale.toFixed(3)})`;
}
}
if (expired) {
setEntries((prev) => prev.filter((entry) => c.t - entry.born <= cfg.lifetime));
}
if (list.length === 0 && !cfg.demo) return false;
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (MODIFIER_KEYS.has(e.key)) return;
if (e.repeat && !live.current.combineRepeats) return;
const target = e.target;
if (target instanceof HTMLElement) {
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable)
return;
}
const mods = MOD_ORDER.filter((m) =>
m === "ctrl" ? e.ctrlKey : m === "alt" ? e.altKey : m === "shift" ? e.shiftKey : e.metaKey,
);
const label =
KEY_LABELS[e.key] ?? (e.key.length === 1 ? e.key.toUpperCase() : e.key);
clock.current.quiet = 0;
addChord(mods, label);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loop.start();
}, [paused, demo, loop]);
const align =
corner === "bottom-left"
? "items-start"
: corner === "bottom-right"
? "items-end"
: "items-center";
const cap = (text: string, isMod: boolean, key: string) => (
<span
key={key}
className="inline-flex items-center justify-center rounded-md border font-mono"
style={{
height: capSize,
minWidth: capSize * 0.92,
paddingInline: capSize * 0.22,
fontSize: capSize * 0.4,
color: isMod ? accent : "#d6d3e4",
borderColor: isMod ? `color-mix(in oklab, ${accent} 45%, transparent)` : "#3a3a46",
background: "linear-gradient(180deg, #26262f 0%, #1a1a21 100%)",
borderBottomWidth: 3,
boxShadow: "0 2px 0 rgba(0,0,0,0.4)",
}}
>
{text}
</span>
);
return (
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className}`.trim()}>
<div
aria-hidden="true"
className={`absolute inset-x-6 bottom-6 flex flex-col justify-end gap-2 ${align}`}
>
{entries.map((entry, i) => (
<div
key={entry.id}
ref={(el) => {
if (el) rowRefs.current.set(entry.id, el);
else rowRefs.current.delete(entry.id);
}}
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5"
style={{
opacity: reducedMotion ? 1 : 0,
background: "rgba(10,10,14,0.55)",
boxShadow:
i === entries.length - 1
? `0 0 0 1px color-mix(in oklab, ${accent} 35%, transparent)`
: "0 0 0 1px rgba(58,58,70,0.5)",
}}
>
{entry.mods.map((m) => cap(MOD_GLYPHS[platform][m], true, `${entry.id}-${m}`))}
{cap(entry.key, false, `${entry.id}-key`)}
{entry.count > 1 && (
<span
className="ml-0.5 font-mono text-[11px] tabular-nums"
style={{ color: accent }}
>
×{entry.count}
</span>
)}
</div>
))}
</div>
</div>
);
};
export default Keycast;