diff --git a/apps/www/src/content/docs/components/command/demo.ts b/apps/www/src/content/docs/components/command/demo.ts
index 2c60ec0f0..fc5ba8ff1 100644
--- a/apps/www/src/content/docs/components/command/demo.ts
+++ b/apps/www/src/content/docs/components/command/demo.ts
@@ -27,14 +27,26 @@ export const preview = {
Actions
}
- trailingIcon={⌘ ⇧ A}
+ trailingIcon={
+
+ ⌘
+ ⇧
+ A
+
+ }
onClick={() => setOpen(false)}
>
Create AOI...
}
- trailingIcon={⌘ ⇧ W}
+ trailingIcon={
+
+ ⌘
+ ⇧
+ W
+
+ }
onClick={() => setOpen(false)}
disabled
>
@@ -137,19 +149,34 @@ export const shortcutDemo = {
Suggestions
⌘ P}
+ trailingIcon={
+
+ ⌘
+ P
+
+ }
onClick={() => setOpen(false)}
>
Profile
⌘ B}
+ trailingIcon={
+
+ ⌘
+ B
+
+ }
onClick={() => setOpen(false)}
>
Billing
⌘ S}
+ trailingIcon={
+
+ ⌘
+ S
+
+ }
onClick={() => setOpen(false)}
>
Settings
diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx
index f3b03b5f8..22a5e21ef 100644
--- a/apps/www/src/content/docs/components/command/index.mdx
+++ b/apps/www/src/content/docs/components/command/index.mdx
@@ -73,7 +73,7 @@ Rendered when no items are visible (all filtered out, or the list is empty). Aut
### Item
-A selectable entry. When `value` is omitted and `children` is a string, the string is used as the value. `onClick` fires on pointer click **and** on keyboard `Enter` when the item is highlighted.
+A selectable entry. When `value` is omitted and `children` is a string, the string is used as the value. `onClick` fires on pointer click **and** on keyboard `Enter` when the item is highlighted. Pass a [`Kbd`](/docs/components/kbd) as `trailingIcon` to show a keyboard hint — use the `ghost` variant so the keys sit on the item's own background.
@@ -95,12 +95,6 @@ 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`.
-
-
-
### Dialog
`Command.Dialog` is an alias for the Base UI Dialog root. Pair it with `Command.DialogTrigger` and `Command.DialogContent` to render the command menu in a centered modal.
@@ -131,8 +125,6 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `command-item-label` | The item's label text |
| `command-item-trailing-icon` | Trailing icon inside an item (when provided) |
| `command-separator` | `Command.Separator` |
-| `command-shortcut` | `Command.Shortcut` wrapper |
-| `command-shortcut-key` | Each `` key inside a shortcut |
| `command-dialog-trigger` | `Command.DialogTrigger` |
| `command-dialog-viewport` | Viewport wrapper for the dialog popup |
| `command-dialog-content` | `Command.DialogContent` popup |
diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts
index 5b1b54945..1b8fc9751 100644
--- a/apps/www/src/content/docs/components/command/props.ts
+++ b/apps/www/src/content/docs/components/command/props.ts
@@ -103,7 +103,7 @@ export interface CommandItemProps {
/** Icon rendered before the item label. */
leadingIcon?: React.ReactNode;
- /** Node rendered after the item label (e.g. `Command.Shortcut` or an icon). */
+ /** Node rendered after the item label (e.g. a `Kbd` shortcut hint or an icon). */
trailingIcon?: React.ReactNode;
/**
@@ -131,11 +131,6 @@ export interface CommandSeparatorProps {
className?: string;
}
-export interface CommandShortcutProps {
- /** Additional CSS class names. */
- className?: string;
-}
-
export interface CommandDialogProps {
/** Controlled open state. */
open?: boolean;
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..1ddb88659
--- /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 command item, a tooltip, or an input.
+
+
+
+### Sequences
+
+Wrap keys in `Kbd.Group` to show a chord. Setting `variant` on the group sets the variant for the keys inside, and an individual `Kbd` can override it.
+
+
+
+### 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: it styles a key. The `` element carries no ARIA role and no accessible name of its own, so it adds no semantics to the text around it.
+- A symbol on its own — `⌘`, `⇧`, `↵` — carries no name for the key it stands for, and how any given screen reader reads the bare glyph varies. Give the key a name whenever 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 command item 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..8fa99e84b
--- /dev/null
+++ b/apps/www/src/content/docs/components/kbd/props.ts
@@ -0,0 +1,30 @@
+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 inherited by every key in the group. A key's own
+ * `variant` takes precedence over it.
+ * @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..8075d7c0e 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,30 @@ describe('Command', () => {
});
});
});
+
+ describe('Shortcut hints', () => {
+ it('renders a Kbd.Group passed as trailingIcon', () => {
+ const { container } = render(
+
+
+
+ ⌘
+ K
+
+ }
+ >
+ Search
+
+
+
+ );
+ expect(screen.getByText('⌘')).toBeInTheDocument();
+ expect(screen.getByText('K')).toBeInTheDocument();
+ expect(container.querySelectorAll(`.${kbdStyles['kbd']}`)).toHaveLength(
+ 2
+ );
+ });
+ });
});
diff --git a/packages/raystack/components/command/__tests__/data-slots.test.tsx b/packages/raystack/components/command/__tests__/data-slots.test.tsx
index 8bb13ddb2..5b2b1c4cf 100644
--- a/packages/raystack/components/command/__tests__/data-slots.test.tsx
+++ b/packages/raystack/components/command/__tests__/data-slots.test.tsx
@@ -1,6 +1,7 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { expectSlots, getSlot } from '~/test-utils/data-slots';
+import { Kbd } from '../../kbd';
import { Command } from '../command';
// Mock scrollIntoView for test environment
@@ -18,7 +19,12 @@ const BasicCommand = () => (
Suggestions
}
- trailingIcon={⌘ K}
+ trailingIcon={
+
+ ⌘
+ K
+
+ }
>
Calendar
@@ -44,8 +50,6 @@ describe('Command data-slot contract', () => {
'command-item-leading-icon',
'command-item-label',
'command-item-trailing-icon',
- 'command-shortcut',
- 'command-shortcut-key',
'command-separator'
]);
});
diff --git a/packages/raystack/components/command/command-item.tsx b/packages/raystack/components/command/command-item.tsx
index ffd5609e5..3990d8edb 100644
--- a/packages/raystack/components/command/command-item.tsx
+++ b/packages/raystack/components/command/command-item.tsx
@@ -10,7 +10,7 @@ import { useCommandContext } from './command-root';
export interface CommandItemProps extends AutocompletePrimitive.Item.Props {
/** Icon rendered at the start of the item. */
leadingIcon?: ReactNode;
- /** Icon rendered at the end of the item (e.g. `Command.Shortcut`). */
+ /** Icon rendered at the end of the item (e.g. a `Kbd` shortcut hint). */
trailingIcon?: ReactNode;
}
diff --git a/packages/raystack/components/command/command-misc.tsx b/packages/raystack/components/command/command-misc.tsx
index a1563995a..a4e9f3345 100644
--- a/packages/raystack/components/command/command-misc.tsx
+++ b/packages/raystack/components/command/command-misc.tsx
@@ -2,7 +2,6 @@
import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete';
import { cx } from 'class-variance-authority';
-import { type ComponentProps } from 'react';
import styles from './command.module.css';
import { useCommandContext } from './command-root';
@@ -62,37 +61,3 @@ export const CommandSeparator = ({
);
};
CommandSeparator.displayName = 'Command.Separator';
-
-export type CommandShortcutProps = ComponentProps<'span'>;
-
-export const CommandShortcut = ({
- className,
- children,
- ...props
-}: CommandShortcutProps) => {
- const keys =
- typeof children === 'string'
- ? children.trim().split(/\s+/).filter(Boolean)
- : Array.isArray(children)
- ? children
- : [children];
-
- return (
-
- {keys.map((key, index) => (
-
- {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..080dbbc3d 100644
--- a/packages/raystack/components/command/command.module.css
+++ b/packages/raystack/components/command/command.module.css
@@ -102,25 +102,6 @@
height: var(--rs-space-5);
}
-.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 {
padding: var(--rs-space-7) var(--rs-space-3);
text-align: center;
diff --git a/packages/raystack/components/command/command.tsx b/packages/raystack/components/command/command.tsx
index ac01d9e46..a3e197db4 100644
--- a/packages/raystack/components/command/command.tsx
+++ b/packages/raystack/components/command/command.tsx
@@ -7,12 +7,7 @@ import {
import { CommandEmpty } from './command-empty';
import { CommandInput } from './command-input';
import { CommandItem } from './command-item';
-import {
- CommandGroup,
- CommandLabel,
- CommandSeparator,
- CommandShortcut
-} from './command-misc';
+import { CommandGroup, CommandLabel, CommandSeparator } from './command-misc';
import { CommandRoot } from './command-root';
export const Command = Object.assign(CommandRoot, {
@@ -23,7 +18,6 @@ export const Command = Object.assign(CommandRoot, {
Group: CommandGroup,
Label: CommandLabel,
Separator: CommandSeparator,
- Shortcut: CommandShortcut,
Dialog: CommandDialog,
DialogTrigger: CommandDialogTrigger,
DialogContent: CommandDialogContent
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..65619b3e0
--- /dev/null
+++ b/packages/raystack/components/kbd/__tests__/kbd.test.tsx
@@ -0,0 +1,185 @@
+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('carries the solid styling on the base class by default', () => {
+ const kbd = render(Ctrl).getByText('Ctrl');
+ expect(kbd).toHaveClass(styles.kbd);
+ expect(kbd).not.toHaveClass(styles['kbd-solid']);
+ expect(kbd).not.toHaveClass(styles['kbd-ghost']);
+ });
+
+ it('applies the solid variant when requested', () => {
+ render(Ctrl);
+ expect(screen.getByText('Ctrl')).toHaveClass(styles['kbd-solid']);
+ });
+
+ it('applies the ghost variant when requested', () => {
+ const kbd = render(Ctrl).getByText('Ctrl');
+ expect(kbd).toHaveClass(styles['kbd-ghost']);
+ expect(kbd).not.toHaveClass(styles['kbd-solid']);
+ });
+
+ it('marks the group so its keys inherit the variant in CSS', () => {
+ const { container } = render(
+
+ ⌘
+ K
+
+ );
+ const group = container.querySelector(`.${styles['kbd-group']}`);
+ expect(group).toHaveClass(styles['kbd-group-ghost']);
+ // Keys carry no variant class of their own, so the group rule applies.
+ expect(screen.getByText('⌘')).not.toHaveClass(styles['kbd-solid']);
+ expect(screen.getByText('K')).not.toHaveClass(styles['kbd-solid']);
+ });
+
+ it('lets a key override the variant inherited from its group', () => {
+ render(
+
+ ⌘
+ K
+
+ );
+ expect(screen.getByText('⌘')).toHaveClass(styles['kbd-solid']);
+ expect(screen.getByText('K')).not.toHaveClass(styles['kbd-solid']);
+ });
+
+ it('leaves a group with no variant unmarked', () => {
+ const { container } = render(
+
+ K
+
+ );
+ const group = container.querySelector(`.${styles['kbd-group']}`);
+ expect(group).not.toHaveClass(styles['kbd-group-ghost']);
+ expect(group).not.toHaveClass(styles['kbd-group-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..da07dfdd2
--- /dev/null
+++ b/packages/raystack/components/kbd/kbd.module.css
@@ -0,0 +1,42 @@
+.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;
+ background: var(--rs-color-background-neutral-primary);
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+:where(.kbd-group-solid) .kbd,
+.kbd.kbd-solid {
+ background: var(--rs-color-background-neutral-primary);
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+:where(.kbd-group-ghost) .kbd,
+.kbd.kbd-ghost {
+ background: transparent;
+ color: var(--rs-color-foreground-base-tertiary);
+}
+
+.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..9f4109bef
--- /dev/null
+++ b/packages/raystack/components/kbd/kbd.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ComponentProps } from 'react';
+import styles from './kbd.module.css';
+
+const kbd = cva(styles['kbd'], {
+ variants: {
+ variant: {
+ solid: styles['kbd-solid'],
+ ghost: styles['kbd-ghost']
+ }
+ }
+});
+
+const kbdGroup = cva(styles['kbd-group'], {
+ variants: {
+ variant: {
+ solid: styles['kbd-group-solid'],
+ ghost: styles['kbd-group-ghost']
+ }
+ }
+});
+
+export type KbdProps = ComponentProps<'kbd'> & VariantProps;
+
+const KbdRoot = ({ className, variant, ...props }: KbdProps) => (
+
+);
+
+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';