diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx
index f3b03b5f8..1f0527ac9 100644
--- a/apps/www/src/content/docs/components/command/index.mdx
+++ b/apps/www/src/content/docs/components/command/index.mdx
@@ -97,7 +97,7 @@ Visual divider between groups. The separator is hidden automatically while the u
### Shortcut
-A `` element for keyboard hints. Typically passed as `trailingIcon` on `Command.Item`.
+Keyboard hints for an item, typically passed as `trailingIcon` on `Command.Item`. Built on [`Kbd`](/docs/components/kbd): it renders a `Kbd.Group` of `ghost` keys and forwards every prop.
diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts
index 5b1b54945..906c6580b 100644
--- a/apps/www/src/content/docs/components/command/props.ts
+++ b/apps/www/src/content/docs/components/command/props.ts
@@ -132,6 +132,18 @@ export interface CommandSeparatorProps {
}
export interface CommandShortcutProps {
+ /**
+ * The keys to display. A whitespace-separated string is split into one key
+ * per token, so `"⌘ K"` renders two keys.
+ */
+ children?: React.ReactNode;
+
+ /**
+ * Visual style variant, applied to every key in the shortcut.
+ * @defaultValue "ghost"
+ */
+ variant?: 'solid' | 'ghost';
+
/** Additional CSS class names. */
className?: string;
}
diff --git a/apps/www/src/content/docs/components/kbd/demo.ts b/apps/www/src/content/docs/components/kbd/demo.ts
new file mode 100644
index 000000000..c5a6f6dca
--- /dev/null
+++ b/apps/www/src/content/docs/components/kbd/demo.ts
@@ -0,0 +1,121 @@
+'use client';
+
+import type { ComponentPropsType } from '@/components/demo/types';
+import { getPropsString } from '@/lib/utils';
+
+export const getCode = (props: ComponentPropsType) => {
+ const { children, ...rest } = props;
+
+ return `${children}`;
+};
+
+export const playground = {
+ type: 'playground',
+ controls: {
+ variant: {
+ type: 'select',
+ options: ['solid', 'ghost'],
+ defaultValue: 'solid'
+ },
+ children: {
+ type: 'text',
+ initialValue: 'Esc'
+ }
+ },
+ getCode
+};
+
+export const singleDemo = {
+ type: 'code',
+ code: `
+ Esc
+ ⌘
+ ⇧
+ ↵
+ Tab
+ `
+};
+
+export const variantDemo = {
+ type: 'code',
+ code: `
+
+ ⌘
+ K
+
+
+ ⌘
+ K
+
+ `
+};
+
+export const groupDemo = {
+ type: 'code',
+ code: `
+
+ ⌘
+ K
+
+
+ ⌘
+ ⇧
+ P
+
+ `
+};
+
+export const separatorDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Plus',
+ code: `
+ ⌘
+ +
+ K
+ `
+ },
+ {
+ name: 'Then',
+ code: `
+ G
+ then
+ P
+ `
+ }
+ ]
+};
+
+export const withTextDemo = {
+ type: 'code',
+ code: `
+ Press ⌘K to open the command palette.
+ `
+};
+
+export const withInputDemo = {
+ type: 'code',
+ code: `⌘K}
+ />`
+};
+
+export const withTooltipDemo = {
+ type: 'code',
+ code: `
+ }>
+ Search
+
+
+
+ Open search
+
+ ⌘
+ K
+
+
+
+ `
+};
diff --git a/apps/www/src/content/docs/components/kbd/index.mdx b/apps/www/src/content/docs/components/kbd/index.mdx
new file mode 100644
index 000000000..8607491e8
--- /dev/null
+++ b/apps/www/src/content/docs/components/kbd/index.mdx
@@ -0,0 +1,110 @@
+---
+title: Kbd
+description: A component for displaying keyboard keys and shortcuts.
+source: packages/raystack/components/kbd
+tag: new
+---
+
+import {
+ playground,
+ singleDemo,
+ variantDemo,
+ groupDemo,
+ separatorDemo,
+ withTextDemo,
+ withInputDemo,
+ withTooltipDemo,
+} from "./demo.ts";
+
+
+
+## Anatomy
+
+Import and assemble the component. A single `Kbd` renders one key; wrap several in `Kbd.Group` to show a sequence.
+
+```tsx
+import { Kbd } from "@raystack/apsara";
+
+Esc
+
+
+ ⌘
+ K
+
+```
+
+## API Reference
+
+Both parts render a `` element and forward any native attributes (`id`, `title`, `aria-label`, …) to it.
+
+### Root
+
+A single keyboard key. Renders a `` element.
+
+
+
+### Group
+
+Groups multiple keyboard keys for key combinations.
+
+
+
+### Slots
+
+Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):
+
+| Slot | Element |
+|------|---------|
+| `kbd` | Each individual key |
+| `kbd-group` | The `Kbd.Group` wrapper |
+
+## Examples
+
+### Single keys
+
+Use `Kbd` on its own for a one-key hint. Keys share a minimum width so a narrow `K` lines up with a wide `⌘`.
+
+
+
+### Variants
+
+`solid` is the default and suits standalone hints. Use `ghost` on surfaces that already have their own background, such as a menu row, a tooltip, or an input.
+
+
+
+### Sequences
+
+Wrap keys in `Kbd.Group` to show a chord. Setting `variant` on the group applies it to every key inside.
+
+
+
+### Separators
+
+`Kbd.Group` renders whatever you put between the keys, so separators are plain text. Use `+` for keys pressed together and a word like `then` for keys pressed in order. Separator text takes the surrounding typography rather than the key styling.
+
+
+
+### Inline with text
+
+Keys sit on the text baseline, so they can be dropped straight into a sentence.
+
+
+
+### In an input
+
+Surface a focus shortcut in a search field. Use a single `ghost` key here — the input's trailing slot is sized for an icon, so a multi-key `Kbd.Group` will be clipped.
+
+
+
+### In a tooltip
+
+A common use is surfacing a shortcut alongside the action it triggers.
+
+
+
+## Accessibility
+
+- `Kbd` is presentational and renders the semantic `` element. It has no ARIA role of its own and is not exposed as a separate accessible object, so it does not change how surrounding content is announced.
+- Symbol-only keys such as `⌘`, `⇧`, or `↵` are not announced usefully on their own — they are read by their Unicode names, if at all. Add an `aria-label` when the symbol is the only cue: `⌘`.
+- Keys are not focusable and carry no interaction. Keep the shortcut wired to a real handler elsewhere — `Kbd` only displays it.
+- Keys ignore pointer events and text selection, so clicking or dragging across a menu row does not highlight the key labels.
diff --git a/apps/www/src/content/docs/components/kbd/props.ts b/apps/www/src/content/docs/components/kbd/props.ts
new file mode 100644
index 000000000..311b8701a
--- /dev/null
+++ b/apps/www/src/content/docs/components/kbd/props.ts
@@ -0,0 +1,29 @@
+import type { ReactNode } from 'react';
+
+export interface KbdProps {
+ /** The key to display, e.g. `⌘`, `Esc`, or `Enter`. */
+ children?: ReactNode;
+
+ /**
+ * Visual style variant. Inherited from a parent `Kbd.Group` when set there.
+ * @defaultValue "solid"
+ */
+ variant?: 'solid' | 'ghost';
+
+ /** Additional CSS class names. */
+ className?: string;
+}
+
+export interface KbdGroupProps {
+ /** The keys in the sequence, plus any plain-text separators between them. */
+ children?: ReactNode;
+
+ /**
+ * Visual style variant applied to every key in the group.
+ * @defaultValue "solid"
+ */
+ variant?: 'solid' | 'ghost';
+
+ /** Additional CSS class names. */
+ className?: string;
+}
diff --git a/packages/raystack/components/command/__tests__/command.test.tsx b/packages/raystack/components/command/__tests__/command.test.tsx
index 2cdefaf0d..1b298f1c3 100644
--- a/packages/raystack/components/command/__tests__/command.test.tsx
+++ b/packages/raystack/components/command/__tests__/command.test.tsx
@@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';
+import { Kbd } from '../../kbd';
+import kbdStyles from '../../kbd/kbd.module.css';
import { Command } from '../command';
import styles from '../command.module.css';
@@ -315,4 +317,57 @@ describe('Command', () => {
});
});
});
+
+ describe('Command.Shortcut', () => {
+ it('splits a string of keys into individual keys', () => {
+ render(⌘ K);
+ expect(screen.getByText('⌘')).toBeInTheDocument();
+ expect(screen.getByText('K')).toBeInTheDocument();
+ });
+
+ it('renders each key through Kbd', () => {
+ render(⌘ K);
+ const key = screen.getByText('⌘');
+ expect(key.tagName).toBe('KBD');
+ expect(key).toHaveClass(kbdStyles['kbd']);
+ });
+
+ it('defaults its keys to the ghost variant', () => {
+ render(⌘ K);
+ expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-ghost']);
+ });
+
+ it('allows the variant to be overridden', () => {
+ render(⌘ K);
+ expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-solid']);
+ });
+
+ it('forwards props and merges className onto the group', () => {
+ const { container } = render(
+
+ ⌘ K
+
+ );
+ const group = container.querySelector('[data-slot="command-shortcut"]');
+ expect(group).toHaveClass('custom');
+ expect(group).toHaveClass(styles.shortcut);
+ expect(group).toHaveAttribute('aria-label', 'Command K');
+ });
+
+ it('forwards ref', () => {
+ const ref = React.createRef();
+ render(⌘ K);
+ expect(ref.current?.tagName).toBe('KBD');
+ });
+
+ it('does not double-wrap element children in a second key', () => {
+ const { container } = render(
+
+ ⌘
+
+ );
+ const keys = container.querySelectorAll(`.${kbdStyles['kbd']}`);
+ expect(keys).toHaveLength(1);
+ });
+ });
});
diff --git a/packages/raystack/components/command/command-misc.tsx b/packages/raystack/components/command/command-misc.tsx
index a1563995a..8bcefe9d8 100644
--- a/packages/raystack/components/command/command-misc.tsx
+++ b/packages/raystack/components/command/command-misc.tsx
@@ -2,7 +2,8 @@
import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete';
import { cx } from 'class-variance-authority';
-import { type ComponentProps } from 'react';
+import { Fragment, isValidElement } from 'react';
+import { Kbd, type KbdGroupProps } from '../kbd';
import styles from './command.module.css';
import { useCommandContext } from './command-root';
@@ -63,11 +64,12 @@ export const CommandSeparator = ({
};
CommandSeparator.displayName = 'Command.Separator';
-export type CommandShortcutProps = ComponentProps<'span'>;
+export type CommandShortcutProps = KbdGroupProps;
export const CommandShortcut = ({
className,
children,
+ variant = 'ghost',
...props
}: CommandShortcutProps) => {
const keys =
@@ -78,21 +80,22 @@ export const CommandShortcut = ({
: [children];
return (
-
- {keys.map((key, index) => (
-
- {key}
-
- ))}
-
+ {keys.map((key, index) =>
+ isValidElement(key) ? (
+ {key}
+ ) : (
+
+ {key}
+
+ )
+ )}
+
);
};
CommandShortcut.displayName = 'Command.Shortcut';
diff --git a/packages/raystack/components/command/command.module.css b/packages/raystack/components/command/command.module.css
index 0cbdd837e..0eaa63891 100644
--- a/packages/raystack/components/command/command.module.css
+++ b/packages/raystack/components/command/command.module.css
@@ -103,22 +103,8 @@
}
.shortcut {
- display: inline-flex;
- align-items: center;
justify-content: flex-end;
- gap: var(--rs-space-1);
white-space: nowrap;
- font-family: var(--rs-font-body);
- font-weight: var(--rs-font-weight-regular);
- font-size: var(--rs-font-size-micro);
- line-height: var(--rs-line-height-micro);
- letter-spacing: var(--rs-letter-spacing-micro);
- color: var(--rs-color-foreground-base-tertiary);
-}
-
-.shortcutKey {
- font: inherit;
- color: inherit;
}
.empty {
diff --git a/packages/raystack/components/kbd/__tests__/data-slots.test.tsx b/packages/raystack/components/kbd/__tests__/data-slots.test.tsx
new file mode 100644
index 000000000..577ef4b09
--- /dev/null
+++ b/packages/raystack/components/kbd/__tests__/data-slots.test.tsx
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots';
+import { Kbd } from '../kbd';
+
+describe('Kbd data-slot contract', () => {
+ it('exposes slots for every rendered part', () => {
+ const { container } = render(
+
+ ⌘
+ K
+
+ );
+ expectSlots(container, ['kbd-group', 'kbd']);
+ });
+
+ it('marks each key with the same slot name', () => {
+ const { container } = render(
+
+ ⌘
+ K
+
+ );
+ expect(getAllSlots(container, 'kbd')).toHaveLength(2);
+ });
+
+ it('drops the group slot when no group is rendered', () => {
+ const { container } = render(Esc);
+ expectSlots(container, ['kbd']);
+ expect(getSlot(container, 'kbd-group')).toBeNull();
+ });
+
+ it('lets callers override the slot name', () => {
+ const { container } = render(Esc);
+ expect(getSlot(container, 'custom')).not.toBeNull();
+ expect(getSlot(container, 'kbd')).toBeNull();
+ });
+});
diff --git a/packages/raystack/components/kbd/__tests__/kbd.test.tsx b/packages/raystack/components/kbd/__tests__/kbd.test.tsx
new file mode 100644
index 000000000..79a353504
--- /dev/null
+++ b/packages/raystack/components/kbd/__tests__/kbd.test.tsx
@@ -0,0 +1,174 @@
+import { render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, expect, it } from 'vitest';
+import { Kbd } from '../kbd';
+import styles from '../kbd.module.css';
+
+describe('Kbd', () => {
+ describe('Basic Rendering', () => {
+ it('renders its children', () => {
+ render(Ctrl);
+ expect(screen.getByText('Ctrl')).toBeInTheDocument();
+ });
+
+ it('renders a kbd element', () => {
+ render(Ctrl);
+ expect(screen.getByText('Ctrl').tagName).toBe('KBD');
+ });
+
+ it('applies the base class', () => {
+ render(Ctrl);
+ expect(screen.getByText('Ctrl')).toHaveClass(styles.kbd);
+ });
+
+ it('merges a custom className with the base class', () => {
+ render(Ctrl);
+ const kbd = screen.getByText('Ctrl');
+ expect(kbd).toHaveClass(styles.kbd);
+ expect(kbd).toHaveClass('custom');
+ });
+
+ it('forwards arbitrary props to the element', () => {
+ render(Ctrl);
+ expect(screen.getByText('Ctrl')).toHaveAttribute(
+ 'aria-label',
+ 'Control key'
+ );
+ });
+
+ it('forwards ref', () => {
+ const ref = createRef();
+ render(Ctrl);
+ expect(ref.current).toBeInstanceOf(HTMLElement);
+ expect(ref.current?.tagName).toBe('KBD');
+ });
+ });
+
+ describe('Variants', () => {
+ it('applies the solid variant by default', () => {
+ render(Ctrl);
+ expect(screen.getByText('Ctrl')).toHaveClass(styles['kbd-solid']);
+ });
+
+ it('applies the ghost variant when requested', () => {
+ render(Ctrl);
+ const kbd = screen.getByText('Ctrl');
+ expect(kbd).toHaveClass(styles['kbd-ghost']);
+ expect(kbd).not.toHaveClass(styles['kbd-solid']);
+ });
+
+ it('inherits the variant from a parent group', () => {
+ render(
+
+ ⌘
+ K
+
+ );
+ expect(screen.getByText('⌘')).toHaveClass(styles['kbd-ghost']);
+ expect(screen.getByText('K')).toHaveClass(styles['kbd-ghost']);
+ });
+
+ it('lets a key override the variant inherited from its group', () => {
+ render(
+
+ ⌘
+ K
+
+ );
+ expect(screen.getByText('⌘')).toHaveClass(styles['kbd-solid']);
+ expect(screen.getByText('K')).toHaveClass(styles['kbd-ghost']);
+ });
+
+ it('falls back to solid for keys in a group with no variant', () => {
+ render(
+
+ K
+
+ );
+ expect(screen.getByText('K')).toHaveClass(styles['kbd-solid']);
+ });
+
+ it('does not put a key variant class on the group', () => {
+ const { container } = render(
+
+ K
+
+ );
+ const group = container.querySelector(`.${styles['kbd-group']}`);
+ expect(group).not.toHaveClass(styles['kbd-ghost']);
+ });
+ });
+
+ describe('Kbd.Group', () => {
+ it('renders every key it contains', () => {
+ render(
+
+ ⌘
+ K
+
+ );
+ expect(screen.getByText('⌘')).toBeInTheDocument();
+ expect(screen.getByText('K')).toBeInTheDocument();
+ });
+
+ it('renders a kbd element so nested keys stay semantic', () => {
+ const { container } = render(
+
+ K
+
+ );
+ const group = container.querySelector(`.${styles['kbd-group']}`);
+ expect(group?.tagName).toBe('KBD');
+ });
+
+ it('applies the group class, not the key class', () => {
+ const { container } = render(
+
+ K
+
+ );
+ const group = container.querySelector(`.${styles['kbd-group']}`);
+ expect(group).not.toHaveClass(styles.kbd);
+ });
+
+ it('merges a custom className with the group class', () => {
+ const { container } = render(
+
+ K
+
+ );
+ const group = container.querySelector(`.${styles['kbd-group']}`);
+ expect(group).toHaveClass('custom');
+ });
+
+ it('forwards ref', () => {
+ const ref = createRef();
+ render(
+
+ K
+
+ );
+ expect(ref.current?.tagName).toBe('KBD');
+ });
+
+ it('allows plain text separators between keys', () => {
+ render(
+
+ ⌘+K
+
+ );
+ expect(screen.getByText('+')).toBeInTheDocument();
+ });
+ });
+
+ describe('Composition', () => {
+ it('exposes Group off the root', () => {
+ expect(Kbd.Group).toBeDefined();
+ });
+
+ it('sets displayName on both parts', () => {
+ expect(Kbd.displayName).toBe('Kbd');
+ expect(Kbd.Group.displayName).toBe('Kbd.Group');
+ });
+ });
+});
diff --git a/packages/raystack/components/kbd/index.tsx b/packages/raystack/components/kbd/index.tsx
new file mode 100644
index 000000000..a170b82e2
--- /dev/null
+++ b/packages/raystack/components/kbd/index.tsx
@@ -0,0 +1 @@
+export { Kbd, type KbdGroupProps, type KbdProps } from './kbd';
diff --git a/packages/raystack/components/kbd/kbd.module.css b/packages/raystack/components/kbd/kbd.module.css
new file mode 100644
index 000000000..b899ad6f9
--- /dev/null
+++ b/packages/raystack/components/kbd/kbd.module.css
@@ -0,0 +1,38 @@
+.kbd,
+.kbd-group {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ pointer-events: none;
+ user-select: none;
+}
+
+.kbd {
+ justify-content: center;
+ box-sizing: border-box;
+ height: var(--rs-space-6);
+ min-width: var(--rs-space-6);
+ padding: 0 var(--rs-space-2);
+ border-radius: var(--rs-radius-1);
+ font-family: var(--rs-font-body);
+ font-size: var(--rs-font-size-mini);
+ font-weight: var(--rs-font-weight-medium);
+ line-height: var(--rs-line-height-mini);
+ letter-spacing: var(--rs-letter-spacing-mini);
+ white-space: nowrap;
+}
+
+.kbd-solid {
+ background: var(--rs-color-background-neutral-primary);
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+.kbd-ghost {
+ background: transparent;
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+.kbd-group {
+ gap: var(--rs-space-2);
+ font: inherit;
+}
diff --git a/packages/raystack/components/kbd/kbd.tsx b/packages/raystack/components/kbd/kbd.tsx
new file mode 100644
index 000000000..ad9a6ccbc
--- /dev/null
+++ b/packages/raystack/components/kbd/kbd.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { cva, cx, type VariantProps } from 'class-variance-authority';
+import { type ComponentProps, createContext, useContext } from 'react';
+import styles from './kbd.module.css';
+
+const kbd = cva(styles['kbd'], {
+ variants: {
+ variant: {
+ solid: styles['kbd-solid'],
+ ghost: styles['kbd-ghost']
+ }
+ },
+ defaultVariants: {
+ variant: 'solid'
+ }
+});
+
+type KbdVariant = NonNullable['variant']>;
+
+const KbdGroupContext = createContext(undefined);
+
+export type KbdProps = ComponentProps<'kbd'> & VariantProps;
+
+const KbdRoot = ({ className, variant, ...props }: KbdProps) => {
+ const groupVariant = useContext(KbdGroupContext);
+
+ return (
+
+ );
+};
+
+KbdRoot.displayName = 'Kbd';
+
+export type KbdGroupProps = ComponentProps<'kbd'> & VariantProps;
+
+const KbdGroup = ({ className, variant, ...props }: KbdGroupProps) => (
+
+
+
+);
+
+KbdGroup.displayName = 'Kbd.Group';
+
+export const Kbd = Object.assign(KbdRoot, {
+ Group: KbdGroup
+});
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 562cbdb2d..c48d4e57e 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -97,6 +97,7 @@ export { IconButton } from './components/icon-button';
export { Image } from './components/image';
export { Indicator } from './components/indicator';
export { Input } from './components/input';
+export { Kbd } from './components/kbd';
export { Label } from './components/label';
export { Link } from './components/link';
export { List } from './components/list';