Skip to content

Status Indicator

A presence dot anchored to an avatar, with online, idle, do-not-disturb, offline and streaming states.

Presence, encoded in shape as well as colour: idle is a crescent, do-not-disturb is a bar, offline is a hollow ring. That is deliberate — the states stay distinguishable in greyscale, in a screenshot, and to a reader who cannot tell green from red.

Root class newt-status
import { Avatar } from "@/registry/bases/newt/ui/avatar"
import {
  StatusDot,
  StatusIndicator,
} from "@/registry/bases/newt/ui/status-indicator"

export default function StatusIndicatorDemo() {
  return (
    <div className="flex items-center gap-6">
      <StatusIndicator aria-label="Online">
        <Avatar>A</Avatar>
        <StatusDot status="online" />
      </StatusIndicator>
      <StatusIndicator aria-label="Idle">
        <Avatar>B</Avatar>
        <StatusDot status="idle" />
      </StatusIndicator>
      <StatusIndicator aria-label="Do not disturb">
        <Avatar>C</Avatar>
        <StatusDot status="dnd" />
      </StatusIndicator>
      <StatusIndicator aria-label="Offline">
        <Avatar>D</Avatar>
        <StatusDot status="offline" />
      </StatusIndicator>
    </div>
  )
}
<script setup lang="ts">
import { Avatar } from "~~/registry/bases/newt/ui/avatar"
import {
  StatusDot,
  StatusIndicator,
} from "~~/registry/bases/newt/ui/status-indicator"
</script>

<template>
  <div class="flex items-center gap-6">
    <StatusIndicator aria-label="Online">
      <Avatar>A</Avatar>
      <StatusDot status="online" />
    </StatusIndicator>
    <StatusIndicator aria-label="Idle">
      <Avatar>B</Avatar>
      <StatusDot status="idle" />
    </StatusIndicator>
    <StatusIndicator aria-label="Do not disturb">
      <Avatar>C</Avatar>
      <StatusDot status="dnd" />
    </StatusIndicator>
    <StatusIndicator aria-label="Offline">
      <Avatar>D</Avatar>
      <StatusDot status="offline" />
    </StatusIndicator>
  </div>
</template>

Installation

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

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"

const PULSE_KEYFRAMES = `@keyframes newt-presence-pulse{0%{transform:scale(.7);opacity:.6}70%{transform:scale(1.6);opacity:0}100%{transform:scale(1.6);opacity:0}}`

const StatusIndicator = React.forwardRef<
  HTMLDivElement,
  React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    role="status"
    className={cn("relative inline-block", className)}
    {...props}
  />
))
StatusIndicator.displayName = "StatusIndicator"

const statusDotVariants = cva(
  "absolute -bottom-0.5 -end-0.5 h-3.5 w-3.5 rounded-full border-[3px] border-newt-bg-base",
  {
    variants: {
      status: {
        online:
          "bg-newt-online after:pointer-events-none after:absolute after:-inset-1 after:rounded-full after:border-[1.5px] after:border-newt-online after:opacity-0 after:content-[''] after:[animation:newt-presence-pulse_2.4s_ease-out_infinite] motion-reduce:after:[animation:none]",
        idle: "bg-newt-bg-base after:absolute after:inset-0 after:rounded-full after:bg-newt-idle after:content-[''] after:[clip-path:circle(60%_at_35%_35%)]",
        dnd: "bg-newt-bg-base after:absolute after:start-1/2 after:top-1/2 after:h-[22%] after:w-[65%] after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-[1px] after:bg-newt-dnd after:content-['']",
        offline: "bg-newt-offline",
        streaming: "bg-[#593695]",
      },
    },
    defaultVariants: {
      status: "offline",
    },
  }
)

export interface StatusDotProps
  extends React.ComponentProps<"div">, VariantProps<typeof statusDotVariants> {}

const StatusDot = React.forwardRef<HTMLDivElement, StatusDotProps>(
  ({ className, status, ...props }, ref) => (
    <>
      {status === "online" ? (
        <style dangerouslySetInnerHTML={{ __html: PULSE_KEYFRAMES }} />
      ) : null}
      <div
        ref={ref}
        data-status={status ?? "offline"}
        className={cn(statusDotVariants({ status }), className)}
        {...props}
      />
    </>
  )
)
StatusDot.displayName = "StatusDot"

export { StatusIndicator, StatusDot, statusDotVariants }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<template>
  <div role="status" :class="cn('relative inline-block', props.class)">
    <slot />
  </div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

import { statusDotVariants, type StatusDotVariants } from "."

const props = withDefaults(
  defineProps<{
    status?: StatusDotVariants["status"]
    class?: HTMLAttributes["class"]
  }>(),
  { status: "offline" }
)
</script>

<template>
  <div
    :data-status="props.status"
    :class="cn(statusDotVariants({ status: props.status }), props.class)"
  />
</template>

<style>
@keyframes newt-presence-pulse {
  0% {
    transform: scale(0.7);
    opacity: 0.6;
  }
  70% {
    transform: scale(1.6);
    opacity: 0;
  }
  100% {
    transform: scale(1.6);
    opacity: 0;
  }
}
</style>
import { cva, type VariantProps } from "class-variance-authority"

export { default as StatusIndicator } from "./StatusIndicator.vue"
export { default as StatusDot } from "./StatusDot.vue"

export const statusDotVariants = cva(
  "absolute -bottom-0.5 -end-0.5 h-3.5 w-3.5 rounded-full border-[3px] border-newt-bg-base",
  {
    variants: {
      status: {
        online:
          "bg-newt-online after:pointer-events-none after:absolute after:-inset-1 after:rounded-full after:border-[1.5px] after:border-newt-online after:opacity-0 after:content-[''] after:[animation:newt-presence-pulse_2.4s_ease-out_infinite] motion-reduce:after:[animation:none]",
        idle: "bg-newt-bg-base after:absolute after:inset-0 after:rounded-full after:bg-newt-idle after:content-[''] after:[clip-path:circle(60%_at_35%_35%)]",
        dnd: "bg-newt-bg-base after:absolute after:start-1/2 after:top-1/2 after:h-[22%] after:w-[65%] after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-[1px] after:bg-newt-dnd after:content-['']",
        offline: "bg-newt-offline",
        streaming: "bg-[#593695]",
      },
    },
    defaultVariants: {
      status: "offline",
    },
  }
)

export type StatusDotVariants = VariantProps<typeof statusDotVariants>

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 { StatusIndicator } from "@/components/ui/status-indicator"

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

<template>
  <StatusIndicator />
</template>

API reference

StatusDot

PropType
status?"online" | "idle" | "dnd" | "offline" | "streaming"

The presence state.

Type"online" | "idle" | "dnd" | "offline" | "streaming"
Default"offline"

StatusIndicator

PropType
status?"online" | "idle" | "dnd" | "offline" | "streaming"

Passed through to the dot.

Type"online" | "idle" | "dnd" | "offline" | "streaming"
Default"offline"
label?string

Text beside the dot. Also the accessible name when present.

Typestring

Every part also accepts the native attributes of the element it renders, and a className that is merged rather than replaced.

Accessibility

The dot carries role="status" and an aria-label naming the state, so it is never colour alone. The online state has a soft pulse, which is the one animation in the library; it is behind prefers-reduced-motion and the state is legible without it.

Tokens

Status 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-status.