Skip to content

Message Composer

The channel input bar: attachment control, text field with combobox hooks, and send.

The channel input bar: attachment control, text field with combobox hooks, and send. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

"use client"

import * as React from "react"

import { MessageComposer } from "@/registry/bases/newt/ui/message-composer"

export default function MessageComposerDemo() {
  const [value, setValue] = React.useState("")

  return (
    <MessageComposer
      channelName="general"
      value={value}
      onValueChange={setValue}
      onSubmit={() => setValue("")}
      className="w-full max-w-md"
    />
  )
}
<script setup lang="ts">
import { ref } from "vue"
import { MessageComposer } from "~~/registry/bases/newt/ui/message-composer"

const value = ref("")
</script>

<template>
  <MessageComposer
    v-model="value"
    channel-name="general"
    class="w-full max-w-md"
    @submit="value = ''"
  />
</template>

Installation

pnpm dlx newtui@latest add message-composer
npx newtui@latest add message-composer
yarn dlx newtui@latest add message-composer
bunx newtui@latest add message-composer

Copy the source into your project

"use client"

import * as React from "react"

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

const AddIcon = () => (
  <svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
    <path
      fill="currentColor"
      d="M11 5a1 1 0 1 1 2 0v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6z"
    />
  </svg>
)

const SendIcon = () => (
  <svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
    <path fill="currentColor" d="M3 20.5v-6l8-2.5-8-2.5v-6l19 8.5z" />
  </svg>
)

const composerButton =
  "inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-inherit hover:bg-newt-bg-hover hover:text-newt-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50"

export interface MessageComposerProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "onSubmit" | "onChange" | "children"
> {
  /** Channel the message goes to; drives the default placeholder and labels. */
  channelName: string
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  placeholder?: string
  /** Sent on Enter and on the send control. */
  onSubmit?: (value: string) => void
  onEscape?: () => void
  /** Arrow keys, for driving a suggestion list above the composer. */
  onNavigate?: (direction: "up" | "down") => void
  /** Wire the input up as a combobox for slash-command autocomplete. */
  autocomplete?: boolean
  ariaControls?: string
  ariaExpanded?: boolean
  ariaActiveDescendant?: string
  /** Extra controls rendered between the field and the send button. */
  actions?: React.ReactNode
  onAdd?: () => void
}

const MessageComposer = React.forwardRef<HTMLDivElement, MessageComposerProps>(
  (
    {
      className,
      channelName,
      value,
      defaultValue = "",
      onValueChange,
      placeholder,
      onSubmit,
      onEscape,
      onNavigate,
      autocomplete = false,
      ariaControls,
      ariaExpanded,
      ariaActiveDescendant,
      actions,
      onAdd,
      ...props
    },
    ref
  ) => {
    const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
    const text = value ?? uncontrolled
    const hasValue = text.trim().length > 0
    const label = placeholder ?? `Message #${channelName}`

    const change = (next: string) => {
      if (value === undefined) setUncontrolled(next)
      onValueChange?.(next)
    }

    const submit = () => {
      if (!hasValue) return
      onSubmit?.(text)
    }

    const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
      switch (event.key) {
        case "Enter":
          event.preventDefault()
          submit()
          return
        case "Escape":
          event.preventDefault()
          onEscape?.()
          return
        case "ArrowUp":
        case "ArrowDown":
          if (!onNavigate) return
          event.preventDefault()
          onNavigate(event.key === "ArrowUp" ? "up" : "down")
      }
    }

    return (
      <div
        ref={ref}
        role="group"
        aria-label={`Message composer for #${channelName}`}
        className={cn(
          "flex min-h-11 min-w-0 items-center gap-2 rounded-md border border-newt-border bg-newt-bg-input-elevated px-4 text-newt-text-muted",
          className
        )}
        {...props}
      >
        <button
          type="button"
          aria-label="Add attachment"
          className={composerButton}
          onClick={onAdd}
        >
          <AddIcon />
        </button>
        <div className="flex min-w-0 flex-1 items-center gap-2">
          <input
            type="text"
            value={text}
            placeholder={label}
            aria-label={label}
            spellCheck={false}
            role={autocomplete ? "combobox" : undefined}
            autoComplete={autocomplete ? "off" : undefined}
            aria-controls={autocomplete ? ariaControls : undefined}
            aria-expanded={autocomplete ? ariaExpanded : undefined}
            aria-activedescendant={
              autocomplete ? ariaActiveDescendant : undefined
            }
            onChange={(event) => change(event.target.value)}
            onKeyDown={onKeyDown}
            className="w-full border-0 bg-transparent font-sans text-[15px] text-newt-text-primary outline-none placeholder:text-newt-text-muted"
          />
        </div>
        {actions}
        <button
          type="button"
          aria-label="Send message"
          disabled={!hasValue}
          onClick={submit}
          /* The send control only lights up once there is something to send. */
          className={cn(composerButton, hasValue && "text-newt-brand")}
        >
          <SendIcon />
        </button>
      </div>
    )
  }
)
MessageComposer.displayName = "MessageComposer"

export { MessageComposer }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"

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

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** Channel the message goes to; drives the placeholder and the labels. */
    channelName: string
    placeholder?: string
    /** Wire the input up as a combobox for slash-command autocomplete. */
    autocomplete?: boolean
    ariaControls?: string
    ariaExpanded?: boolean
    ariaActivedescendant?: string
  }>(),
  { autocomplete: false }
)

const emit = defineEmits<{
  submit: [value: string]
  escape: []
  /** Arrow keys, for driving a suggestion list above the composer. */
  navigate: [direction: "up" | "down"]
  add: []
}>()

const text = defineModel<string>({ default: "" })

const label = computed(
  () => props.placeholder ?? `Message #${props.channelName}`
)
const hasValue = computed(() => text.value.trim().length > 0)

const composerButton =
  "inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-inherit hover:bg-newt-bg-hover hover:text-newt-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50"

function submit() {
  if (!hasValue.value) return
  emit("submit", text.value)
}

function onKeydown(event: KeyboardEvent) {
  switch (event.key) {
    case "Enter":
      event.preventDefault()
      submit()
      return
    case "Escape":
      event.preventDefault()
      emit("escape")
      return
    case "ArrowUp":
    case "ArrowDown":
      event.preventDefault()
      emit("navigate", event.key === "ArrowUp" ? "up" : "down")
  }
}
</script>

<template>
  <div
    role="group"
    :aria-label="`Message composer for #${props.channelName}`"
    :class="
      cn(
        'flex min-h-11 min-w-0 items-center gap-2 rounded-md border border-newt-border bg-newt-bg-input-elevated px-4 text-newt-text-muted',
        props.class
      )
    "
  >
    <button
      type="button"
      aria-label="Add attachment"
      :class="composerButton"
      @click="emit('add')"
    >
      <svg viewBox="0 0 24 24" aria-hidden="true" class="h-5 w-5">
        <path
          fill="currentColor"
          d="M11 5a1 1 0 1 1 2 0v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6z"
        />
      </svg>
    </button>
    <div class="flex min-w-0 flex-1 items-center gap-2">
      <input
        v-model="text"
        type="text"
        :placeholder="label"
        :aria-label="label"
        :spellcheck="false"
        :role="props.autocomplete ? 'combobox' : undefined"
        :autocomplete="props.autocomplete ? 'off' : undefined"
        :aria-controls="props.autocomplete ? props.ariaControls : undefined"
        :aria-expanded="props.autocomplete ? props.ariaExpanded : undefined"
        :aria-activedescendant="
          props.autocomplete ? props.ariaActivedescendant : undefined
        "
        class="w-full border-0 bg-transparent font-sans text-[15px] text-newt-text-primary outline-none placeholder:text-newt-text-muted"
        @keydown="onKeydown"
      />
    </div>
    <slot name="actions" />
    <button
      type="button"
      aria-label="Send message"
      :disabled="!hasValue"
      :class="cn(composerButton, hasValue && 'text-newt-brand')"
      @click="submit"
    >
      <!-- The send control only lights up once there is something to send. -->
      <svg viewBox="0 0 24 24" aria-hidden="true" class="h-5 w-5">
        <path fill="currentColor" d="M3 20.5v-6l8-2.5-8-2.5v-6l19 8.5z" />
      </svg>
    </button>
  </div>
</template>
export { default as MessageComposer } from "./MessageComposer.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 { MessageComposer } from "@/components/ui/message-composer"

export function Example() {
  return <MessageComposer />
}
<script setup lang="ts">
import { MessageComposer } from "@/components/ui/message-composer"
</script>

<template>
  <MessageComposer />
</template>

API reference

MessageComposer

PropType
channelName?string

Channel the message goes to; drives the default placeholder and labels.

Typestring
value?string | undefined

Optional value value.

Typestring | undefined
defaultValue?string | undefined

Optional defaultValue value.

Typestring | undefined
onValueChange?(value: string) => void | undefined

Optional onValueChange value.

Type(value: string) => void | undefined
placeholder?string | undefined

Optional placeholder value.

Typestring | undefined
onSubmit?(value: string) => void | undefined

Sent on Enter and on the send control.

Type(value: string) => void | undefined
onEscape?() => void | undefined

Optional onEscape value.

Type() => void | undefined
onNavigate?(direction: "up" | "down") => void | undefined

Arrow keys, for driving a suggestion list above the composer.

Type(direction: "up" | "down") => void | undefined
autocomplete?boolean | undefined

Wire the input up as a combobox for slash-command autocomplete.

Typeboolean | undefined
ariaControls?string | undefined

Optional ariaControls value.

Typestring | undefined
ariaExpanded?boolean | undefined

Optional ariaExpanded value.

Typeboolean | undefined
ariaActiveDescendant?string | undefined

Optional ariaActiveDescendant value.

Typestring | undefined
actions?React.ReactNode | undefined

Extra controls rendered between the field and the send button.

TypeReact.ReactNode | undefined
onAdd?() => void | undefined

Optional onAdd value.

Type() => void | undefined

The registry exports MessageComposer. 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 the textarea labelled with the channel context, let Enter and Shift+Enter retain their documented meanings, and give every icon-only action an accessible name.

Tokens

Message Composer 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.