Skip to content

Typing Indicator

Three bouncing dots with a label that signals someone is typing.

Three bouncing dots with a label that signals someone is typing. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-typing
import { TypingIndicator } from "@/registry/bases/newt/ui/typing-indicator"

export default function TypingIndicatorDemo() {
  return <TypingIndicator>someone is typing...</TypingIndicator>
}
<script setup lang="ts">
import { TypingIndicator } from "~~/registry/bases/newt/ui/typing-indicator"
</script>

<template>
  <TypingIndicator>someone is typing...</TypingIndicator>
</template>

Installation

pnpm dlx newtui@latest add typing-indicator
npx newtui@latest add typing-indicator
yarn dlx newtui@latest add typing-indicator
bunx newtui@latest add typing-indicator

Copy the source into your project

import * as React from "react"

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

const typingKeyframes = `
@keyframes newt-typing-bounce {
  0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
  30% { transform: translateY(-3px); opacity: 1; }
}`

export interface TypingIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Accessible label announced to screen readers. */
  label?: string
}

const TypingIndicator = React.forwardRef<HTMLDivElement, TypingIndicatorProps>(
  ({ className, label = "Someone is typing", children, ...props }, ref) => (
    <div
      ref={ref}
      role="status"
      aria-label={label}
      className={cn(
        "inline-flex items-center gap-2 text-[13px] text-newt-text-muted",
        className
      )}
      {...props}
    >
      <style>{typingKeyframes}</style>
      <TypingIndicatorDots />
      {children}
    </div>
  )
)
TypingIndicator.displayName = "TypingIndicator"

const TypingIndicatorDots = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("inline-flex gap-[3px]", className)}
    aria-hidden="true"
    {...props}
  >
    {[0, 0.15, 0.3].map((delay) => (
      <span
        key={delay}
        className="h-[6px] w-[6px] rounded-full bg-newt-text-muted animate-[newt-typing-bounce_1.2s_ease-in-out_infinite] motion-reduce:animate-none motion-reduce:opacity-80"
        style={{ animationDelay: `${delay}s` }}
      />
    ))}
  </div>
))
TypingIndicatorDots.displayName = "TypingIndicatorDots"

export { TypingIndicator, TypingIndicatorDots }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

import TypingIndicatorDots from "./TypingIndicatorDots.vue"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** Accessible label announced to screen readers. */
    label?: string
  }>(),
  { label: "Someone is typing" }
)
</script>

<template>
  <div
    role="status"
    :aria-label="label"
    :class="
      cn(
        'inline-flex items-center gap-2 text-[13px] text-newt-text-muted',
        props.class
      )
    "
  >
    <TypingIndicatorDots />
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const delays = [0, 0.15, 0.3]
</script>

<template>
  <div :class="cn('inline-flex gap-[3px]', props.class)" aria-hidden="true">
    <span
      v-for="delay in delays"
      :key="delay"
      class="h-[6px] w-[6px] rounded-full bg-newt-text-muted animate-[newt-typing-bounce_1.2s_ease-in-out_infinite] motion-reduce:animate-none motion-reduce:opacity-80"
      :style="{ animationDelay: `${delay}s` }"
    />
  </div>
</template>

<style>
@keyframes newt-typing-bounce {
  0%,
  60%,
  100% {
    transform: translateY(0);
    opacity: 0.5;
  }
  30% {
    transform: translateY(-3px);
    opacity: 1;
  }
}
</style>
export { default as TypingIndicator } from "./TypingIndicator.vue"
export { default as TypingIndicatorDots } from "./TypingIndicatorDots.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 { TypingIndicator } from "@/components/ui/typing-indicator"

export function Example() {
  return <TypingIndicator />
}
<script setup lang="ts">
import { TypingIndicator, TypingIndicatorDots } from "@/components/ui/typing-indicator"
</script>

<template>
  <TypingIndicator />
</template>

API reference

TypingIndicator

PropType
label?string | undefined

Accessible label announced to screen readers.

Typestring | undefined

The registry exports TypingIndicator, TypingIndicatorDots. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Typing Indicator 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. In the plain HTML and CSS distribution the same component is newt-typing.