Skip to content

Reaction Pill

A toggleable emoji reaction pill, plus the group that rows them under a message.

A toggleable emoji reaction pill, plus the group that rows them under a message. 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-reaction
import { Reaction, ReactionGroup } from "@/registry/bases/newt/ui/reaction"

export default function ReactionDemo() {
  return (
    <ReactionGroup>
      <Reaction emoji="👍" count={12} defaultActive />
      <Reaction emoji="🚀" count={4} />
    </ReactionGroup>
  )
}
<script setup lang="ts">
import { Reaction, ReactionGroup } from "~~/registry/bases/newt/ui/reaction"
</script>

<template>
  <ReactionGroup>
    <Reaction emoji="👍" :count="12" default-active />
    <Reaction emoji="🚀" :count="4" />
  </ReactionGroup>
</template>

Installation

pnpm dlx newtui@latest add reaction
npx newtui@latest add reaction
yarn dlx newtui@latest add reaction
bunx newtui@latest add reaction

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

Copy the source into your project

"use client"

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

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

const reactionVariants = cva(
  "inline-flex cursor-pointer items-center gap-1 rounded-md border px-2 py-0.5 text-[13px] transition-[background-color,border-color] duration-fast ease-newt focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
  {
    variants: {
      active: {
        false:
          "border-newt-border bg-newt-bg-surface text-newt-text-secondary hover:bg-newt-bg-hover",
        true: "border-newt-brand bg-[color-mix(in_srgb,var(--newt-brand)_15%,transparent)] text-newt-mention-text",
      },
    },
    defaultVariants: {
      active: false,
    },
  }
)

function isStringNode(value: React.ReactNode): value is string {
  return Object.prototype.toString.call(value) === "[object String]"
}

export interface ReactionProps
  extends
    Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children">,
    VariantProps<typeof reactionVariants> {
  /** Emoji or icon rendered on the left. */
  emoji: React.ReactNode
  /** Number of users who reacted. */
  count: number
  /** Controlled active state. */
  active?: boolean
  /** Initial active state when uncontrolled. */
  defaultActive?: boolean
  /** Called with the next active state when toggled. */
  onActiveChange?: (active: boolean) => void
}

const Reaction = React.forwardRef<HTMLButtonElement, ReactionProps>(
  (
    {
      className,
      emoji,
      count,
      active: activeProp,
      defaultActive = false,
      onActiveChange,
      onClick,
      "aria-label": ariaLabel,
      ...props
    },
    ref
  ) => {
    const [uncontrolled, setUncontrolled] = React.useState(defaultActive)
    const isControlled = activeProp !== undefined
    const active = isControlled ? activeProp : uncontrolled

    const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(event)
      if (event.defaultPrevented) return
      const next = !active
      if (!isControlled) setUncontrolled(next)
      onActiveChange?.(next)
    }

    const label =
      ariaLabel ??
      `${isStringNode(emoji) ? emoji : "Emoji"} reaction, ${count} ${
        count === 1 ? "person" : "people"
      }${active ? ", you reacted" : ""}`

    return (
      <button
        ref={ref}
        type="button"
        aria-pressed={active}
        aria-label={label}
        data-state={active ? "active" : "inactive"}
        className={cn(reactionVariants({ active }), className)}
        onClick={handleClick}
        {...props}
      >
        <span aria-hidden="true">{emoji}</span>
        <span className="text-xs font-semibold">{count}</span>
      </button>
    )
  }
)
Reaction.displayName = "Reaction"

/* The row of reactions under a message. */
const ReactionGroup = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("mt-1 flex flex-wrap items-center gap-1", className)}
    {...props}
  />
))
ReactionGroup.displayName = "ReactionGroup"

export { Reaction, ReactionGroup, reactionVariants }
<script setup lang="ts">
import type { ButtonHTMLAttributes } from "vue"
import { computed, ref, watch } from "vue"

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

import { reactionVariants } from "."

const props = withDefaults(
  defineProps<{
    class?: ButtonHTMLAttributes["class"]
    /** Emoji or short text rendered on the left. */
    emoji: string
    /** Number of users who reacted. */
    count: number
    /** Controlled active state (use with v-model:active). */
    active?: boolean
    /** Initial active state when uncontrolled. */
    defaultActive?: boolean
    ariaLabel?: string
  }>(),
  { active: undefined, defaultActive: false }
)

const emit = defineEmits<{ (e: "update:active", value: boolean): void }>()

const internal = ref(props.active ?? props.defaultActive)
watch(
  () => props.active,
  (v) => {
    if (v !== undefined) internal.value = v
  }
)
const isActive = computed(() =>
  props.active !== undefined ? props.active : internal.value
)

function toggle() {
  const next = !isActive.value
  if (props.active === undefined) internal.value = next
  emit("update:active", next)
}

const label = computed(
  () =>
    props.ariaLabel ??
    `${props.emoji} reaction, ${props.count} ${props.count === 1 ? "person" : "people"}${
      isActive.value ? ", you reacted" : ""
    }`
)
</script>

<template>
  <button
    type="button"
    :aria-pressed="isActive"
    :aria-label="label"
    :data-state="isActive ? 'active' : 'inactive'"
    :class="cn(reactionVariants({ active: isActive }), props.class)"
    @click="toggle"
  >
    <span aria-hidden="true">{{ emoji }}</span>
    <span class="text-xs font-semibold">{{ count }}</span>
  </button>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

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

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

<!-- The row of reactions under a message. -->
<template>
  <div :class="cn('mt-1 flex flex-wrap items-center gap-1', props.class)">
    <slot />
  </div>
</template>
import { cva, type VariantProps } from "class-variance-authority"

export { default as Reaction } from "./Reaction.vue"
export { default as ReactionGroup } from "./ReactionGroup.vue"

export const reactionVariants = cva(
  "inline-flex cursor-pointer items-center gap-1 rounded-md border px-2 py-0.5 text-[13px] transition-[background-color,border-color] duration-fast ease-newt focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link",
  {
    variants: {
      active: {
        false:
          "border-newt-border bg-newt-bg-surface text-newt-text-secondary hover:bg-newt-bg-hover",
        true: "border-newt-brand bg-[color-mix(in_srgb,var(--newt-brand)_15%,transparent)] text-newt-mention-text",
      },
    },
    defaultVariants: {
      active: false,
    },
  }
)

export type ReactionVariants = VariantProps<typeof reactionVariants>

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 { Reaction } from "@/components/ui/reaction"

export function Example() {
  return <Reaction />
}
<script setup lang="ts">
import { Reaction, ReactionGroup } from "@/components/ui/reaction"
</script>

<template>
  <Reaction />
</template>

API reference

Reaction

PropType
emoji?React.ReactNode

Emoji or icon rendered on the left.

TypeReact.ReactNode
count?number

Number of users who reacted.

Typenumber
active?boolean | undefined

Controlled active state.

Typeboolean | undefined
defaultActive?boolean | undefined

Initial active state when uncontrolled.

Typeboolean | undefined
onActiveChange?(active: boolean) => void | undefined

Called with the next active state when toggled.

Type(active: boolean) => void | undefined

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

Accessibility

The reaction is a real button and publishes its toggled state through aria-pressed. Keep the emoji name and count understandable to screen readers.

Tokens

Reaction Pill 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-reaction.