Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Mention Input

A text field that suggests people, channels or tags after a trigger character.

Rendered by React 19.3.1

Loading React…
import { MentionInput } from '@hamur/react/mention-input'

interface Person {
  label: string
  handle: string
}

const people: Person[] = [
  { label: 'Alice Johnson', handle: 'alice' },
  { label: 'Ben Okafor', handle: 'ben' },
  { label: 'Chloé Martin', handle: 'chloe' },
  { label: 'Dmitri Ivanov', handle: 'dmitri' },
  { label: 'Emma Larsen', handle: 'emma' },
  { label: 'Farah Haddad', handle: 'farah' },
]

const insertText = (person: Person, trigger: string) => `${trigger}${person.handle}`

export default function MentionInputExample() {
  return (
    <MentionInput.Root items={people} itemToInsertText={insertText}>
      <label className="field">
        <span className="select-label">Comment</span>
        <MentionInput.Input
          className="textarea mention-input"
          placeholder="Type @ to mention a teammate"
        />
      </label>
      <MentionInput.Portal>
        <MentionInput.Positioner
          className="select-positioner"
          sideOffset={6}
        >
          <MentionInput.Popup className="select-popup mention-input-popup">
            <MentionInput.Empty className="combobox-empty">
              No teammates found.
            </MentionInput.Empty>
            <MentionInput.List aria-label="Teammates" className="select-list">
              <MentionInput.Collection>
                {(person: Person) => (
                  <MentionInput.Item
                    key={person.handle}
                    value={person}
                    className="select-item mention-input-item"
                  >
                    {person.label}
                    <span className="mention-input-handle">@{person.handle}</span>
                  </MentionInput.Item>
                )}
              </MentionInput.Collection>
            </MentionInput.List>
          </MentionInput.Popup>
        </MentionInput.Positioner>
      </MentionInput.Portal>
    </MentionInput.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<MentionInput.Root>
  <MentionInput.Input />
  <MentionInput.Portal>
    <MentionInput.Positioner>
      <MentionInput.Popup>
        <MentionInput.Empty />
        <MentionInput.List>
          <MentionInput.Collection>
            <MentionInput.Item />
          </MentionInput.Collection>
        </MentionInput.List>
      </MentionInput.Popup>
    </MentionInput.Positioner>
  </MentionInput.Portal>
</MentionInput.Root>

// Not in this example: MentionInput.Arrow, MentionInput.Group, MentionInput.GroupLabel, MentionInput.Row, MentionInput.Separator

Examples

Triggers

A message box with trigger @ and #: onQueryChange swaps the items between people and channels, and names the List for each.

MentionInputTriggers.tsx
import * as React from 'react'
import { MentionInput, type MentionInputQuery } from '@hamur/react/mention-input'

const people = ['Alice', 'Ben', 'Chloé', 'Dmitri', 'Emma', 'Farah']
const channels = ['general', 'design', 'engineering', 'releases', 'random']
const triggers = ['@', '#']

export default function MentionInputTriggersExample() {
  const [trigger, setTrigger] = React.useState('@')
  const onQueryChange = (query: MentionInputQuery | null) => {
    if (query) setTrigger(query.trigger)
  }
  return (
    <MentionInput.Root
      trigger={triggers}
      items={trigger === '#' ? channels : people}
      onQueryChang={onQueryChange}
    >
      <label className="field">
        <span className="select-label">Message</span>
        <MentionInput.Input
          className="textarea mention-input"
          placeholder="Type @ for people, # for channels"
        />
      </label>
      <MentionInput.Portal>
        <MentionInput.Positioner
          className="select-positioner"
          sideOffset={6}
        >
          <MentionInput.Popup className="select-popup mention-input-popup">
            <MentionInput.Empty className="combobox-empty">
              No matches.
            </MentionInput.Empty>
            <MentionInput.List
              aria-label={trigger === '#' ? 'Channels' : 'People'}
              className="select-list"
            >
              <MentionInput.Collection>
                {(item: string) => (
                  <MentionInput.Item
                    key={item}
                    value={item}
                    className="select-item plain"
                  >
                    {trigger === '#' ? `#${item}` : item}
                  </MentionInput.Item>
                )}
              </MentionInput.Collection>
            </MentionInput.List>
          </MentionInput.Popup>
        </MentionInput.Positioner>
      </MentionInput.Portal>
    </MentionInput.Root>
  )
}

API reference

60 props across 14 parts, in React syntax. className, style, render are listed once, under Shared props.

ArrowMentionInput.Arrow

Arrow data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-uncenteredPresent when the arrow is uncentered.

CollectionMentionInput.Collection

Renders filtered list items. If rendering a flat list, pass a function child to the List component instead, which implicitly wraps it. Doesn’t render its own element.

Collection props
PropTypeDefault
childrenrequired(item: any, index: number) => React.ReactNode—

EmptyMentionInput.Empty

GroupMentionInput.Group

Group props
PropTypeDefault
itemsItems to be rendered within this group.readonly any[]—

GroupLabelMentionInput.GroupLabel

InputMentionInput.Input

The text field the mentions are typed into, on the Combobox store's reference props., or a single-line <input> with render={<input />}. Renders a <textarea>.

Input props
PropTypeDefault
disabledWhether the component should ignore user interaction.booleanfalse

ItemMentionInput.Item

Item props
PropTypeDefault
childrenReact.ReactNode—
onClickAn optional click handler for the item when selected.((event: BaseUIEvent<React.MouseEvent<HTMLDivElement, MouseEvent>>) => void)—
indexThe index of the item in the list.number—
valueA unique value that identifies this item.anynull
disabledWhether the component should ignore user interaction.booleanfalse
nativeButtonWhether the component renders a native <button> element when replacing itbooleanfalse

ListMentionInput.List

List props
PropTypeDefault
childrenReact.ReactNode | ((item: any, index: number) => React.ReactNode)—

PopupMentionInput.Popup

Popup props
PropTypeDefault
initialFocusDetermines the element to focus when the popup is opened.boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)—
finalFocusDetermines the element to focus when the popup is closed.boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)—
Popup data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-starting-stylePresent when the popup begins animating in.
data-ending-stylePresent when the popup is animating out.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-anchor-hiddenPresent when the anchor is hidden.
data-emptyPresent when the items list is empty.

PortalMentionInput.Portal

Portal props
PropTypeDefault
keepMountedWhether to keep the portal mounted in the DOM while the popup is hidden.booleanfalse
containerA parent element to render the portal element into.HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null—

PositionerMentionInput.Positioner

Positions the popup against the trigger character in the field. Renders a <div>.

Positioner props
PropTypeDefault
anchorAn element to position the popup against.Element | VirtualElement | React.RefObject<Element | null> | (() => Element | VirtualElement | null) | null—
positionMethodDetermines which CSS position property to use."absolute" | "fixed"'absolute'
sideWhich side of the anchor element to align the popup against.Side'bottom'
sideOffsetDistance between the anchor and the popup in pixels.number | OffsetFunction0
alignOffsetAdditional offset along the alignment axis in pixels.number | OffsetFunction0
collisionBoundaryAn element or a rectangle that delimits the area that the popup is confined to.Boundary'clipping-ancestors'
collisionPaddingAdditional space to maintain from the edge of the collision boundary.Padding5
stickyWhether to maintain the popup in the viewport afterbooleanfalse
arrowPaddingMinimum distance to maintain between the arrow and the edges of the popup.number5
disableAnchorTrackingWhether to disable the popup from tracking any layout shift of its positioning anchor.booleanfalse
collisionAvoidanceDetermines how to handle collisions when positioning the popup.CollisionAvoidance—
alignHow to align the popup relative to the specified side.Align'start'
Positioner data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-anchor-hiddenPresent when the anchor is hidden.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-emptyPresent when the items list is empty.
Positioner CSS variables
CSS variableDescription
--available-widthThe available width between the trigger and the edge of the viewport.
--available-heightThe available height between the trigger and the edge of the viewport.
--anchor-widthThe anchor's width.
--anchor-heightThe anchor's height.
--transform-originThe coordinates that this element is anchored to. Used for animations and transitions.

RootMentionInput.Root

Groups all parts of the mention input: a text field that suggests items after a trigger character.; the status region is rendered after its children. Doesn’t render its own element.

Root props
PropTypeDefault
childrenReact.ReactNode—
valueThe text of the field.string—
defaultValueThe text of the field when it's initially rendered.string—
onValueChangeEvent handler called when the text of the field changes.((value: string, eventDetails: MentionInputRootChangeEventDetails) => void)—
triggerThe characters that start a query: one character, or a list of characters or ofstring | readonly (string | MentionInputTrigger)[]'@'
insertSuffixThe text inserted after an item's text, left out when whitespace already follows the caret.string' '
itemToInsertTextConverts an item to the text that replaces the trigger and the query.((itemValue: ItemValue, trigger: string) => string)—
itemToStringValueConverts an item to the string it is filtered and inserted by.((itemValue: ItemValue) => string)—
itemsThe items to be displayed in the list.readonly ItemValue[] | readonly Group<ItemValue>[]—
filteredItemsFiltered items to display in the list, used instead of filtering items by the query.readonly ItemValue[] | readonly Group<ItemValue>[]—
filterFilter function used to match items against the query.((item: ItemValue, query: string, itemToString?: ((item: ItemValue) => string) | undefined) => boolean) | null—
limitThe maximum number of items to display in the list.number-1
localeThe locale used by the default filter.Intl.LocalesArgument—
onQueryChangeEvent handler called when the query the caret is in changes, or ends (null).((query: MentionInputQuery | null, eventDetails: MentionInputRootChangeEventDetails) => void)—
onItemInsertEvent handler called before an item is inserted.((itemValue: ItemValue, eventDetails: MentionInputInsertEventDetails) => void)—
onOpenChangeEvent handler called when the popup is opened or closed.((open: boolean, eventDetails: MentionInputRootOpenChangeEventDetails) => void)—
onOpenChangeCompleteEvent handler called after any animations complete when the popup is opened or closed.((open: boolean) => void)—
onItemHighlightedCallback fired when an item is highlighted or unhighlighted.((highlightedValue: ItemValue | undefined, eventDetails: AriaCombobox.HighlightEventDetails) => void)—
autoHighlightWhether the first enabled item is highlighted when the popup opens and when the listbooleantrue
keepHighlightWhether the highlighted item should be preserved when the pointer leaves the list.booleanfalse
highlightItemOnHoverWhether moving the pointer over items should highlight them.booleantrue
loopFocusWhether to loop keyboard focus back to the first item when the end of the list is reached.booleantrue
gridWhether the items are arranged in a grid.booleanfalse
virtualizedWhether the items are being externally virtualized.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the text.booleanfalse
requiredWhether the field must be filled before the owning form is submitted.booleanfalse
nameIdentifies the field when a form is submitted.string—
formIdentifies the form that owns the field.string—
idThe id of the field.string—
inputRefA ref to the field element.React.Ref<HTMLInputElement | HTMLTextAreaElement>—
labelsThe texts of the status region, merged over the English defaults.Partial<MentionInputLabels>—
actionsRefA ref to imperative actions.React.RefObject<MentionInputRootActions | null>—

RowMentionInput.Row

SeparatorMentionInput.Separator

Separator props
PropTypeDefault
orientationThe orientation of the separator.Orientation'horizontal'

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State & State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State & State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State & State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
ArrowDownArrowUpWhile open, moves the highlight, skipping disabled items and loopingInput
EnterPuts the highlighted item in place of the queryInput
EnterWith nothing highlighted, is the field's: a line break, and the popup closesInput
EscapeCloses the popup; typing on in the same query does not open it againInput
TabNever puts an item in; focus moves on and the popup closesInput
Is the field's: the arrows move the caret, Enter adds a lineInput

Accessibility

What the parts set
PartSetsSo that
Inputon a <textarea>, no role (it stays a textbox), aria-autocomplete="list" and aria-haspopup="listbox"a multi-line field is still read and edited as one, and says it has suggestions
Inputon an <input>, role="combobox" with aria-expandeda single-line field is read as an editable combobox that says whether the popup is shown
Inputaria-controls to the List while the popup is open, aria-activedescendant to the highlighted itemthe highlight is read while focus stays in the field
Listrole="listbox", named by the app (aria-label)the suggestions are announced as a list with a name, such as "Teammates"
Itemrole="option", no aria-selectedeach suggestion is read as an option; nothing is remembered as a selection
Roota polite status region after its children: "5 suggestions", "No suggestions", then "@alice inserted"the count and the insertion are announced without moving focus; labels translates them

Focus order

  1. Focus stays in the Input the whole time; the arrows move a highlight in the list, not focus.
  2. Nothing in the popup is a Tab stop, and a press on an item keeps focus and the caret in the field.
  3. Tab moves on to the next element; the blur closes the popup.