Skip to content

Command panel

A command browser with a selectable suggestion list and a detailed option card.

A command browser with a selectable suggestion list and a detailed option card. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

import { CommandPanel } from "@/registry/bases/newt/blocks/command-panel/command-panel"

const COMMANDS = [
  {
    id: "poll",
    name: "poll create",
    description: "Create a poll for the current channel.",
    appLabel: "Community",
    options: [
      {
        name: "question",
        type: "STRING",
        required: true,
        description: "The question people will answer.",
      },
      {
        name: "duration",
        type: "INTEGER",
        description: "How many hours the poll stays open.",
      },
    ],
  },
  {
    id: "remind",
    name: "remind",
    description: "Schedule a reminder in this channel.",
    appLabel: "Utilities",
    options: [
      { name: "when", type: "STRING", required: true },
      { name: "message", type: "STRING", required: true },
    ],
  },
  {
    id: "topic",
    name: "topic suggest",
    description: "Suggest a conversation prompt.",
    appLabel: "Community",
  },
] as const

export default function CommandPanelDemo() {
  return <CommandPanel commands={COMMANDS} defaultSelectedId="poll" />
}
<script setup lang="ts">
import { CommandPanel } from "~~/registry/bases/newt/blocks/command-panel"
import type { CommandPanelCommand } from "~~/registry/bases/newt/blocks/command-panel"

const COMMANDS: readonly CommandPanelCommand[] = [
  {
    id: "poll",
    name: "poll create",
    description: "Create a poll for the current channel.",
    appLabel: "Community",
    options: [
      {
        name: "question",
        type: "STRING",
        required: true,
        description: "The question people will answer.",
      },
      {
        name: "duration",
        type: "INTEGER",
        description: "How many hours the poll stays open.",
      },
    ],
  },
  {
    id: "remind",
    name: "remind",
    description: "Schedule a reminder in this channel.",
    appLabel: "Utilities",
    options: [
      { name: "when", type: "STRING", required: true },
      { name: "message", type: "STRING", required: true },
    ],
  },
  {
    id: "topic",
    name: "topic suggest",
    description: "Suggest a conversation prompt.",
    appLabel: "Community",
  },
]
</script>

<template>
  <CommandPanel :commands="COMMANDS" default-selected-id="poll" />
</template>

Installation

pnpm dlx newtui@latest add command-panel
npx newtui@latest add command-panel
yarn dlx newtui@latest add command-panel
bunx newtui@latest add command-panel

Add the components it builds on

Copy the source into your project

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  BotCommandCard,
  BotCommandCardDescription,
  BotCommandCardHeader,
  BotCommandCardName,
  BotCommandCardOption,
  BotCommandCardOptions,
} from "@/registry/bases/newt/ui/bot-command-card"
import {
  SlashCommandAppIcon,
  SlashCommandSuggestion,
  SlashCommandSuggestionGroup,
  SlashCommandSuggestions,
  SlashCommandSuggestionsHeader,
  SlashCommandSuggestionsList,
} from "@/registry/bases/newt/ui/slash-command-suggestions"

export interface CommandPanelOption {
  name: string
  type?: string
  required?: boolean
  description?: string
}

export interface CommandPanelCommand {
  id: string
  name: string
  description: string
  appLabel?: string
  options?: readonly CommandPanelOption[]
}

export interface CommandPanelProps extends Omit<
  React.ComponentProps<"section">,
  "children"
> {
  commands: readonly CommandPanelCommand[]
  selectedId?: string
  defaultSelectedId?: string
  onSelectedIdChange?: (id: string) => void
  listLabel?: string
  emptyLabel?: string
}

export function CommandPanel({
  commands,
  selectedId: controlledSelectedId,
  defaultSelectedId,
  onSelectedIdChange,
  listLabel = "Available commands",
  emptyLabel = "No commands available.",
  className,
  ...props
}: CommandPanelProps) {
  const [uncontrolledSelectedId, setUncontrolledSelectedId] = React.useState(
    defaultSelectedId ?? commands[0]?.id
  )
  const instanceId = React.useId().replaceAll(":", "")
  const selectedId = controlledSelectedId ?? uncontrolledSelectedId
  const selectedIndex = Math.max(
    0,
    commands.findIndex((command) => command.id === selectedId)
  )
  const selectedCommand = commands[selectedIndex]

  function select(id: string) {
    if (controlledSelectedId === undefined) setUncontrolledSelectedId(id)
    onSelectedIdChange?.(id)
  }

  function moveSelection(key: "first" | "last" | "next" | "previous") {
    if (commands.length === 0) return
    const nextIndex =
      key === "first"
        ? 0
        : key === "last"
          ? commands.length - 1
          : key === "next"
            ? (selectedIndex + 1) % commands.length
            : (selectedIndex - 1 + commands.length) % commands.length
    const command = commands[nextIndex]
    if (command) select(command.id)
  }

  return (
    <section
      className={cn(
        "grid w-full grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]",
        className
      )}
      {...props}
    >
      <SlashCommandSuggestions className="max-w-none">
        <SlashCommandSuggestionsList
          aria-label={listLabel}
          aria-activedescendant={
            selectedCommand
              ? `${instanceId}-command-${selectedCommand.id}`
              : undefined
          }
          tabIndex={0}
          onKeyDown={(event) => {
            if (event.key === "ArrowDown") {
              event.preventDefault()
              moveSelection("next")
            } else if (event.key === "ArrowUp") {
              event.preventDefault()
              moveSelection("previous")
            } else if (event.key === "Home") {
              event.preventDefault()
              moveSelection("first")
            } else if (event.key === "End") {
              event.preventDefault()
              moveSelection("last")
            }
          }}
        >
          <SlashCommandSuggestionsHeader>
            Commands
          </SlashCommandSuggestionsHeader>
          <SlashCommandSuggestionGroup aria-label={listLabel}>
            {commands.map((command) => (
              <SlashCommandSuggestion
                key={command.id}
                id={`${instanceId}-command-${command.id}`}
                active={command.id === selectedCommand?.id}
                name={command.name}
                description={command.description}
                appLabel={command.appLabel}
                icon={
                  <SlashCommandAppIcon>
                    {command.name.slice(0, 1).toUpperCase()}
                  </SlashCommandAppIcon>
                }
                onClick={() => select(command.id)}
                onMouseEnter={() => select(command.id)}
              />
            ))}
          </SlashCommandSuggestionGroup>
          {commands.length === 0 ? (
            <p className="px-2 py-4 text-sm text-newt-text-muted">
              {emptyLabel}
            </p>
          ) : null}
        </SlashCommandSuggestionsList>
      </SlashCommandSuggestions>

      {selectedCommand ? (
        <BotCommandCard className="max-w-none" aria-live="polite">
          <BotCommandCardHeader>
            <BotCommandCardName>{selectedCommand.name}</BotCommandCardName>
            {selectedCommand.appLabel ? (
              <span className="text-xs text-newt-text-muted">
                {selectedCommand.appLabel}
              </span>
            ) : null}
          </BotCommandCardHeader>
          <BotCommandCardDescription>
            {selectedCommand.description}
          </BotCommandCardDescription>
          {selectedCommand.options?.length ? (
            <BotCommandCardOptions>
              {selectedCommand.options.map((option) => (
                <BotCommandCardOption key={option.name} {...option} />
              ))}
            </BotCommandCardOptions>
          ) : null}
        </BotCommandCard>
      ) : (
        <div
          className="rounded-md border border-newt-border bg-newt-bg-elevated p-4 text-sm text-newt-text-muted"
          aria-live="polite"
        >
          {emptyLabel}
        </div>
      )}
    </section>
  )
}
<script setup lang="ts">
import { computed, shallowRef, useId } from "vue"
import type { HTMLAttributes } from "vue"
import {
  BotCommandCard,
  BotCommandCardDescription,
  BotCommandCardHeader,
  BotCommandCardName,
  BotCommandCardOption,
  BotCommandCardOptions,
} from "~~/registry/bases/newt/ui/bot-command-card"
import {
  SlashCommandAppIcon,
  SlashCommandSuggestion,
  SlashCommandSuggestionGroup,
  SlashCommandSuggestions,
  SlashCommandSuggestionsHeader,
  SlashCommandSuggestionsList,
} from "~~/registry/bases/newt/ui/slash-command-suggestions"

import { cn } from "@/lib/utils"

import type { CommandPanelCommand } from "./types"

const props = withDefaults(
  defineProps<{
    commands: readonly CommandPanelCommand[]
    selectedId?: string
    defaultSelectedId?: string
    listLabel?: string
    emptyLabel?: string
    class?: HTMLAttributes["class"]
  }>(),
  {
    listLabel: "Available commands",
    emptyLabel: "No commands available.",
  }
)

const emit = defineEmits<{ "update:selectedId": [id: string] }>()
const uncontrolledSelectedId = shallowRef(
  props.defaultSelectedId ?? props.commands[0]?.id
)
const instanceId = useId().replaceAll(":", "")
const currentSelectedId = computed(
  () => props.selectedId ?? uncontrolledSelectedId.value
)
const selectedIndex = computed(() =>
  Math.max(
    0,
    props.commands.findIndex(
      (command) => command.id === currentSelectedId.value
    )
  )
)
const selectedCommand = computed(() => props.commands[selectedIndex.value])

function select(id: string) {
  if (props.selectedId === undefined) uncontrolledSelectedId.value = id
  emit("update:selectedId", id)
}

function moveSelection(key: "first" | "last" | "next" | "previous") {
  if (props.commands.length === 0) return
  const nextIndex =
    key === "first"
      ? 0
      : key === "last"
        ? props.commands.length - 1
        : key === "next"
          ? (selectedIndex.value + 1) % props.commands.length
          : (selectedIndex.value - 1 + props.commands.length) %
            props.commands.length
  const command = props.commands[nextIndex]
  if (command) select(command.id)
}

function handleKeydown(event: KeyboardEvent) {
  if (event.key === "ArrowDown") {
    event.preventDefault()
    moveSelection("next")
  } else if (event.key === "ArrowUp") {
    event.preventDefault()
    moveSelection("previous")
  } else if (event.key === "Home") {
    event.preventDefault()
    moveSelection("first")
  } else if (event.key === "End") {
    event.preventDefault()
    moveSelection("last")
  }
}
</script>

<template>
  <section
    :class="
      cn(
        'grid w-full grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]',
        props.class
      )
    "
  >
    <SlashCommandSuggestions class="max-w-none">
      <SlashCommandSuggestionsList
        :aria-label="props.listLabel"
        :aria-activedescendant="
          selectedCommand
            ? `${instanceId}-command-${selectedCommand.id}`
            : undefined
        "
        tabindex="0"
        @keydown="handleKeydown"
      >
        <SlashCommandSuggestionsHeader>Commands</SlashCommandSuggestionsHeader>
        <SlashCommandSuggestionGroup :aria-label="props.listLabel">
          <SlashCommandSuggestion
            v-for="command in props.commands"
            :id="`${instanceId}-command-${command.id}`"
            :key="command.id"
            :active="command.id === selectedCommand?.id"
            :name="command.name"
            :description="command.description"
            :app-label="command.appLabel"
            @click="select(command.id)"
            @mouseenter="select(command.id)"
          >
            <template #icon>
              <SlashCommandAppIcon>
                {{ command.name.slice(0, 1).toUpperCase() }}
              </SlashCommandAppIcon>
            </template>
          </SlashCommandSuggestion>
        </SlashCommandSuggestionGroup>
        <p
          v-if="props.commands.length === 0"
          class="px-2 py-4 text-sm text-newt-text-muted"
        >
          {{ props.emptyLabel }}
        </p>
      </SlashCommandSuggestionsList>
    </SlashCommandSuggestions>

    <BotCommandCard
      v-if="selectedCommand"
      class="max-w-none"
      aria-live="polite"
    >
      <BotCommandCardHeader>
        <BotCommandCardName>{{ selectedCommand.name }}</BotCommandCardName>
        <span
          v-if="selectedCommand.appLabel"
          class="text-xs text-newt-text-muted"
        >
          {{ selectedCommand.appLabel }}
        </span>
      </BotCommandCardHeader>
      <BotCommandCardDescription>
        {{ selectedCommand.description }}
      </BotCommandCardDescription>
      <BotCommandCardOptions v-if="selectedCommand.options?.length">
        <BotCommandCardOption
          v-for="option in selectedCommand.options"
          :key="option.name"
          v-bind="option"
        />
      </BotCommandCardOptions>
    </BotCommandCard>
    <div
      v-else
      class="rounded-md border border-newt-border bg-newt-bg-elevated p-4 text-sm text-newt-text-muted"
      aria-live="polite"
    >
      {{ props.emptyLabel }}
    </div>
  </section>
</template>
export interface CommandPanelOption {
  name: string
  type?: string
  required?: boolean
  description?: string
}

export interface CommandPanelCommand {
  id: string
  name: string
  description: string
  appLabel?: string
  options?: readonly CommandPanelOption[]
}
export { default as CommandPanel } from "./CommandPanel.vue"
export type { CommandPanelCommand, CommandPanelOption } from "./types"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { CommandPanel } from "@/components/command-panel"

export function Example() {
  return <CommandPanel />
}
<script setup lang="ts">
import { CommandPanel } from "@/components/command-panel"
</script>

<template>
  <CommandPanel />
</template>

API reference

CommandPanel

PropType
commands?readonly CommandPanelCommand[]

Required commands value.

Typereadonly CommandPanelCommand[]
selectedId?string | undefined

Optional selectedId value.

Typestring | undefined
defaultSelectedId?string | undefined

Optional defaultSelectedId value.

Typestring | undefined
onSelectedIdChange?(id: string) => void | undefined

Optional onSelectedIdChange value.

Type(id: string) => void | undefined
listLabel?string | undefined

Optional listLabel value.

Typestring | undefined
emptyLabel?string | undefined

Optional emptyLabel value.

Typestring | undefined

The registry exports CommandPanel plus the CommandPanelCommand and CommandPanelOption types. It also accepts the native section attributes; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

The suggestion list is one keyboard focus stop. Arrow keys, Home and End move its active descendant while the detail card is announced through a polite live region. Give the list a contextual listLabel, keep command IDs unique, and do not communicate the active command by colour alone.

Tokens

Command panel reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.