Kbd
Free, copy-and-go Kbd components built on the SevenUI Kbd primitive.Read the primitive docs.
- Compact
- Dense tables and menu rows
- Command K⌘K
- Default
- Tooltips, buttons and inline copy
- Command K⌘K
- Large
- Onboarding tips and empty states
- Command K⌘K
- Display
- Shortcut cheat sheets and hero callouts
- Command K⌘K
"use client";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
const sizes = [
{
label: "Compact",
usage: "Dense tables and menu rows",
className: "h-4 min-w-4 rounded-[3px] px-0.5 text-[0.625rem]",
},
{
label: "Default",
usage: "Tooltips, buttons and inline copy",
className: "",
},
{
label: "Large",
usage: "Onboarding tips and empty states",
className: "h-7 min-w-7 rounded-md px-1.5 text-sm",
},
{
label: "Display",
usage: "Shortcut cheat sheets and hero callouts",
className: "h-10 min-w-10 rounded-lg px-2.5 text-lg",
},
];
export default function Kbd01() {
return (
<dl className="flex w-full max-w-md flex-col divide-y divide-border rounded-xl border bg-card text-card-foreground">
{sizes.map((size) => (
<div
key={size.label}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-sm font-medium">{size.label}</dt>
<dd className="text-xs text-muted-foreground">{size.usage}</dd>
</div>
<dd className="shrink-0">
<span className="sr-only">Command K</span>
<KbdGroup aria-hidden="true">
<Kbd className={size.className}>⌘</Kbd>
<Kbd className={size.className}>K</Kbd>
</KbdGroup>
</dd>
</div>
))}
</dl>
);
}
npx shadcn@latest add @sevenui/component/kbd-01pnpm dlx shadcn@latest add @sevenui/component/kbd-01yarn dlx shadcn@latest add @sevenui/component/kbd-01bunx --bun shadcn@latest add @sevenui/component/kbd-01- Shift Command P⇧⌘PSubtleThe default muted fill.
- Shift Command P⇧⌘POutlineA hairline border on a transparent face.
- Shift Command P⇧⌘PKeycapA raised face with a thicker bottom edge.
- Shift Command P⇧⌘PSolidHigh emphasis for the primary action.
- Shift Command P⇧⌘PGhostText only, for the quietest hint.
"use client";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
const styles = [
{
label: "Subtle",
description: "The default muted fill.",
className: "",
},
{
label: "Outline",
description: "A hairline border on a transparent face.",
className: "border border-border bg-transparent text-foreground",
},
{
label: "Keycap",
description: "A raised face with a thicker bottom edge.",
className:
"h-6 min-w-6 rounded-md border border-b-[3px] border-border bg-background px-1.5 text-foreground shadow-xs",
},
{
label: "Solid",
description: "High emphasis for the primary action.",
className: "bg-primary text-primary-foreground",
},
{
label: "Ghost",
description: "Text only, for the quietest hint.",
className: "min-w-0 bg-transparent px-0 text-muted-foreground",
},
];
export default function Kbd02() {
return (
<ul className="grid w-full max-w-md grid-cols-1 gap-3 min-[360px]:grid-cols-2">
{styles.map((style, index) => (
<li
key={style.label}
className={
index === styles.length - 1
? "overflow-hidden rounded-lg border bg-card text-card-foreground min-[360px]:col-span-2"
: "overflow-hidden rounded-lg border bg-card text-card-foreground"
}
>
<div className="flex h-16 items-center justify-center border-b bg-background">
<span className="sr-only">Shift Command P</span>
<KbdGroup aria-hidden="true">
<Kbd className={style.className}>⇧</Kbd>
<Kbd className={style.className}>⌘</Kbd>
<Kbd className={style.className}>P</Kbd>
</KbdGroup>
</div>
<div className="flex flex-col gap-0.5 px-3 py-2.5">
<span className="text-sm font-medium">{style.label}</span>
<span className="text-xs text-muted-foreground">
{style.description}
</span>
</div>
</li>
))}
</ul>
);
}
npx shadcn@latest add @sevenui/component/kbd-02pnpm dlx shadcn@latest add @sevenui/component/kbd-02yarn dlx shadcn@latest add @sevenui/component/kbd-02bunx --bun shadcn@latest add @sevenui/component/kbd-02Icon only
Icon with label
"use client";
import {
ArrowBigUpIcon,
ArrowDownIcon,
ArrowUpIcon,
ChevronUpIcon,
CommandIcon,
CornerDownLeftIcon,
DeleteIcon,
OptionIcon,
} from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
const keys = [
{ name: "Command", icon: CommandIcon },
{ name: "Shift", icon: ArrowBigUpIcon },
{ name: "Option", icon: OptionIcon },
{ name: "Control", icon: ChevronUpIcon },
{ name: "Return", icon: CornerDownLeftIcon },
{ name: "Delete", icon: DeleteIcon },
{ name: "Arrow up", icon: ArrowUpIcon },
{ name: "Arrow down", icon: ArrowDownIcon },
];
export default function Kbd03() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<section aria-labelledby="icon-only-label" className="flex flex-col gap-3">
<h3
id="icon-only-label"
className="text-xs font-medium text-muted-foreground"
>
Icon only
</h3>
<div className="flex flex-wrap gap-2">
{keys.map(({ name, icon: Icon }) => (
<Kbd key={name} className="size-7">
<Icon aria-hidden="true" className="size-3.5" />
<span className="sr-only">{name}</span>
</Kbd>
))}
</div>
</section>
<section aria-labelledby="icon-label-label" className="flex flex-col gap-3">
<h3
id="icon-label-label"
className="text-xs font-medium text-muted-foreground"
>
Icon with label
</h3>
<div className="flex flex-wrap gap-2">
{keys.slice(0, 6).map(({ name, icon: Icon }) => (
<Kbd key={name} className="h-7 gap-1.5 px-2">
<Icon aria-hidden="true" className="size-3.5" />
{name}
</Kbd>
))}
</div>
</section>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-03pnpm dlx shadcn@latest add @sevenui/component/kbd-03yarn dlx shadcn@latest add @sevenui/component/kbd-03bunx --bun shadcn@latest add @sevenui/component/kbd-03- Open command menuChord⌘K
- Go to inboxSequenceGthenI
- Reopen closed tabChord⌘⇧T
- Clear formattingSequence⌘Kthen⌘\
"use client";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
const bindings = [
{
action: "Open command menu",
kind: "Chord",
steps: [["⌘", "K"]],
},
{
action: "Go to inbox",
kind: "Sequence",
steps: [["G"], ["I"]],
},
{
action: "Reopen closed tab",
kind: "Chord",
steps: [["⌘", "⇧", "T"]],
},
{
action: "Clear formatting",
kind: "Sequence",
steps: [["⌘", "K"], ["⌘", "\\"]],
},
];
function Chord({ keys }: { keys: string[] }) {
return (
<KbdGroup className="gap-0.5">
{keys.map((key, index) => (
<span key={key} className="inline-flex items-center gap-0.5">
{index > 0 ? (
<span aria-hidden="true" className="text-xs text-muted-foreground">
+
</span>
) : null}
<Kbd>{key}</Kbd>
</span>
))}
</KbdGroup>
);
}
export default function Kbd04() {
return (
<div className="w-full max-w-md overflow-hidden rounded-xl border bg-card text-card-foreground">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b bg-muted/50 px-4 py-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Kbd className="bg-background">A</Kbd>+
<Kbd className="bg-background">B</Kbd>
together
</span>
<span className="inline-flex items-center gap-1.5">
<Kbd className="bg-background">A</Kbd>
then
<Kbd className="bg-background">B</Kbd>
in order
</span>
</div>
<ul className="divide-y divide-border">
{bindings.map((binding) => (
<li
key={binding.action}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="flex min-w-0 flex-col">
<span className="text-sm">{binding.action}</span>
<span className="text-xs text-muted-foreground">
{binding.kind}
</span>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5">
{binding.steps.map((step, index) => (
<span
key={step.join("+")}
className="inline-flex items-center gap-1.5"
>
{index > 0 ? (
<span className="text-xs text-muted-foreground">then</span>
) : null}
<Chord keys={step} />
</span>
))}
</span>
</li>
))}
</ul>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-04pnpm dlx shadcn@latest add @sevenui/component/kbd-04yarn dlx shadcn@latest add @sevenui/component/kbd-04bunx --bun shadcn@latest add @sevenui/component/kbd-04- Duplicate layerCommand D⌘D
- Group selectionCommand G⌘G
- Export as PNGShift Command E⇧⌘E
- Copy propertiesOption Command C⌥⌘C
"use client";
import * as React from "react";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Platform = "mac" | "windows";
const modifiers: Record<Platform, Record<string, { glyph: string; name: string }>> =
{
mac: {
mod: { glyph: "⌘", name: "Command" },
alt: { glyph: "⌥", name: "Option" },
shift: { glyph: "⇧", name: "Shift" },
},
windows: {
mod: { glyph: "Ctrl", name: "Control" },
alt: { glyph: "Alt", name: "Alt" },
shift: { glyph: "Shift", name: "Shift" },
},
};
const shortcuts = [
{ action: "Duplicate layer", keys: ["mod", "D"] },
{ action: "Group selection", keys: ["mod", "G"] },
{ action: "Export as PNG", keys: ["shift", "mod", "E"] },
{ action: "Copy properties", keys: ["alt", "mod", "C"] },
];
export default function Kbd05() {
const [platform, setPlatform] = React.useState<Platform>("mac");
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<span id="kbd-platform-label" className="text-sm font-medium">
Show shortcuts for
</span>
<ToggleGroup
aria-labelledby="kbd-platform-label"
variant="outline"
size="sm"
spacing={0}
value={[platform]}
onValueChange={(next) => {
// Keep one platform selected at all times.
if (next.length > 0) setPlatform(next[0] as Platform);
}}
>
<ToggleGroupItem value="mac">macOS</ToggleGroupItem>
<ToggleGroupItem value="windows">Windows</ToggleGroupItem>
</ToggleGroup>
</div>
<ul className="divide-y divide-border rounded-xl border bg-card text-card-foreground">
{shortcuts.map((shortcut) => {
const keys = shortcut.keys.map(
(key) => modifiers[platform][key] ?? { glyph: key, name: key },
);
return (
<li
key={shortcut.action}
className="flex items-center justify-between gap-3 px-3 py-2.5 text-sm sm:px-4"
>
<span className="min-w-0 truncate">{shortcut.action}</span>
<span className="sr-only">
{keys.map((key) => key.name).join(" ")}
</span>
<KbdGroup aria-hidden="true" className="shrink-0">
{keys.map((key) => (
<Kbd
key={`${platform}-${key.name}`}
className="animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none"
>
{key.glyph}
</Kbd>
))}
</KbdGroup>
</li>
);
})}
</ul>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-05pnpm dlx shadcn@latest add @sevenui/component/kbd-05yarn dlx shadcn@latest add @sevenui/component/kbd-05bunx --bun shadcn@latest add @sevenui/component/kbd-05Column 3 of 5, row 3 of 5
Focus the panel, then hold the arrow keys to see each keycap press down.
"use client";
import * as React from "react";
import { cn } from "cn";
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
} from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
const arrows = {
ArrowUp: { label: "Up", icon: ArrowUpIcon },
ArrowLeft: { label: "Left", icon: ArrowLeftIcon },
ArrowDown: { label: "Down", icon: ArrowDownIcon },
ArrowRight: { label: "Right", icon: ArrowRightIcon },
} as const;
type ArrowKey = keyof typeof arrows;
function isArrowKey(key: string): key is ArrowKey {
return key in arrows;
}
function Keycap({ code, pressed }: { code: ArrowKey; pressed: boolean }) {
const { icon: Icon } = arrows[code];
return (
<Kbd
data-pressed={pressed ? "" : undefined}
className={cn(
"size-11 rounded-lg border border-b-4 border-border bg-background text-foreground shadow-xs transition-[translate,border-width,background-color,box-shadow] duration-100 ease-out motion-reduce:transition-none",
"data-pressed:translate-y-[3px] data-pressed:border-b data-pressed:border-primary/40 data-pressed:bg-accent data-pressed:shadow-none",
)}
>
<Icon aria-hidden="true" className="size-4" />
</Kbd>
);
}
export default function Kbd06() {
const [pressed, setPressed] = React.useState<Set<ArrowKey>>(new Set());
const [position, setPosition] = React.useState({ x: 0, y: 0 });
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isArrowKey(event.key)) return;
event.preventDefault();
const key = event.key;
setPressed((current) => new Set(current).add(key));
const dx = key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0;
const dy = key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0;
// Keep the dot inside the 5 x 5 grid.
setPosition((current) => ({
x: Math.max(-2, Math.min(2, current.x + dx)),
y: Math.max(-2, Math.min(2, current.y + dy)),
}));
};
const onKeyUp = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isArrowKey(event.key)) return;
const key = event.key;
setPressed((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
};
return (
<div className="flex w-full max-w-xs flex-col items-center gap-4">
<div
role="application"
aria-label="Arrow key playground"
aria-describedby="kbd-arrows-hint"
// biome-ignore lint/a11y/noNoninteractiveTabindex: the region captures arrow keys to animate the keycaps.
tabIndex={0}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => setPressed(new Set())}
className="flex w-full flex-col items-center gap-5 rounded-xl border bg-muted/40 p-6 outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<div
aria-hidden="true"
className="relative grid size-24 grid-cols-5 grid-rows-5 rounded-lg border border-dashed border-border bg-background"
>
<span
className="size-3 place-self-center rounded-full bg-primary"
style={{
gridColumn: position.x + 3,
gridRow: position.y + 3,
}}
/>
</div>
<div aria-hidden="true" className="grid grid-cols-3 gap-1.5">
<span />
<Keycap code="ArrowUp" pressed={pressed.has("ArrowUp")} />
<span />
<Keycap code="ArrowLeft" pressed={pressed.has("ArrowLeft")} />
<Keycap code="ArrowDown" pressed={pressed.has("ArrowDown")} />
<Keycap code="ArrowRight" pressed={pressed.has("ArrowRight")} />
</div>
<p aria-live="polite" className="sr-only">
{`Column ${position.x + 3} of 5, row ${position.y + 3} of 5`}
</p>
</div>
<p
id="kbd-arrows-hint"
className="text-center text-xs text-balance text-muted-foreground"
>
Focus the panel, then hold the arrow keys to see each keycap press
down.
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-06pnpm dlx shadcn@latest add @sevenui/component/kbd-06yarn dlx shadcn@latest add @sevenui/component/kbd-06bunx --bun shadcn@latest add @sevenui/component/kbd-06Press ⌘Enter to send the reply.
Press ⌘Enter to send the reply.
Press ⌘Enter to send the reply.
"use client";
import { CheckIcon, SendIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
export default function Kbd07() {
const [done, setDone] = React.useState<"saved" | "sent" | null>(null);
// Clear the confirmation shortly after each click.
React.useEffect(() => {
if (!done) return;
const id = window.setTimeout(() => setDone(null), 1600);
return () => window.clearTimeout(id);
}, [done]);
return (
<div className="grid w-full max-w-md gap-3">
<div className="flex flex-col gap-3 rounded-xl border bg-background p-4">
<span className="text-xs font-medium text-muted-foreground">
On background
</span>
<p className="text-sm">
Press{" "}
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>Enter</Kbd>
</KbdGroup>{" "}
to send the reply.
</p>
</div>
<div className="flex flex-col gap-3 rounded-xl bg-muted p-4">
<span className="text-xs font-medium text-muted-foreground">
On muted
</span>
<p className="text-sm">
Press{" "}
<KbdGroup>
<Kbd className="bg-background shadow-xs">⌘</Kbd>
<Kbd className="bg-background shadow-xs">Enter</Kbd>
</KbdGroup>{" "}
to send the reply.
</p>
</div>
<div className="flex flex-col gap-3 rounded-xl bg-primary p-4 text-primary-foreground">
<span className="text-xs font-medium text-primary-foreground/80">
On primary
</span>
<p className="text-sm">
Press{" "}
<KbdGroup>
<Kbd className="bg-primary-foreground/15 text-primary-foreground">
⌘
</Kbd>
<Kbd className="bg-primary-foreground/15 text-primary-foreground">
Enter
</Kbd>
</KbdGroup>{" "}
to send the reply.
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<p aria-live="polite" className="sr-only">
{done === "saved" ? "Draft saved" : done === "sent" ? "Reply sent" : ""}
</p>
<Button variant="outline" onClick={() => setDone("saved")}>
{done === "saved" ? (
<CheckIcon aria-hidden="true" data-icon="inline-start" />
) : null}
{done === "saved" ? "Saved" : "Save draft"}
<Kbd>⌘ S</Kbd>
</Button>
<Button onClick={() => setDone("sent")}>
{done === "sent" ? (
<CheckIcon aria-hidden="true" data-icon="inline-start" />
) : (
<SendIcon aria-hidden="true" data-icon="inline-start" />
)}
{done === "sent" ? "Sent" : "Send reply"}
<Kbd className="bg-primary-foreground/15 text-primary-foreground">
⌘ ⏎
</Kbd>
</Button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-07pnpm dlx shadcn@latest add @sevenui/component/kbd-07yarn dlx shadcn@latest add @sevenui/component/kbd-07bunx --bun shadcn@latest add @sevenui/component/kbd-07Lena Fischer
Billing · Ticket 4821
- Lena: Hi! My March invoice shows two charges for the Team plan.
- You: Thanks, Lena. I can see both charges. Let me check what happened.
"use client";
import { SendHorizontal } from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { Textarea } from "@/components/ui/textarea";
type Message = { id: number; from: "agent" | "customer"; text: string };
const initialMessages: Message[] = [
{
id: 1,
from: "customer",
text: "Hi! My March invoice shows two charges for the Team plan.",
},
{
id: 2,
from: "agent",
text: "Thanks, Lena. I can see both charges. Let me check what happened.",
},
];
export default function Kbd08() {
const [messages, setMessages] = React.useState(initialMessages);
const [draft, setDraft] = React.useState("");
const [shiftHeld, setShiftHeld] = React.useState(false);
const listRef = React.useRef<HTMLOListElement>(null);
const canSend = draft.trim().length > 0;
React.useEffect(() => {
const list = listRef.current;
if (list && messages.length > 0) {
list.scrollTop = list.scrollHeight;
}
}, [messages]);
function send() {
if (!canSend) return;
setMessages((prev) => [
...prev,
{ id: prev.length + 1, from: "agent", text: draft.trim() },
]);
setDraft("");
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
setShiftHeld(event.shiftKey);
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}
return (
<section
aria-label="Support conversation with Lena Fischer"
className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<header className="flex items-center gap-3 border-b px-4 py-3">
<Avatar className="size-8">
<AvatarFallback>LF</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">Lena Fischer</p>
<p className="truncate text-xs text-muted-foreground">
Billing · Ticket 4821
</p>
</div>
</header>
<ol
ref={listRef}
aria-label="Messages"
className="flex max-h-56 flex-col gap-2 overflow-y-auto px-4 py-4"
>
{messages.map((message) => (
<li
key={message.id}
className={
message.from === "agent"
? "ml-8 self-end rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-sm text-primary-foreground"
: "mr-8 self-start rounded-2xl rounded-bl-sm bg-muted px-3 py-2 text-sm"
}
>
<span className="sr-only">
{message.from === "agent" ? "You: " : "Lena: "}
</span>
<span className="whitespace-pre-wrap">{message.text}</span>
</li>
))}
</ol>
<form
className="border-t p-3"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<label htmlFor="kbd-08-reply" className="sr-only">
Reply to Lena
</label>
<Textarea
id="kbd-08-reply"
value={draft}
rows={2}
placeholder="Write a reply…"
aria-describedby="kbd-08-hint"
className="max-h-32 resize-none"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
onKeyUp={(event) => setShiftHeld(event.shiftKey)}
onBlur={() => setShiftHeld(false)}
/>
<div className="mt-2 flex items-center justify-between gap-3">
<p
id="kbd-08-hint"
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
>
<span
className={
shiftHeld
? "flex items-center gap-1.5 opacity-50 transition-opacity"
: "flex items-center gap-1.5 transition-opacity"
}
>
<Kbd>Enter</Kbd>
<span>to send</span>
</span>
<span
className={
shiftHeld
? "flex items-center gap-1.5 text-foreground transition-colors"
: "flex items-center gap-1.5 transition-colors"
}
>
<KbdGroup>
<Kbd
className={
shiftHeld ? "bg-primary text-primary-foreground" : undefined
}
>
Shift
</Kbd>
<span aria-hidden="true">+</span>
<Kbd>Enter</Kbd>
</KbdGroup>
<span>for a new line</span>
</span>
</p>
<Button type="submit" size="sm" disabled={!canSend}>
Send
<SendHorizontal aria-hidden="true" data-icon="inline-end" />
</Button>
</div>
</form>
</section>
);
}
npx shadcn@latest add @sevenui/component/kbd-08pnpm dlx shadcn@latest add @sevenui/component/kbd-08yarn dlx shadcn@latest add @sevenui/component/kbd-08bunx --bun shadcn@latest add @sevenui/component/kbd-08Learn three shortcuts
Step 2 of 4Most of the team never touches the mouse. Try each shortcut in the practice area below.
- CtrlK
Open search (not yet)
Jump to any project, issue, or teammate.
- C
Create an issue (not yet)
Works from anywhere, no mouse needed.
- GthenI
Go to your inbox (not yet)
Press G, let go, then press I.
0 of 3 shortcuts practiced
"use client";
import { Check } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { Progress } from "@/components/ui/progress";
type LessonId = "search" | "create" | "inbox";
const lessons: { id: LessonId; label: string; detail: string }[] = [
{
id: "search",
label: "Open search",
detail: "Jump to any project, issue, or teammate.",
},
{
id: "create",
label: "Create an issue",
detail: "Works from anywhere, no mouse needed.",
},
{
id: "inbox",
label: "Go to your inbox",
detail: "Press G, let go, then press I.",
},
];
// Two-key sequences ("G then I") must complete within this window.
const SEQUENCE_WINDOW_MS = 1200;
function usePrimaryModifier() {
const [label, setLabel] = React.useState("Ctrl");
React.useEffect(() => {
if (/Mac|iPhone|iPad/.test(navigator.userAgent)) setLabel("⌘");
}, []);
return label;
}
export default function Kbd09() {
const modifier = usePrimaryModifier();
const [done, setDone] = React.useState<LessonId[]>([]);
const [lastPress, setLastPress] = React.useState<string | null>(null);
const [exit, setExit] = React.useState<"skipped" | "finished" | null>(null);
const pendingG = React.useRef(0);
const complete = (id: LessonId) =>
setDone((prev) => (prev.includes(id) ? prev : [...prev, id]));
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const key = event.key.toLowerCase();
if (["shift", "control", "meta", "alt", "tab"].includes(key)) return;
if ((event.metaKey || event.ctrlKey) && key === "k") {
// Keep the page's own ⌘K search from opening while practicing. The
// app may listen on the same node React does, so stop it immediately.
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
complete("search");
setLastPress(`${modifier} K`);
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (key === "c") {
complete("create");
} else if (key === "g") {
pendingG.current = Date.now();
} else if (
key === "i" &&
Date.now() - pendingG.current < SEQUENCE_WINDOW_MS
) {
complete("inbox");
pendingG.current = 0;
setLastPress("G then I");
return;
}
setLastPress(event.key.length === 1 ? event.key.toUpperCase() : event.key);
}
const progress = Math.round((done.length / lessons.length) * 100);
const finished = done.length === lessons.length;
function restart() {
setDone([]);
setLastPress(null);
setExit(null);
}
if (exit) {
return (
<div className="flex w-full max-w-md flex-col items-center gap-3 rounded-xl border bg-card p-8 text-center text-card-foreground">
<span className="flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check aria-hidden="true" className="size-5" />
</span>
<h3 className="text-base font-semibold">
{exit === "finished" ? "Shortcuts learned" : "Skipped for now"}
</h3>
<p className="text-sm text-balance text-muted-foreground">
{exit === "finished"
? "You practiced all three. On to step 3 of 4."
: "You can come back to the shortcut lesson any time."}
</p>
<Button variant="outline" size="sm" onClick={restart}>
Practice again
</Button>
</div>
);
}
return (
<div className="w-full max-w-md rounded-xl border bg-card p-5 text-card-foreground">
<div className="flex items-baseline justify-between gap-4">
<h3 className="text-base font-semibold">Learn three shortcuts</h3>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
Step 2 of 4
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Most of the team never touches the mouse. Try each shortcut in the
practice area below.
</p>
<Progress
value={progress}
aria-label="Shortcuts practiced"
className="mt-4"
/>
<ul className="mt-4 divide-y">
{lessons.map((lesson) => {
const isDone = done.includes(lesson.id);
return (
<li key={lesson.id} className="flex items-center gap-3 py-3">
<span
className={
isDone
? "flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-colors"
: "flex size-5 shrink-0 items-center justify-center rounded-full border border-border transition-colors"
}
>
{isDone && <Check aria-hidden="true" className="size-3" />}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{lesson.label}
<span className="sr-only">
{isDone ? " (done)" : " (not yet)"}
</span>
</p>
<p className="text-xs text-muted-foreground">{lesson.detail}</p>
</div>
{lesson.id === "search" && (
<KbdGroup>
<Kbd>{modifier}</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
)}
{lesson.id === "create" && <Kbd>C</Kbd>}
{lesson.id === "inbox" && (
<KbdGroup>
<Kbd>G</Kbd>
<span className="font-sans text-xs text-muted-foreground">then</span>
<Kbd>I</Kbd>
</KbdGroup>
)}
</li>
);
})}
</ul>
<section
// biome-ignore lint/a11y/noNoninteractiveTabindex: focusable practice surface that captures key presses
tabIndex={0}
aria-label="Shortcut practice area. Focus here and press a shortcut."
onKeyDown={handleKeyDown}
className="mt-2 flex h-20 flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed bg-muted/40 text-center outline-none focus-visible:border-ring focus-visible:bg-muted/60 focus-visible:ring-3 focus-visible:ring-ring/50"
>
{lastPress ? (
<>
<span className="text-xs text-muted-foreground">You pressed</span>
<Kbd className="h-6 px-2 text-sm text-foreground">{lastPress}</Kbd>
</>
) : (
<span className="px-4 text-sm text-muted-foreground">
Click here, then press a shortcut
</span>
)}
</section>
<p aria-live="polite" className="sr-only">
{`${done.length} of ${lessons.length} shortcuts practiced`}
</p>
<div className="mt-5 flex items-center justify-end gap-2">
<Button variant="ghost" onClick={() => setExit("skipped")}>
Skip for now
</Button>
<Button disabled={!finished} onClick={() => setExit("finished")}>
Continue
</Button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-09pnpm dlx shadcn@latest add @sevenui/component/kbd-09yarn dlx shadcn@latest add @sevenui/component/kbd-09bunx --bun shadcn@latest add @sevenui/component/kbd-09Keyboard shortcuts
Select a shortcut and press the keys you want to use instead.
- New message
- Reply
- Archive conversation
- Snooze until tomorrow
- Search mail
"use client";
import { RotateCcw, TriangleAlert } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
type Binding = string[];
type Action = { id: string; label: string; defaultBinding: Binding };
const actions: Action[] = [
{ id: "compose", label: "New message", defaultBinding: ["Alt", "N"] },
{ id: "reply", label: "Reply", defaultBinding: ["R"] },
{ id: "archive", label: "Archive conversation", defaultBinding: ["E"] },
{ id: "snooze", label: "Snooze until tomorrow", defaultBinding: ["H"] },
{ id: "search", label: "Search mail", defaultBinding: ["/"] },
];
const MODIFIER_KEYS = ["Shift", "Control", "Alt", "Meta"];
const keyLabels: Record<string, string> = {
" ": "Space",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Backspace: "Backspace",
Enter: "Enter",
};
function bindingFromEvent(event: React.KeyboardEvent): Binding {
const keys: Binding = [];
if (event.ctrlKey) keys.push("Ctrl");
if (event.metaKey) keys.push("⌘");
if (event.altKey) keys.push("Alt");
if (event.shiftKey) keys.push("Shift");
// On macOS, Option composes characters (Option+N types "˜"), so read the
// physical key for letters and digits whenever Alt is held.
const physical = /^(?:Key([A-Z])|Digit(\d))$/.exec(event.code);
const main =
event.altKey && physical
? (physical[1] ?? physical[2])
: (keyLabels[event.key] ??
(event.key.length === 1 ? event.key.toUpperCase() : event.key));
keys.push(main);
return keys;
}
const sameBinding = (a: Binding, b: Binding) => a.join("+") === b.join("+");
const defaults = () =>
Object.fromEntries(actions.map((a) => [a.id, a.defaultBinding]));
function Shortcut({ keys }: { keys: Binding }) {
return (
<KbdGroup>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
);
}
export default function Kbd10() {
const [bindings, setBindings] =
React.useState<Record<string, Binding>>(defaults);
const [recording, setRecording] = React.useState<string | null>(null);
const [conflict, setConflict] = React.useState<{
actionId: string;
binding: Binding;
usedBy: string;
} | null>(null);
const isCustomized = actions.some(
(a) => !sameBinding(bindings[a.id], a.defaultBinding),
);
function stopRecording() {
setRecording(null);
setConflict(null);
}
function handleRecordKey(
event: React.KeyboardEvent<HTMLButtonElement>,
actionId: string,
) {
if (recording !== actionId) return;
if (event.key === "Tab") {
stopRecording();
return;
}
event.preventDefault();
// While recording, the keys belong to this field, not to page-level
// shortcuts such as "/" or ⌘K search.
event.nativeEvent.stopImmediatePropagation();
if (event.key === "Escape") {
stopRecording();
return;
}
if (MODIFIER_KEYS.includes(event.key)) return;
const next = bindingFromEvent(event);
const owner = actions.find(
(a) => a.id !== actionId && sameBinding(bindings[a.id], next),
);
if (owner) {
setConflict({ actionId, binding: next, usedBy: owner.label });
return;
}
setBindings((prev) => ({ ...prev, [actionId]: next }));
stopRecording();
}
return (
<section
aria-labelledby="kbd-10-title"
className="w-full max-w-lg rounded-xl border bg-card text-card-foreground"
>
<header className="flex items-start justify-between gap-4 border-b p-5">
<div>
<h3 id="kbd-10-title" className="text-base font-semibold">
Keyboard shortcuts
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Select a shortcut and press the keys you want to use instead.
</p>
</div>
<Button
variant="ghost"
size="sm"
disabled={!isCustomized}
onClick={() => {
setBindings(defaults());
stopRecording();
}}
>
<RotateCcw aria-hidden="true" data-icon="inline-start" />
Reset
</Button>
</header>
<ul className="divide-y">
{actions.map((action) => {
const isRecording = recording === action.id;
const rowConflict =
conflict?.actionId === action.id ? conflict : null;
const changed = !sameBinding(
bindings[action.id],
action.defaultBinding,
);
return (
<li key={action.id} className="px-5 py-3">
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 text-sm">
{action.label}
{changed && (
<span className="ml-2 text-xs text-muted-foreground">
Edited
</span>
)}
</span>
<button
type="button"
aria-label={
isRecording
? `Recording shortcut for ${action.label}. Press keys, or Escape to cancel.`
: `${action.label}: ${bindings[action.id].join(" ")}. Press to change.`
}
aria-pressed={isRecording}
onClick={() => {
setConflict(null);
setRecording(isRecording ? null : action.id);
}}
onKeyDown={(event) => handleRecordKey(event, action.id)}
onBlur={() => isRecording && stopRecording()}
className={
isRecording
? "flex h-8 min-w-28 shrink-0 items-center justify-center rounded-md border border-ring bg-background px-2 text-xs text-foreground ring-3 ring-ring/50 outline-none"
: "flex h-8 min-w-28 shrink-0 items-center justify-end rounded-md border border-transparent px-2 outline-none transition-colors hover:border-border hover:bg-muted/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
}
>
{isRecording ? (
rowConflict ? (
<Shortcut keys={rowConflict.binding} />
) : (
<span className="motion-safe:animate-pulse">Press keys…</span>
)
) : (
<Shortcut keys={bindings[action.id]} />
)}
</button>
</div>
{isRecording && (
<p
aria-live="polite"
className={
rowConflict
? "mt-2 flex items-center justify-end gap-1.5 text-xs text-destructive"
: "mt-2 flex items-center justify-end gap-1.5 text-xs text-muted-foreground"
}
>
{rowConflict ? (
<>
<TriangleAlert aria-hidden="true" className="size-3.5" />
Already used by “{rowConflict.usedBy}”. Try another.
</>
) : (
<>
Press <Kbd>Esc</Kbd> to cancel
</>
)}
</p>
)}
</li>
);
})}
</ul>
</section>
);
}
npx shadcn@latest add @sevenui/component/kbd-10pnpm dlx shadcn@latest add @sevenui/component/kbd-10yarn dlx shadcn@latest add @sevenui/component/kbd-10bunx --bun shadcn@latest add @sevenui/component/kbd-10Shared drive / Marketing
6 items"use client";
import {
Copy,
FileArchive,
FileImage,
FileSpreadsheet,
FileText,
Folder,
PencilLine,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
type Kind = "folder" | "doc" | "sheet" | "image" | "archive";
type FileEntry = { id: string; name: string; kind: Kind; meta: string };
const initialFiles: FileEntry[] = [
{ id: "f1", name: "Brand assets", kind: "folder", meta: "24 items" },
{ id: "f2", name: "Q3 roadmap.pdf", kind: "doc", meta: "1.2 MB" },
{ id: "f3", name: "Payroll Sept.xlsx", kind: "sheet", meta: "88 KB" },
{ id: "f4", name: "Hero photo.jpg", kind: "image", meta: "3.4 MB" },
{ id: "f5", name: "Contracts", kind: "folder", meta: "9 items" },
{ id: "f6", name: "Launch kit.zip", kind: "archive", meta: "56 MB" },
];
const icons = {
folder: Folder,
doc: FileText,
sheet: FileSpreadsheet,
image: FileImage,
archive: FileArchive,
};
const COLUMNS = 3;
function usePrimaryModifier() {
const [label, setLabel] = React.useState("Ctrl");
React.useEffect(() => {
if (/Mac|iPhone|iPad/.test(navigator.userAgent)) setLabel("⌘");
}, []);
return label;
}
function copyName(name: string) {
const dot = name.lastIndexOf(".");
return dot > 0
? `${name.slice(0, dot)} copy${name.slice(dot)}`
: `${name} copy`;
}
export default function Kbd11() {
const modifier = usePrimaryModifier();
const [files, setFiles] = React.useState(initialFiles);
const [active, setActive] = React.useState(0);
const [renaming, setRenaming] = React.useState<string | null>(null);
const [draftName, setDraftName] = React.useState("");
const [deleted, setDeleted] = React.useState<{
file: FileEntry;
index: number;
} | null>(null);
const tileRefs = React.useRef<(HTMLDivElement | null)[]>([]);
// Set by Escape so the blur that follows does not save the draft name.
const cancelRename = React.useRef(false);
const current = files[active];
// The undo bar replaces the actions, so let it go after a few seconds.
React.useEffect(() => {
if (!deleted) return;
const id = window.setTimeout(() => setDeleted(null), 6000);
return () => window.clearTimeout(id);
}, [deleted]);
function focusTile(index: number) {
const next = Math.max(0, Math.min(files.length - 1, index));
setActive(next);
tileRefs.current[next]?.focus();
}
function startRename() {
if (!current) return;
setDeleted(null);
cancelRename.current = false;
setDraftName(current.name);
setRenaming(current.id);
}
function commitRename() {
if (!renaming) return;
const name = draftName.trim();
if (name && !cancelRename.current) {
setFiles((prev) =>
prev.map((f) => (f.id === renaming ? { ...f, name } : f)),
);
}
cancelRename.current = false;
setRenaming(null);
requestAnimationFrame(() => tileRefs.current[active]?.focus());
}
function duplicate() {
if (!current) return;
setDeleted(null);
const copy = {
...current,
id: `${current.id}-${Date.now()}`,
name: copyName(current.name),
};
setFiles((prev) => [
...prev.slice(0, active + 1),
copy,
...prev.slice(active + 1),
]);
setActive(active + 1);
requestAnimationFrame(() => tileRefs.current[active + 1]?.focus());
}
function remove() {
if (!current) return;
setDeleted({ file: current, index: active });
setFiles((prev) => prev.filter((f) => f.id !== current.id));
const next = Math.max(0, Math.min(active, files.length - 2));
setActive(next);
requestAnimationFrame(() => tileRefs.current[next]?.focus());
}
function undo() {
if (!deleted) return;
const { file, index } = deleted;
setFiles((prev) => [...prev.slice(0, index), file, ...prev.slice(index)]);
setActive(index);
setDeleted(null);
requestAnimationFrame(() => tileRefs.current[index]?.focus());
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (renaming) return;
const mod = event.metaKey || event.ctrlKey;
const key = event.key;
if (key === "ArrowRight") focusTile(active + 1);
else if (key === "ArrowLeft") focusTile(active - 1);
else if (key === "ArrowDown") focusTile(active + COLUMNS);
else if (key === "ArrowUp") focusTile(active - COLUMNS);
else if (key === "Home") focusTile(0);
else if (key === "End") focusTile(files.length - 1);
else if (key === "F2" || (key === "Enter" && !mod)) startRename();
else if (key === "Delete" || key === "Backspace") remove();
else if (mod && key.toLowerCase() === "d") duplicate();
else if (mod && key.toLowerCase() === "z") undo();
else return;
event.preventDefault();
}
return (
<section
aria-labelledby="kbd-11-title"
className="w-full max-w-md overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<header className="flex items-baseline justify-between gap-3 px-4 pt-4 pb-3">
<h3 id="kbd-11-title" className="text-sm font-semibold">
Shared drive{" "}
<span className="text-muted-foreground">/ Marketing</span>
</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{files.length} items
</span>
</header>
<div
role="listbox"
aria-label="Files"
aria-orientation="horizontal"
onKeyDown={handleKeyDown}
className="grid grid-cols-3 gap-2 px-4 pb-4"
>
{files.map((file, index) => {
const Icon = icons[file.kind];
const isActive = index === active;
const isRenaming = renaming === file.id;
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles arrow keys, F2, and Delete
<div
key={file.id}
ref={(el) => {
tileRefs.current[index] = el;
}}
role="option"
aria-selected={isActive}
tabIndex={isActive && !isRenaming ? 0 : -1}
onClick={() => setActive(index)}
onDoubleClick={() => {
setActive(index);
setDeleted(null);
cancelRename.current = false;
setDraftName(file.name);
setRenaming(file.id);
}}
className={
isActive
? "flex min-w-0 cursor-default flex-col items-center gap-1.5 rounded-lg bg-accent px-1.5 py-3 text-accent-foreground ring-1 ring-ring/40 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
: "flex min-w-0 cursor-default flex-col items-center gap-1.5 rounded-lg px-1.5 py-3 outline-none hover:bg-muted/60"
}
>
<Icon
aria-hidden="true"
className={
file.kind === "folder"
? "size-8 fill-muted stroke-[1.5] text-muted-foreground"
: "size-8 stroke-[1.5] text-muted-foreground"
}
/>
{isRenaming ? (
<input
aria-label={`Rename ${file.name}`}
value={draftName}
// biome-ignore lint/a11y/noAutofocus: the user explicitly asked to rename this file
autoFocus
onFocus={(event) => {
const dot = event.target.value.lastIndexOf(".");
event.target.setSelectionRange(
0,
dot > 0 ? dot : event.target.value.length,
);
}}
onChange={(event) => setDraftName(event.target.value)}
onBlur={commitRename}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") commitRename();
if (event.key === "Escape") {
cancelRename.current = true;
setRenaming(null);
tileRefs.current[index]?.focus();
}
}}
className="w-full min-w-0 rounded-sm border border-ring bg-background px-1 text-center text-xs outline-none ring-2 ring-ring/40"
/>
) : (
<span className="line-clamp-2 w-full text-center text-xs font-medium wrap-anywhere">
{file.name}
</span>
)}
<span className="text-[0.7rem] text-muted-foreground tabular-nums">
{file.meta}
</span>
</div>
);
})}
</div>
<footer className="border-t bg-muted/30 px-2 py-2">
{deleted ? (
<div
role="status"
className="flex items-center justify-between gap-2 px-2 text-xs"
>
<span className="min-w-0 truncate">
Moved “{deleted.file.name}” to trash
</span>
<Button variant="ghost" size="xs" onClick={undo}>
Undo
<KbdGroup>
<Kbd>{modifier}</Kbd>
<Kbd>Z</Kbd>
</KbdGroup>
</Button>
</div>
) : renaming ? (
<p className="flex items-center justify-center gap-3 px-2 py-0.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Kbd>Enter</Kbd> save
</span>
<span className="flex items-center gap-1.5">
<Kbd>Esc</Kbd> cancel
</span>
</p>
) : (
<div className="flex flex-wrap items-center justify-between gap-1">
<Button
variant="ghost"
size="xs"
disabled={!current}
onClick={startRename}
>
<PencilLine aria-hidden="true" data-icon="inline-start" />
Rename <Kbd>F2</Kbd>
</Button>
<Button
variant="ghost"
size="xs"
disabled={!current}
onClick={duplicate}
>
<Copy aria-hidden="true" data-icon="inline-start" />
Duplicate
<KbdGroup>
<Kbd>{modifier}</Kbd>
<Kbd>D</Kbd>
</KbdGroup>
</Button>
<Button
variant="ghost"
size="xs"
disabled={!current}
onClick={remove}
className="text-destructive hover:text-destructive"
>
<Trash2 aria-hidden="true" data-icon="inline-start" />
Delete <Kbd>Del</Kbd>
</Button>
</div>
)}
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/kbd-11pnpm dlx shadcn@latest add @sevenui/component/kbd-11yarn dlx shadcn@latest add @sevenui/component/kbd-11bunx --bun shadcn@latest add @sevenui/component/kbd-11Notifications3 unread
0 done · 0 snoozed
Priya Raman requested your review on Checkout redesign
Daniel Kim mentioned you in Q4 pricing experiment
Sara Okafor assigned you Fix VAT rounding on invoices
Marco Lenz resolved your comment on Onboarding checklist
Ava Brooks invited you to Growth team workspace
"use client";
import { CheckCheck } from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
type Notification = {
id: string;
initials: string;
actor: string;
action: string;
target: string;
time: string;
unread: boolean;
};
const initialNotifications: Notification[] = [
{
id: "n1",
initials: "PR",
actor: "Priya Raman",
action: "requested your review on",
target: "Checkout redesign",
time: "4m",
unread: true,
},
{
id: "n2",
initials: "DK",
actor: "Daniel Kim",
action: "mentioned you in",
target: "Q4 pricing experiment",
time: "22m",
unread: true,
},
{
id: "n3",
initials: "SO",
actor: "Sara Okafor",
action: "assigned you",
target: "Fix VAT rounding on invoices",
time: "1h",
unread: true,
},
{
id: "n4",
initials: "ML",
actor: "Marco Lenz",
action: "resolved your comment on",
target: "Onboarding checklist",
time: "3h",
unread: false,
},
{
id: "n5",
initials: "AB",
actor: "Ava Brooks",
action: "invited you to",
target: "Growth team workspace",
time: "Yesterday",
unread: false,
},
];
const hints = [
{ keys: ["J", "K"], label: "Move" },
{ keys: ["E"], label: "Done" },
{ keys: ["S"], label: "Snooze" },
{ keys: ["U"], label: "Read / unread" },
];
export default function Kbd12() {
const [items, setItems] = React.useState(initialNotifications);
const [active, setActive] = React.useState(0);
const [cleared, setCleared] = React.useState({ done: 0, snoozed: 0 });
const [status, setStatus] = React.useState("");
const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const unreadCount = items.filter((n) => n.unread).length;
function focusRow(index: number) {
const next = Math.max(0, Math.min(items.length - 1, index));
setActive(next);
rowRefs.current[next]?.focus();
}
function clear(kind: "done" | "snoozed") {
const item = items[active];
if (!item) return;
const remaining = items.filter((n) => n.id !== item.id);
setItems(remaining);
setCleared((c) => ({ ...c, [kind]: c[kind] + 1 }));
setStatus(
kind === "done"
? `Marked “${item.target}” as done`
: `Snoozed “${item.target}” until tomorrow`,
);
const next = Math.min(active, remaining.length - 1);
setActive(Math.max(0, next));
requestAnimationFrame(() => rowRefs.current[Math.max(0, next)]?.focus());
}
function reset() {
setItems(initialNotifications);
setActive(0);
setCleared({ done: 0, snoozed: 0 });
setStatus("Notifications restored");
requestAnimationFrame(() => rowRefs.current[0]?.focus());
}
function toggleRead() {
const item = items[active];
if (!item) return;
setItems((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, unread: !n.unread } : n)),
);
setStatus(item.unread ? "Marked as read" : "Marked as unread");
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const key = event.key.toLowerCase();
if (key === "j" || key === "arrowdown") focusRow(active + 1);
else if (key === "k" || key === "arrowup") focusRow(active - 1);
else if (key === "e") clear("done");
else if (key === "s") clear("snoozed");
else if (key === "u") toggleRead();
else return;
event.preventDefault();
}
return (
<section
aria-labelledby="kbd-12-title"
className="w-full max-w-md overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<header className="flex items-center justify-between gap-3 border-b px-4 py-3">
<h3 id="kbd-12-title" className="text-sm font-semibold">
Notifications
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-[0.7rem] font-medium text-primary-foreground tabular-nums">
{unreadCount}
<span className="sr-only"> unread</span>
</span>
)}
</h3>
<p className="text-xs text-muted-foreground tabular-nums">
{cleared.done} done · {cleared.snoozed} snoozed
</p>
</header>
{items.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center">
<CheckCheck
aria-hidden="true"
className="size-6 text-muted-foreground"
/>
<p className="text-sm font-medium">You’re all caught up</p>
<p className="text-xs text-muted-foreground">
New mentions and review requests will land here.
</p>
<Button variant="outline" size="sm" className="mt-2" onClick={reset}>
Restore notifications
</Button>
</div>
) : (
<div
role="listbox"
aria-label="Notifications. Use J and K to move, E to mark done, S to snooze, U to toggle read."
onKeyDown={handleKeyDown}
className="flex flex-col py-1"
>
{items.map((item, index) => {
const isActive = index === active;
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles J, K, E, S, and U
<div
key={item.id}
ref={(el) => {
rowRefs.current[index] = el;
}}
role="option"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => setActive(index)}
className={
isActive
? "group relative flex cursor-default items-start gap-3 bg-accent px-4 py-3 text-accent-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
: "group relative flex cursor-default items-start gap-3 px-4 py-3 outline-none hover:bg-muted/40"
}
>
<Avatar className="size-8">
<AvatarFallback className="text-xs">
{item.initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p
className={
item.unread
? "text-sm leading-snug"
: "text-sm leading-snug text-muted-foreground"
}
>
<span className="font-medium text-foreground">
{item.actor}
</span>{" "}
{item.action}{" "}
<span className="font-medium text-foreground">
{item.target}
</span>
</p>
<div className="mt-1 flex h-5 items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">{item.time}</span>
{isActive && (
<span
className="flex items-center gap-2"
aria-hidden="true"
>
<span>·</span>
<span className="flex items-center gap-1">
<Kbd className="bg-background">E</Kbd> Done
</span>
<span className="flex items-center gap-1">
<Kbd className="bg-background">S</Kbd> Snooze
</span>
</span>
)}
</div>
</div>
{item.unread && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unread</span>
</span>
)}
</div>
);
})}
</div>
)}
<p role="status" className="sr-only">
{status}
</p>
<footer className="flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t bg-muted/30 px-4 py-2.5 text-xs text-muted-foreground">
{hints.map((hint) => (
<span key={hint.label} className="flex items-center gap-1.5">
<span className="flex gap-1">
{hint.keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
{hint.label}
</span>
))}
</footer>
</section>
);
}
npx shadcn@latest add @sevenui/component/kbd-12pnpm dlx shadcn@latest add @sevenui/component/kbd-12yarn dlx shadcn@latest add @sevenui/component/kbd-12bunx --bun shadcn@latest add @sevenui/component/kbd-12Launch checklist
Final QA pass on the pricing page, then hand off the release notes to support before Thursday.
"use client";
import { Keyboard, Search } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Platform = "mac" | "windows";
// "mod" resolves to ⌘ on macOS and Ctrl elsewhere.
type Shortcut = { label: string; keys: string[] };
const groups: { title: string; shortcuts: Shortcut[] }[] = [
{
title: "General",
shortcuts: [
{ label: "Quick open", keys: ["mod", "P"] },
{ label: "Show keyboard shortcuts", keys: ["?"] },
{ label: "Open settings", keys: ["mod", ","] },
],
},
{
title: "Editing",
shortcuts: [
{ label: "Bold", keys: ["mod", "B"] },
{ label: "Insert link", keys: ["mod", "K"] },
{ label: "Strikethrough", keys: ["mod", "shift", "X"] },
{ label: "Undo", keys: ["mod", "Z"] },
{ label: "Redo", keys: ["mod", "shift", "Z"] },
],
},
{
title: "Navigation",
shortcuts: [
{ label: "Go to inbox", keys: ["G", "I"] },
{ label: "Go to projects", keys: ["G", "P"] },
{ label: "Toggle sidebar", keys: ["mod", "\\"] },
{ label: "Previous page", keys: ["alt", "←"] },
],
},
];
const symbols: Record<Platform, Record<string, string>> = {
mac: { mod: "⌘", shift: "⇧", alt: "⌥" },
windows: { mod: "Ctrl", shift: "Shift", alt: "Alt" },
};
const spoken: Record<string, string> = {
mod: "Command",
shift: "Shift",
alt: "Option",
};
const isSequence = (keys: string[]) =>
keys.length === 2 && keys.every((k) => /^[A-Z]$/.test(k));
function ShortcutKeys({
keys,
platform,
}: {
keys: string[];
platform: Platform;
}) {
const labels = keys.map((k) => symbols[platform][k] ?? k);
const readable = keys
.map((k) =>
platform === "mac" ? (spoken[k] ?? k) : (symbols.windows[k] ?? k),
)
.join(isSequence(keys) ? " then " : " plus ");
return (
<span className="shrink-0">
<span className="sr-only">{readable}</span>
<KbdGroup aria-hidden="true">
{labels.map((label, i) => (
<React.Fragment key={label}>
{isSequence(keys) && i > 0 && (
<span
aria-hidden="true"
className="text-xs text-muted-foreground"
>
then
</span>
)}
<Kbd>{label}</Kbd>
</React.Fragment>
))}
</KbdGroup>
</span>
);
}
export default function Kbd13() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [platform, setPlatform] = React.useState<Platform>("windows");
React.useEffect(() => {
if (/Mac|iPhone|iPad/.test(navigator.userAgent)) setPlatform("mac");
}, []);
const q = query.trim().toLowerCase();
const filtered = groups
.map((group) => ({
...group,
shortcuts: group.shortcuts.filter(
(s) =>
!q ||
s.label.toLowerCase().includes(q) ||
group.title.toLowerCase().includes(q),
),
}))
.filter((group) => group.shortcuts.length > 0);
function handleOpenChange(next: boolean) {
setOpen(next);
if (!next) setQuery("");
}
return (
<div className="w-full max-w-md">
{/* The editor surface listens for "?" the way most apps do. */}
<section
aria-label="Document editor. Press question mark for keyboard shortcuts."
// biome-ignore lint/a11y/noNoninteractiveTabindex: the editor region owns its "?" shortcut
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "?") {
event.preventDefault();
setOpen(true);
}
}}
className="rounded-xl border bg-card text-card-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<div className="space-y-2 px-5 pt-5 pb-6">
<p className="text-lg font-semibold">Launch checklist</p>
<p className="text-sm text-muted-foreground">
Final QA pass on the pricing page, then hand off the release notes
to support before Thursday.
</p>
</div>
<footer className="flex items-center justify-between gap-3 border-t px-3 py-2">
<span className="truncate pl-2 text-xs text-muted-foreground">
Saved · 2 min ago
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
aria-keyshortcuts="?"
>
<Keyboard aria-hidden="true" data-icon="inline-start" />
Shortcuts
<Kbd>?</Kbd>
</Button>
</footer>
</section>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(36rem,85vh)] flex-col gap-0 p-0 sm:max-w-md">
<DialogHeader className="gap-1 px-5 pt-5 pb-3">
<DialogTitle>Keyboard shortcuts</DialogTitle>
<DialogDescription>
Work faster without leaving the keyboard.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b px-5 pb-3">
<InputGroup className="flex-1">
<InputGroupAddon>
<Search aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search shortcuts"
placeholder="Search shortcuts"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
aria-label="Keyboard layout"
value={[platform]}
onValueChange={(value) => {
if (value[0]) setPlatform(value[0] as Platform);
}}
>
<ToggleGroupItem value="mac" aria-label="macOS keys">
Mac
</ToggleGroupItem>
<ToggleGroupItem value="windows" aria-label="Windows keys">
Win
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (
<div className="py-10 text-center">
<p className="text-sm font-medium">No shortcuts found</p>
<p className="mt-1 text-xs text-muted-foreground">
Nothing matches “{query}”. Try “bold” or “go to”.
</p>
</div>
) : (
filtered.map((group) => (
<section
key={group.title}
aria-labelledby={`kbd-13-${group.title}`}
className="py-2"
>
<h4
id={`kbd-13-${group.title}`}
className="pb-1 text-xs font-medium text-muted-foreground"
>
{group.title}
</h4>
<ul className="divide-y">
{group.shortcuts.map((shortcut) => (
<li
key={shortcut.label}
className="flex items-center justify-between gap-4 py-2 text-sm"
>
<span className="min-w-0">{shortcut.label}</span>
<ShortcutKeys
keys={shortcut.keys}
platform={platform}
/>
</li>
))}
</ul>
</section>
))
)}
</div>
<p className="border-t bg-muted/40 px-5 py-2.5 text-xs text-muted-foreground">
Press <Kbd>Esc</Kbd> to close
</p>
</DialogContent>
</Dialog>
</div>
);
}
npx shadcn@latest add @sevenui/component/kbd-13pnpm dlx shadcn@latest add @sevenui/component/kbd-13yarn dlx shadcn@latest add @sevenui/component/kbd-13bunx --bun shadcn@latest add @sevenui/component/kbd-13