Skip to content

Slash Command Suggestions

The command picker that opens above the composer, with an app rail, grouped options and a matched-command preview.

The command picker that opens above the composer, with an app rail, grouped options and a matched-command preview. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

import { SlashCommand } from "@/registry/bases/newt/ui/slash-command"
import {
  SlashCommandAppIcon,
  SlashCommandSuggestion,
  SlashCommandSuggestionGroup,
  SlashCommandSuggestionMatched,
  SlashCommandSuggestions,
  SlashCommandSuggestionsHeader,
  SlashCommandSuggestionsList,
  SlashCommandSuggestionsRail,
  SlashCommandSuggestionsRailItem,
} from "@/registry/bases/newt/ui/slash-command-suggestions"

const ShieldIcon = () => (
  <svg viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 2 4 5v7c0 5 3.4 8.4 8 10 4.6-1.6 8-5 8-10V5l-8-3Z" />
  </svg>
)

export default function SlashCommandSuggestionsDemo() {
  return (
    <SlashCommandSuggestions>
      <SlashCommandSuggestionsRail aria-label="Filter commands by app">
        <SlashCommandSuggestionsRailItem active aria-label="Frequently used">
          <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
            <path d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7v4l5-4-5-4v2Z" />
            <path d="M11 7h2v5l4 2-1 1.7L11 13V7Z" />
          </svg>
        </SlashCommandSuggestionsRailItem>
        <SlashCommandSuggestionsRailItem aria-label="Moderation commands">
          <SlashCommandAppIcon size="rail">
            <ShieldIcon />
          </SlashCommandAppIcon>
        </SlashCommandSuggestionsRailItem>
      </SlashCommandSuggestionsRail>

      <SlashCommandSuggestionsList aria-label="Slash commands">
        <SlashCommandSuggestionsHeader>
          Frequently Used
        </SlashCommandSuggestionsHeader>

        <SlashCommandSuggestionGroup aria-label="Moderation">
          <SlashCommandSuggestion
            active
            name="ban"
            description="Ban a member from the server."
            appLabel="Moderation"
            icon={
              <SlashCommandAppIcon>
                <ShieldIcon />
              </SlashCommandAppIcon>
            }
          />
          <SlashCommandSuggestion
            name="timeout"
            description="Mute a member for a set amount of time."
            appLabel="Moderation"
            icon={
              <SlashCommandAppIcon>
                <ShieldIcon />
              </SlashCommandAppIcon>
            }
          />
          <SlashCommandSuggestion
            disabled
            name="verify"
            description="Only available to server administrators."
            appLabel="Moderation"
            icon={
              <SlashCommandAppIcon>
                <ShieldIcon />
              </SlashCommandAppIcon>
            }
          />
        </SlashCommandSuggestionGroup>

        <SlashCommandSuggestionMatched>
          <SlashCommand
            name="ban"
            options={[{ name: "user", value: "@username" }]}
          />
        </SlashCommandSuggestionMatched>
      </SlashCommandSuggestionsList>
    </SlashCommandSuggestions>
  )
}
<script setup lang="ts">
import { SlashCommand } from "~~/registry/bases/newt/ui/slash-command"
import {
  SlashCommandAppIcon,
  SlashCommandSuggestion,
  SlashCommandSuggestionGroup,
  SlashCommandSuggestionMatched,
  SlashCommandSuggestions,
  SlashCommandSuggestionsHeader,
  SlashCommandSuggestionsList,
  SlashCommandSuggestionsRail,
  SlashCommandSuggestionsRailItem,
} from "~~/registry/bases/newt/ui/slash-command-suggestions"

const shieldPath = "M12 2 4 5v7c0 5 3.4 8.4 8 10 4.6-1.6 8-5 8-10V5l-8-3Z"
</script>

<template>
  <SlashCommandSuggestions>
    <SlashCommandSuggestionsRail aria-label="Filter commands by app">
      <SlashCommandSuggestionsRailItem active aria-label="Frequently used">
        <svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor">
          <path d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7v4l5-4-5-4v2Z" />
          <path d="M11 7h2v5l4 2-1 1.7L11 13V7Z" />
        </svg>
      </SlashCommandSuggestionsRailItem>
      <SlashCommandSuggestionsRailItem aria-label="Moderation commands">
        <SlashCommandAppIcon size="rail">
          <svg viewBox="0 0 24 24" fill="currentColor">
            <path :d="shieldPath" />
          </svg>
        </SlashCommandAppIcon>
      </SlashCommandSuggestionsRailItem>
    </SlashCommandSuggestionsRail>

    <SlashCommandSuggestionsList aria-label="Slash commands">
      <SlashCommandSuggestionsHeader>
        Frequently Used
      </SlashCommandSuggestionsHeader>

      <SlashCommandSuggestionGroup aria-label="Moderation">
        <SlashCommandSuggestion
          active
          name="ban"
          description="Ban a member from the server."
          app-label="Moderation"
        >
          <template #icon>
            <SlashCommandAppIcon>
              <svg viewBox="0 0 24 24" fill="currentColor">
                <path :d="shieldPath" />
              </svg>
            </SlashCommandAppIcon>
          </template>
        </SlashCommandSuggestion>
        <SlashCommandSuggestion
          name="timeout"
          description="Mute a member for a set amount of time."
          app-label="Moderation"
        >
          <template #icon>
            <SlashCommandAppIcon>
              <svg viewBox="0 0 24 24" fill="currentColor">
                <path :d="shieldPath" />
              </svg>
            </SlashCommandAppIcon>
          </template>
        </SlashCommandSuggestion>
        <SlashCommandSuggestion
          disabled
          name="verify"
          description="Only available to server administrators."
          app-label="Moderation"
        >
          <template #icon>
            <SlashCommandAppIcon>
              <svg viewBox="0 0 24 24" fill="currentColor">
                <path :d="shieldPath" />
              </svg>
            </SlashCommandAppIcon>
          </template>
        </SlashCommandSuggestion>
      </SlashCommandSuggestionGroup>

      <SlashCommandSuggestionMatched>
        <SlashCommand
          name="ban"
          :options="[{ name: 'user', value: '@username' }]"
        />
      </SlashCommandSuggestionMatched>
    </SlashCommandSuggestionsList>
  </SlashCommandSuggestions>
</template>

Installation

pnpm dlx newtui@latest add slash-command-suggestions
npx newtui@latest add slash-command-suggestions
yarn dlx newtui@latest add slash-command-suggestions
bunx newtui@latest add slash-command-suggestions

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Add the components it builds on

Copy the source into your project

import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

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

/*
 * The picker that opens above the composer once a message starts with `/`.
 * Focus stays in the composer: wire `aria-activedescendant` on the input to
 * the id of the active option rather than moving focus into the list.
 */
const SlashCommandSuggestions = React.forwardRef<
  HTMLDivElement,
  React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "flex max-h-80 w-full max-w-[640px] overflow-hidden rounded-lg bg-newt-bg-floating text-newt-text-primary shadow-elevation-high",
      className
    )}
    {...props}
  />
))
SlashCommandSuggestions.displayName = "SlashCommandSuggestions"

/* Vertical strip of app icons that filters the list by app. */
const SlashCommandSuggestionsRail = React.forwardRef<
  HTMLElement,
  React.ComponentProps<"nav">
>(({ className, ...props }, ref) => (
  <nav
    ref={ref}
    className={cn(
      "flex shrink-0 flex-col items-center gap-2 overflow-y-auto border-e border-newt-border bg-newt-bg-base p-2",
      className
    )}
    {...props}
  />
))
SlashCommandSuggestionsRail.displayName = "SlashCommandSuggestionsRail"

export interface SlashCommandSuggestionsRailItemProps extends React.ComponentProps<"button"> {
  active?: boolean
}

const SlashCommandSuggestionsRailItem = React.forwardRef<
  HTMLButtonElement,
  SlashCommandSuggestionsRailItemProps
>(({ className, active = false, type = "button", ...props }, ref) => (
  <button
    ref={ref}
    type={type}
    aria-pressed={active}
    data-state={active ? "active" : "inactive"}
    className={cn(
      "inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-newt-text-muted transition-colors duration-fast ease-newt",
      "hover:bg-newt-bg-hover hover:text-newt-text-primary",
      "data-[state=active]:bg-newt-bg-active data-[state=active]:text-newt-text-primary",
      "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
      className
    )}
    {...props}
  />
))
SlashCommandSuggestionsRailItem.displayName = "SlashCommandSuggestionsRailItem"

const SlashCommandSuggestionsList = React.forwardRef<
  HTMLDivElement,
  React.ComponentProps<"div">
>(({ className, role = "listbox", ...props }, ref) => (
  <div
    ref={ref}
    role={role}
    className={cn("min-w-0 flex-1 overflow-y-auto p-2", className)}
    {...props}
  />
))
SlashCommandSuggestionsList.displayName = "SlashCommandSuggestionsList"

/* Sticky section header, as used by the "Frequently Used" block. */
const SlashCommandSuggestionsHeader = React.forwardRef<
  HTMLParagraphElement,
  React.ComponentProps<"p">
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    role="presentation"
    className={cn(
      "sticky top-0 z-[1] m-0 flex items-center gap-1.5 bg-newt-bg-floating px-2 py-1.5 text-sm font-semibold text-newt-text-primary",
      className
    )}
    {...props}
  />
))
SlashCommandSuggestionsHeader.displayName = "SlashCommandSuggestionsHeader"

const SlashCommandSuggestionGroup = React.forwardRef<
  HTMLElement,
  React.ComponentProps<"section">
>(({ className, role = "group", ...props }, ref) => (
  <section
    ref={ref}
    role={role}
    className={cn("min-w-0", className)}
    {...props}
  />
))
SlashCommandSuggestionGroup.displayName = "SlashCommandSuggestionGroup"

const appIconVariants = cva(
  "inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-newt-bg-elevated text-newt-text-muted [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>svg]:h-1/2 [&>svg]:w-1/2",
  {
    variants: {
      size: {
        header: "h-4 w-4",
        row: "h-6 w-6",
        rail: "h-8 w-8",
      },
    },
    defaultVariants: { size: "row" },
  }
)

export interface SlashCommandAppIconProps
  extends React.ComponentProps<"span">, VariantProps<typeof appIconVariants> {}

const SlashCommandAppIcon = React.forwardRef<
  HTMLSpanElement,
  SlashCommandAppIconProps
>(({ className, size, ...props }, ref) => (
  <span
    ref={ref}
    aria-hidden="true"
    className={cn(appIconVariants({ size }), className)}
    {...props}
  />
))
SlashCommandAppIcon.displayName = "SlashCommandAppIcon"

export interface SlashCommandSuggestionProps extends Omit<
  React.ComponentProps<"div">,
  "children"
> {
  /** Command path without the leading slash, e.g. `moderation ban`. */
  name: string
  description?: string
  /** Name of the app the command belongs to, shown on the right. */
  appLabel?: string
  /** Icon for the owning app; pair with `<SlashCommandAppIcon>`. */
  icon?: React.ReactNode
  active?: boolean
  disabled?: boolean
}

const SlashCommandSuggestion = React.forwardRef<
  HTMLDivElement,
  SlashCommandSuggestionProps
>(
  (
    {
      className,
      name,
      description,
      appLabel,
      icon,
      active = false,
      disabled = false,
      ...props
    },
    ref
  ) => (
    <div
      ref={ref}
      role="option"
      aria-selected={active}
      aria-disabled={disabled || undefined}
      data-state={active ? "active" : "inactive"}
      className={cn(
        "grid min-h-12 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5",
        disabled
          ? "cursor-default opacity-60"
          : "cursor-pointer hover:bg-newt-bg-hover",
        "data-[state=active]:bg-newt-bg-active",
        className
      )}
      {...props}
    >
      {icon}
      <div className="flex min-w-0 flex-col gap-0.5">
        {/* The client spaces the slash away from the command path. */}
        <span className="text-[15px] font-semibold leading-tight text-newt-text-primary">
          /&nbsp;{name}
        </span>
        {description ? (
          <span className="truncate text-[13px] leading-snug text-newt-text-secondary">
            {description}
          </span>
        ) : null}
      </div>
      {appLabel ? (
        <span className="shrink-0 text-[13px] text-newt-text-secondary">
          {appLabel}
        </span>
      ) : null}
    </div>
  )
)
SlashCommandSuggestion.displayName = "SlashCommandSuggestion"

export interface SlashCommandSuggestionMatchedProps extends React.ComponentProps<"div"> {
  active?: boolean
}

/* Row that previews the command already typed, rendered with `<SlashCommand>`. */
const SlashCommandSuggestionMatched = React.forwardRef<
  HTMLDivElement,
  SlashCommandSuggestionMatchedProps
>(({ className, active = false, ...props }, ref) => (
  <div
    ref={ref}
    role="option"
    aria-selected={active}
    data-state={active ? "active" : "inactive"}
    className={cn(
      "min-h-11 cursor-pointer rounded-md px-3 py-2 hover:bg-newt-bg-hover",
      "data-[state=active]:bg-newt-bg-active",
      className
    )}
    {...props}
  />
))
SlashCommandSuggestionMatched.displayName = "SlashCommandSuggestionMatched"

export {
  SlashCommandSuggestions,
  SlashCommandSuggestionsRail,
  SlashCommandSuggestionsRailItem,
  SlashCommandSuggestionsList,
  SlashCommandSuggestionsHeader,
  SlashCommandSuggestionGroup,
  SlashCommandAppIcon,
  SlashCommandSuggestion,
  SlashCommandSuggestionMatched,
}
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <!--
    The picker that opens above the composer once a message starts with `/`.
    Focus stays in the composer: wire `aria-activedescendant` on the input to
    the id of the active option rather than moving focus into the list.
  -->
  <div
    :class="
      cn(
        'flex max-h-80 w-full max-w-[640px] overflow-hidden rounded-lg bg-newt-bg-floating text-newt-text-primary shadow-elevation-high',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <!-- Vertical strip of app icons that filters the list by app. -->
  <nav
    :class="
      cn(
        'flex shrink-0 flex-col items-center gap-2 overflow-y-auto border-e border-newt-border bg-newt-bg-base p-2',
        props.class
      )
    "
  >
    <slot />
  </nav>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = withDefaults(
  defineProps<{ class?: HTMLAttributes["class"]; active?: boolean }>(),
  { active: false }
)
</script>

<template>
  <button
    type="button"
    :aria-pressed="props.active"
    :data-state="props.active ? 'active' : 'inactive'"
    :class="
      cn(
        'inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-newt-text-muted transition-colors duration-fast ease-newt',
        'hover:bg-newt-bg-hover hover:text-newt-text-primary',
        'data-[state=active]:bg-newt-bg-active data-[state=active]:text-newt-text-primary',
        'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link',
        props.class
      )
    "
  >
    <slot />
  </button>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div
    role="listbox"
    :class="cn('min-w-0 flex-1 overflow-y-auto p-2', props.class)"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <!-- Sticky section header, as used by the "Frequently Used" block. -->
  <p
    role="presentation"
    :class="
      cn(
        'sticky top-0 z-[1] m-0 flex items-center gap-1.5 bg-newt-bg-floating px-2 py-1.5 text-sm font-semibold text-newt-text-primary',
        props.class
      )
    "
  >
    <slot />
  </p>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <section role="group" :class="cn('min-w-0', props.class)">
    <slot />
  </section>
</template>
<script setup lang="ts">
import { cva } from "class-variance-authority"
import type { HTMLAttributes } from "vue"

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

const appIconVariants = cva(
  "inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-newt-bg-elevated text-newt-text-muted [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>svg]:h-1/2 [&>svg]:w-1/2",
  {
    variants: {
      size: {
        header: "h-4 w-4",
        row: "h-6 w-6",
        rail: "h-8 w-8",
      },
    },
    defaultVariants: { size: "row" },
  }
)

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    size?: "header" | "row" | "rail"
  }>(),
  { size: "row" }
)
</script>

<template>
  <span
    aria-hidden="true"
    :class="cn(appIconVariants({ size: props.size }), props.class)"
  >
    <slot />
  </span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** Command path without the leading slash, e.g. `moderation ban`. */
    name: string
    description?: string
    /** Name of the app the command belongs to, shown on the right. */
    appLabel?: string
    active?: boolean
    disabled?: boolean
  }>(),
  { active: false, disabled: false }
)
</script>

<template>
  <div
    role="option"
    :aria-selected="props.active"
    :aria-disabled="props.disabled || undefined"
    :data-state="props.active ? 'active' : 'inactive'"
    :class="
      cn(
        'grid min-h-12 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5',
        props.disabled
          ? 'cursor-default opacity-60'
          : 'cursor-pointer hover:bg-newt-bg-hover',
        'data-[state=active]:bg-newt-bg-active',
        props.class
      )
    "
  >
    <slot name="icon" />
    <div class="flex min-w-0 flex-col gap-0.5">
      <!-- The client spaces the slash away from the command path. -->
      <span
        class="text-[15px] font-semibold leading-tight text-newt-text-primary"
      >
        /&nbsp;{{ props.name }}
      </span>
      <span
        v-if="props.description"
        class="truncate text-[13px] leading-snug text-newt-text-secondary"
      >
        {{ props.description }}
      </span>
    </div>
    <span
      v-if="props.appLabel"
      class="shrink-0 text-[13px] text-newt-text-secondary"
    >
      {{ props.appLabel }}
    </span>
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = withDefaults(
  defineProps<{ class?: HTMLAttributes["class"]; active?: boolean }>(),
  { active: false }
)
</script>

<template>
  <!-- Row that previews the command already typed, rendered with `<SlashCommand>`. -->
  <div
    role="option"
    :aria-selected="props.active"
    :data-state="props.active ? 'active' : 'inactive'"
    :class="
      cn(
        'min-h-11 cursor-pointer rounded-md px-3 py-2 hover:bg-newt-bg-hover',
        'data-[state=active]:bg-newt-bg-active',
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
export { default as SlashCommandSuggestions } from "./SlashCommandSuggestions.vue"
export { default as SlashCommandSuggestionsRail } from "./SlashCommandSuggestionsRail.vue"
export { default as SlashCommandSuggestionsRailItem } from "./SlashCommandSuggestionsRailItem.vue"
export { default as SlashCommandSuggestionsList } from "./SlashCommandSuggestionsList.vue"
export { default as SlashCommandSuggestionsHeader } from "./SlashCommandSuggestionsHeader.vue"
export { default as SlashCommandSuggestionGroup } from "./SlashCommandSuggestionGroup.vue"
export { default as SlashCommandAppIcon } from "./SlashCommandAppIcon.vue"
export { default as SlashCommandSuggestion } from "./SlashCommandSuggestion.vue"
export { default as SlashCommandSuggestionMatched } from "./SlashCommandSuggestionMatched.vue"

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 { SlashCommandSuggestions } from "@/components/ui/slash-command-suggestions"

export function Example() {
  return <SlashCommandSuggestions />
}
<script setup lang="ts">
import { SlashCommandSuggestions, SlashCommandSuggestionsRail, SlashCommandSuggestionsRailItem, SlashCommandSuggestionsList, SlashCommandSuggestionsHeader, SlashCommandSuggestionGroup, SlashCommandAppIcon, SlashCommandSuggestion, SlashCommandSuggestionMatched } from "@/components/ui/slash-command-suggestions"
</script>

<template>
  <SlashCommandSuggestions />
</template>

API reference

SlashCommandSuggestionsRailItem

PropType
active?boolean | undefined

Optional active value.

Typeboolean | undefined

SlashCommandSuggestion

PropType
name?string

Command path without the leading slash, e.g. `moderation ban`.

Typestring
description?string | undefined

Optional description value.

Typestring | undefined
appLabel?string | undefined

Name of the app the command belongs to, shown on the right.

Typestring | undefined
icon?React.ReactNode | undefined

Icon for the owning app; pair with `<SlashCommandAppIcon>`.

TypeReact.ReactNode | undefined
active?boolean | undefined

Optional active value.

Typeboolean | undefined
disabled?boolean | undefined

Optional disabled value.

Typeboolean | undefined

SlashCommandSuggestionMatched

PropType
active?boolean | undefined

Optional active value.

Typeboolean | undefined

The registry exports SlashCommandSuggestions, SlashCommandSuggestionsRail, SlashCommandSuggestionsRailItem, SlashCommandSuggestionsList, SlashCommandSuggestionsHeader, SlashCommandSuggestionGroup, SlashCommandAppIcon, SlashCommandSuggestion, SlashCommandSuggestionMatched. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Keep focus in the composer and point aria-activedescendant at the active option. Announce disabled options and label both the app rail and command list.

Tokens

Slash Command Suggestions 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.