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.
newt-reactionimport { 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 reactionnpx newtui@latest add reactionyarn dlx newtui@latest add reactionbunx newtui@latest add reactionInstall the dependencies
pnpm dlx --yes class-variance-authoritynpx --yes class-variance-authorityyarn dlx --yes class-variance-authoritybunx --yes class-variance-authorityCopy 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
emoji?React.ReactNode
Emoji or icon rendered on the left.
React.ReactNodecount?number
Number of users who reacted.
numberactive?boolean | undefined
Controlled active state.
boolean | undefineddefaultActive?boolean | undefined
Initial active state when uncontrolled.
boolean | undefinedonActiveChange?(active: boolean) => void | undefined
Called with the next active state when toggled.
(active: boolean) => void | undefinedThe 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.