# Command palette

⌘K spotlight - global commands, grouped, keyboard-shortcut hinted.

Preview: https://design.freecodecamp.org/playground#command-palette

## Add to your project

Use React and TypeScript. Required packages: `react@>=18 <20`, `@ark-ui/react@^5.0.0`. No freeCodeCamp package is needed.

Copy the files below to the indicated paths, relative to your project root. If you change the layout, update relative imports too.

Import the CSS once from your application entry. For an entry in src/:

```ts
import './ui/theme/tokens.css';
import './ui/theme/base.css';
import './ui/command-palette/command-palette.css';
import './ui/button/button.css';
```

The theme is shared: reuse it if already installed. Fonts use /fonts/ URLs on your host. Download the font files listed in https://design.freecodecamp.org/registry/starter.md or change those URLs in your copied tokens.css.

## Example

```tsx
import { Button } from './ui/button/Button';
import { useState } from 'react';
import { CommandPalette } from './ui/command-palette/CommandPalette';

export function Example() {
  const GROUPS = [
    {
      label: 'Navigation',
      items: [
        { id: 'curriculum', label: 'Go to curriculum', shortcut: 'G C' },
        { id: 'settings', label: 'Open settings', shortcut: 'G S' }
      ]
    }
  ];

  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState('');

  return (
    <>
      <Button onClick={() => setOpen(true)}>Open commands</Button>
      <p role='status'>{selected && `Selected: ${selected}`}</p>
      <CommandPalette
        open={open}
        onClose={() => setOpen(false)}
        onSelect={id => {
          setSelected(id);
          setOpen(false);
        }}
        groups={GROUPS}
        placeholder='Type a command or search…'
      />
    </>
  );
}
```

## Interaction guidance

Review https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ and test keyboard operation in your project.

## Component source

### src/ui/command-palette/CommandPalette.tsx

Source: https://design.freecodecamp.org/registry/command-palette/CommandPalette.tsx

```tsx
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
import { Dialog } from '@ark-ui/react/dialog';

export interface CommandPaletteItem {
  id: string;
  label: React.ReactNode;
  icon?: React.ReactNode;
  shortcut?: string;
  keywords?: string;
}

export interface CommandPaletteGroup {
  label: React.ReactNode;
  items: readonly CommandPaletteItem[];
}

export interface CommandPaletteProps {
  open: boolean;
  onClose: () => void;
  onSelect: (id: string) => void;
  groups: readonly CommandPaletteGroup[];
  placeholder?: string;
  /** Slot rendered when `groups` is empty (after filtering). */
  emptyState?: React.ReactNode;
  /** Controlled search value. Omit for uncontrolled. */
  value?: string;
  onValueChange?: (next: string) => void;
  className?: string;
}

interface FlatItem {
  readonly id: string;
  readonly label: React.ReactNode;
}

const searchString = (item: CommandPaletteItem): string => {
  const parts: string[] = [];
  if (typeof item.label === 'string') parts.push(item.label);
  if (item.keywords) parts.push(item.keywords);
  return parts.join(' ').toLowerCase();
};

const filterGroups = (
  groups: readonly CommandPaletteGroup[],
  query: string
): readonly CommandPaletteGroup[] => {
  if (query.trim() === '') return groups;
  const needle = query.toLowerCase();
  return groups
    .map(group => ({
      label: group.label,
      items: group.items.filter(item => searchString(item).includes(needle))
    }))
    .filter(group => group.items.length > 0);
};

const flattenItems = (
  groups: readonly CommandPaletteGroup[]
): readonly FlatItem[] =>
  groups.flatMap(group =>
    group.items.map(item => ({ id: item.id, label: item.label }))
  );

export const CommandPalette = ({
  open,
  onClose,
  onSelect,
  groups,
  placeholder = 'Type a command…',
  emptyState = 'No commands found.',
  value,
  onValueChange,
  className = ''
}: CommandPaletteProps): React.ReactElement | null => {
  const isControlled = value !== undefined;
  const listId = useId();
  const [internal, setInternal] = useState('');
  const query = isControlled ? value : internal;
  const setQuery = (next: string): void => {
    if (!isControlled) setInternal(next);
    onValueChange?.(next);
  };

  const filtered = useMemo(() => filterGroups(groups, query), [groups, query]);
  const flat = useMemo(() => flattenItems(filtered), [filtered]);
  const [activeIndex, setActiveIndex] = useState(0);
  const activeRef = useRef<HTMLLIElement | null>(null);

  useEffect(() => {
    setActiveIndex(0);
  }, [flat.length]);

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
        return;
      }
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        setActiveIndex(i => Math.min(i + 1, flat.length - 1));
        return;
      }
      if (event.key === 'ArrowUp') {
        event.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        return;
      }
      if (event.key === 'Enter') {
        const selected = flat[activeIndex];
        if (selected) {
          event.preventDefault();
          onSelect(selected.id);
        }
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, flat, activeIndex, onClose, onSelect]);

  useEffect(() => {
    activeRef.current?.scrollIntoView?.({ block: 'nearest' });
  }, [activeIndex]);

  const classes = ['command-palette', className].filter(Boolean).join(' ');
  const hasMatches = filtered.length > 0;

  let cursor = -1;
  return (
    <Dialog.Root
      open={open}
      onOpenChange={details => {
        if (!details.open) onClose();
      }}
      unmountOnExit
      lazyMount
    >
      <Dialog.Positioner
        className='command-palette__backdrop'
        onClick={event => {
          if (event.target === event.currentTarget) onClose();
        }}
        data-state='open'
      >
        <Dialog.Content aria-label='Command palette' className={classes}>
          <input
            type='text'
            className='command-palette__search'
            placeholder={placeholder}
            value={query}
            onChange={e => setQuery(e.target.value)}
            aria-autocomplete='list'
            aria-label='Search commands'
            aria-controls={listId}
            aria-activedescendant={
              flat[activeIndex] ? `${listId}-${activeIndex}` : undefined
            }
          />
          <ul
            id={listId}
            className='command-palette__list'
            role='listbox'
            aria-label='Commands'
          >
            {!hasMatches && emptyState !== undefined && (
              <li className='command-palette__empty' role='presentation'>
                {emptyState}
              </li>
            )}
            {filtered.map((group, gi) => (
              <li
                key={gi}
                className='command-palette__group'
                role='presentation'
              >
                <div
                  id={`${listId}-group-${gi}`}
                  className='command-palette__group-label'
                >
                  {group.label}
                </div>
                <ul
                  className='command-palette__group-items'
                  role='group'
                  aria-labelledby={`${listId}-group-${gi}`}
                >
                  {group.items.map(item => {
                    cursor += 1;
                    const isActive = cursor === activeIndex;
                    const capturedCursor = cursor;
                    return (
                      <li
                        key={item.id}
                        ref={isActive ? activeRef : undefined}
                        role='option'
                        id={`${listId}-${cursor}`}
                        aria-selected={isActive}
                        data-active={isActive ? 'true' : undefined}
                        className='command-palette__item'
                        onMouseEnter={() => setActiveIndex(capturedCursor)}
                        onClick={() => onSelect(item.id)}
                      >
                        {item.icon !== undefined && (
                          <span
                            className='command-palette__icon'
                            aria-hidden='true'
                          >
                            {item.icon}
                          </span>
                        )}
                        <span className='command-palette__label'>
                          {item.label}
                        </span>
                        {item.shortcut !== undefined && (
                          <span className='command-palette__shortcut'>
                            {item.shortcut}
                          </span>
                        )}
                      </li>
                    );
                  })}
                </ul>
              </li>
            ))}
          </ul>
        </Dialog.Content>
      </Dialog.Positioner>
    </Dialog.Root>
  );
};
CommandPalette.displayName = 'CommandPalette';
```

### src/ui/command-palette/command-palette.css

Source: https://design.freecodecamp.org/registry/command-palette/command-palette.css

```css
.command-palette__backdrop {
  position: fixed;
  inset: 0;
  z-index: 9100;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 64px 16px 16px;
}
.command-palette {
  width: min(560px, 100%);
  max-height: calc(100vh - 96px);
  display: flex;
  flex-direction: column;
  background: var(--background-quaternary);
  color: var(--foreground-primary);
  border: var(--border-width-thin) solid var(--foreground-secondary);
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
}
.command-palette__search {
  flex: 0 0 auto;
  appearance: none;
  padding: 14px 16px;
  background: var(--background-quaternary);
  color: var(--foreground-primary);
  border: 0;
  border-bottom: var(--border-width-thin) solid var(--foreground-secondary);
  font-family: var(--font-body);
  font-size: var(--fs-md);
  outline: none;
}
.command-palette__search::placeholder {
  color: var(--foreground-secondary);
}
.command-palette__list {
  flex: 1 1 auto;
  overflow-y: auto;
  margin: 0;
  padding: 4px 0;
  list-style: none;
}
.command-palette__group {
  padding: 4px 0;
}
.command-palette__group + .command-palette__group {
  border-top: var(--border-width-thin) dashed var(--foreground-secondary);
}
.command-palette__group-label {
  padding: 8px 16px 4px;
  font-family: var(--font-heading);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--foreground-secondary);
}
.command-palette__group-items {
  margin: 0;
  padding: 0;
  list-style: none;
}
.command-palette__item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 16px;
  cursor: pointer;
  color: var(--foreground-primary);
}
.command-palette__item[data-active='true'] {
  background: var(--background-tertiary);
}
.command-palette__icon {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--foreground-secondary);
}
.command-palette__label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.command-palette__shortcut {
  flex: 0 0 auto;
  padding: 2px 6px;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--foreground-secondary);
  border: var(--border-width-thin) solid var(--foreground-secondary);
}
.command-palette__empty {
  padding: 24px 16px;
  text-align: center;
  color: var(--foreground-secondary);
}
```

## Shared source: Theme

### src/ui/theme/tokens.css

Source: https://design.freecodecamp.org/registry/theme/tokens.css

```css
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Light.woff') format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Italic.woff') format('woff');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-BoldItalic.woff') format('woff');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Black.woff') format('woff');
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Italic.woff2') format('woff2');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-BoldItalic.woff2') format('woff2');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

:root {
  --gray-00: #ffffff;
  --gray-00-translucent: rgba(255, 255, 255, 0.85);
  --gray-05: #f5f6f7;
  --gray-10: #dfdfe2;
  --gray-15: #d0d0d5;
  --gray-45: #858591;
  --gray-75: #3b3b4f;
  --gray-80: #2a2a40;
  --gray-85: #1b1b32;
  --gray-90: #0a0a23;
  --gray-90-translucent: rgba(10, 10, 35, 0.85);

  --purple-light: #dbb8ff;
  --purple-mid: #9400d3;
  --purple-dark: #5a01a7;
  --yellow-light: #ffc300;
  --yellow-gold: #ffbf00;
  --yellow-style: #f1be32;
  --yellow-dark: #4d3800;
  --blue-light: #99c9ff;
  --blue-light-translucent: rgba(153, 201, 255, 0.3);
  --blue-mid: #198eee;
  --blue-dark: #002ead;
  --blue-dark-translucent: rgba(0, 46, 173, 0.3);
  --green-light: #acd157;
  --green-dark: #00471b;
  --red-light: #ffadad;
  --red-dark: #850000;
  --love-light: #f8577c;
  --love-dark: #f82153;
  --orange: #eda971;

  --editor-background-light: #fffffe;
  --editor-background-dark: #2a2b40;

  --syntax-keyword: #dbb8ff;
  --syntax-fn: #99c9ff;
  --syntax-string: #acd157;
  --syntax-class: #f1be32;
  --syntax-number: #f78c6c;
  --syntax-tag: #f07178;
  --syntax-operator: #89ddff;
  --syntax-invalid: #ff5370;
  --syntax-comment: #858591;
  --syntax-plain: #eeffff;

  --font-sans:
    'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-mono: 'Hack-ZeroSlash', 'Fira Mono', Menlo, Consolas, monospace;

  --fs-base: 18px;
  --fs-sm: 16px;
  --fs-md: 18px;
  --fs-lg: 24px;
  --fs-xl: 32px;
  --fs-2xl: 42px;
  --fs-3xl: 56px;
  --fs-display: clamp(2.5rem, 5vw, 3.75rem);

  --lh-tight: 1.2;
  --lh-snug: 1.33;
  --lh-base: 1.42857143;
  --lh-loose: 1.6;

  --fw-light: 300;
  --fw-regular: 400;
  --fw-bold: 700;
  --fw-black: 900;

  --space-0: 0;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --border-width-hair: 1px;
  --border-width-default: 2px;
  --border-width-thick: 3px;
  --radius-none: 0;
  --radius-sm: 2px;

  --focus-outline-color: var(--blue-mid);
  --focus-outline-width: 3px;

  --z-breadcrumbs: 100;
  --z-flash: 150;
  --z-site-header: 200;
  --z-modal: 1050;

  --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-fast: 120ms;
  --dur-base: 180ms;
  --dur-slow: 260ms;

  --header-height: 48px;
  --breadcrumbs-height: 32px;
  --sidebar-width: 288px;
  --content-max: 1040px;

  color-scheme: dark;
}

.dark-palette,
:root {
  color-scheme: dark;
  --foreground-primary: var(--gray-00);
  --foreground-secondary: var(--gray-05);
  --foreground-tertiary: var(--gray-10);
  --foreground-quaternary: var(--gray-15);
  --foreground-muted: #b0b0bd;

  --background-primary: var(--gray-90);
  --background-primary-translucent: var(--gray-90-translucent);
  --background-secondary: var(--gray-85);
  --background-tertiary: #33334f;
  --background-quaternary: #4b4b66;

  --highlight-color: var(--blue-light);
  --highlight-background: var(--blue-dark);
  --selection-color: var(--blue-light-translucent);

  --success-color: var(--green-light);
  --success-background: var(--green-dark);
  --danger-color: var(--red-light);
  --danger-background: var(--red-dark);
  --warning-color: var(--yellow-light);
  --warning-background: var(--yellow-dark);
  --purple-color: var(--purple-light);
  --purple-background: var(--purple-dark);
  --love-color: var(--love-light);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(255, 255, 255, 0.045);
  --surface-elevation-2: rgba(255, 255, 255, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);
}

.light-palette {
  --foreground-primary: var(--gray-90);
  --foreground-secondary: var(--gray-85);
  --foreground-tertiary: var(--gray-80);
  --foreground-quaternary: var(--gray-75);
  --foreground-muted: #5a5a68;

  --background-primary: var(--gray-00);
  --background-primary-translucent: var(--gray-00-translucent);
  --background-secondary: var(--gray-05);
  --background-tertiary: #c5c5cc;
  --background-quaternary: #a8a8b4;

  --highlight-color: var(--blue-dark);
  --highlight-background: var(--blue-light);
  --selection-color: var(--blue-dark-translucent);

  --success-color: var(--green-dark);
  --success-background: var(--green-light);
  --danger-color: var(--red-dark);
  --danger-background: var(--red-light);
  --warning-color: var(--yellow-dark);
  --warning-background: var(--yellow-light);
  --purple-color: var(--purple-dark);
  --purple-background: var(--purple-light);
  --love-color: var(--love-dark);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(10, 10, 35, 0.05);
  --surface-elevation-2: rgba(10, 10, 35, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);

  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: var(--fs-md);
  font-family: var(--font-sans);
  line-height: var(--lh-base);
  color: var(--foreground-primary);
  background: var(--background-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-height) + 24px);
}

body {
  margin: 0;
  font-family: var(--font-sans);
  color: var(--foreground-primary);
  background: var(--background-primary);
}

::selection {
  background: var(--selection-color);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
  line-height: var(--lh-snug);
  margin: 0 0 12px 0;
}
h1 {
  font-size: var(--fs-3xl);
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
}
h2 {
  font-size: var(--fs-2xl);
  letter-spacing: -0.005em;
}
h3 {
  font-size: var(--fs-xl);
}
h4 {
  font-size: var(--fs-lg);
}
h5 {
  font-size: var(--fs-md);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
h6 {
  font-size: var(--fs-sm);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--foreground-muted);
  font-family: var(--font-mono);
}

p {
  margin: 0 0 12px 0;
}

a {
  color: var(--highlight-color);
  text-decoration: underline;
  text-underline-position: under;
  text-underline-offset: 0.1em;
}
a:hover {
  color: var(--foreground-primary);
}

code,
pre,
kbd,
samp {
  font-family: var(--font-mono);
  font-size: 16px;
}
code {
  background: var(--background-tertiary);
  color: var(--foreground-tertiary);
}
:not(pre) > code {
  border: 1px solid var(--background-quaternary);
  padding: 1px 4px;
  overflow-wrap: anywhere;
  word-break: break-word;
}
pre {
  background: var(--editor-background);
  color: var(--foreground-tertiary);
  padding: 14px 16px;
  font-size: 14px;
  line-height: var(--lh-base);
  max-width: 100%;
  overflow-x: auto;
  margin: 0;
}
pre code {
  display: block;
  width: max-content;
  min-width: 100%;
  background: transparent;
  border: 0;
  padding: 0;
}

:focus-visible {
  outline: var(--focus-outline-width) solid var(--focus-outline-color);
  outline-offset: 0;
}

hr {
  border: 0;
  border-top: 1px solid var(--background-quaternary);
  margin: 24px 0;
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: var(--background-primary);
}
::-webkit-scrollbar-thumb {
  background: var(--background-quaternary);
  border: 2px solid var(--background-primary);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--foreground-muted);
}
```

### src/ui/theme/base.css

Source: https://design.freecodecamp.org/registry/theme/base.css

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
```

## Shared source: button

### src/ui/button/Button.tsx

Source: https://design.freecodecamp.org/registry/button/Button.tsx

```tsx
import React, { forwardRef } from 'react';

export type ButtonVariant =
  | 'default'
  | 'cta'
  | 'danger'
  | 'info'
  | 'ghost'
  | 'link';
export type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  block?: boolean;
  isLoading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'default',
      size = 'md',
      block = false,
      isLoading = false,
      className = '',
      disabled,
      children,
      ...rest
    },
    ref
  ) => {
    const classes = [
      'btn',
      variant !== 'default' && `btn--${variant}`,
      size !== 'md' && `btn--${size}`,
      block && 'btn--block',
      className
    ]
      .filter(Boolean)
      .join(' ');
    return (
      <button
        ref={ref}
        className={classes}
        disabled={disabled || isLoading}
        aria-busy={isLoading ? true : undefined}
        {...rest}
      >
        {isLoading && <span className='btn__spinner' aria-hidden='true' />}
        {children}
      </button>
    );
  }
);
Button.displayName = 'Button';
```

### src/ui/button/button.css

Source: https://design.freecodecamp.org/registry/button/button.css

```css
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  font-weight: var(--fw-regular);
  line-height: var(--lh-base);
  padding: 6px 14px;
  border: var(--border-width-thick) solid var(--foreground-secondary);
  background: var(--background-quaternary);
  color: var(--foreground-secondary);
  text-decoration: none;
  cursor: pointer;
  transition:
    background-color 120ms,
    color 120ms,
    border-color 120ms;
  position: relative;
}
.btn:hover {
  background: var(--foreground-primary);
  color: var(--background-primary);
}
.btn:active::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--gray-90);
  opacity: 0.2;
}
.btn:disabled,
.btn[aria-disabled='true'] {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn--cta {
  background: var(--cta-background);
  color: var(--cta-foreground);
  border-color: var(--cta-background);
}
.btn--cta:hover {
  background: var(--cta-background);
  color: var(--cta-foreground);
  filter: brightness(1.08);
}

.btn--danger {
  border-color: var(--danger-color);
  background: var(--danger-background);
  color: var(--danger-color);
}
.btn--danger:hover {
  background: var(--danger-color);
  color: var(--danger-background);
}

.btn--info {
  border-color: var(--highlight-color);
  background: var(--highlight-background);
  color: var(--highlight-color);
}
.btn--info:hover {
  background: var(--highlight-color);
  color: var(--highlight-background);
}

.btn--ghost {
  border-color: transparent;
  background: transparent;
  color: var(--foreground-secondary);
}
.btn--ghost:hover {
  background: var(--background-tertiary);
  color: var(--foreground-primary);
}

.btn--link {
  border-color: transparent;
  background: transparent;
  color: var(--highlight-color);
  text-decoration: underline;
  padding: 2px 4px;
}
.btn--link:hover {
  background: transparent;
  color: var(--foreground-primary);
}

.btn--sm {
  padding: 4px 10px;
  font-size: var(--fs-sm);
  border-width: var(--border-width-default);
}
.btn--lg {
  padding: 10px 18px;
  font-size: var(--fs-lg);
}
.btn--block {
  display: flex;
  width: 100%;
}

.btn[aria-busy='true'] {
  cursor: progress;
}
.btn__spinner {
  display: inline-block;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: fcc-spin 800ms linear infinite;
  vertical-align: -0.125em;
}
@keyframes fcc-spin {
  to {
    transform: rotate(360deg);
  }
}
```

## Adapting this component

Keep the component's semantics and keyboard behavior. Use the CSS variables to change its appearance. Check the result in your project; copied source does not receive automatic updates.

Source revision: 37aae52 (2026-09-08). Component source: BSD-3-Clause. Preserve the license notice: https://design.freecodecamp.org/license.txt.

Design rules: https://design.freecodecamp.org/handbook.md

