emoji under a post with how many gave each, yours pressed, more offered
npx shadcn add @ag/reactionsAlready installed? Preview an update
npx shadcn add @ag/reactions --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Fixed: taking back a reaction nobody else gave keeps the focus in the row (on the pill now in its place, else the last, else the add button); it fell to the page with the pill.
also called emoji reactions, likes
shelf ratings & feedback
uses button, material, popover
used by github
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { type Reaction, Reactions } from "@/registry/base-nova/ui/reactions"
// #region post
const OFFER = ["👍", "❤️", "🎉", "😄", "🚀", "👀"]
const START: Reaction[] = [
{ emoji: "👍", count: 12, mine: false },
{ emoji: "🎉", count: 3, mine: true },
{ emoji: "🚀", count: 1, mine: false, label: "ship it" },
]
/** Yours flipped in the handler, the count with it: what is given shows at once. */
function useReactions(start: readonly Reaction[]) {
const [all, setAll] = React.useState(start)
const toggle = (emoji: string) =>
setAll((list) => {
const had = list.find((r) => r.emoji === emoji)
if (!had) return [...list, { emoji, count: 1, mine: true }]
return list.map((r) =>
r.emoji === emoji
? { ...r, mine: !r.mine, count: r.count + (r.mine ? -1 : 1) }
: r,
)
})
return [all, toggle] as const
}
function Post() {
const [all, toggle] = useReactions(START)
return <Reactions reactions={all} onToggle={toggle} offer={OFFER} />
}
// #endregion
export default function ReactionsDemo() {
return (
<div className="space-y-8">
<Sample
name="post"
glance
title="give one, take it back"
description="a pill is a toggle: press it and you count, again and you leave. the smile offers the rest; one you take back to nobody leaves the row."
with="post"
>
<Post />
</Sample>
<Sample name="materials" title="plain and glass" with="post">
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Post />
</div>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-6 text-white">
<Post />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="readout"
title="someone else's reactions"
description="no onToggle: words, not buttons, since this page cannot send one."
>
<Reactions
size="md"
reactions={[
{ emoji: "👍", count: 48, mine: false },
{ emoji: "😕", count: 2, mine: false, label: "confused" },
{ emoji: "❤️", count: 9, mine: false },
]}
/>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { SmilePlus } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/base-nova/ui/popover"
// REACTIONS UNDER A POST (GitHub, Slack, Telegram): each emoji given, how many gave
// it, and whether you did. A pill is a toggle (`Button` with `aria-pressed`): press
// it and you count, press again and you leave. `offer` adds a button that opens the
// emoji you may give (a `Popover`), each a toggle too, the popover closing on a
// choice. Controlled: the consumer holds the list and flips `mine` and the count in
// `onToggle`, before any answer, as a vote's score moves.
//
// With no `onToggle` it is a readout: the same pills as words, not buttons (a page
// that shows another site's reactions cannot send one, and a button would promise
// it could). A reaction nobody gave and you did not either is not shown.
export interface Reaction {
emoji: string
count: number
/** You gave it. */
mine: boolean
/** Its name for a reader where the emoji's own is not what is meant ("ship it"). */
label?: string
}
export interface ReactionsProps {
reactions: readonly Reaction[]
/** A press on a pill or on an offered emoji: flip yours. Absent, a readout. */
onToggle?: (emoji: string) => void
/** The emoji the add button offers; absent, no add button. */
offer?: readonly string[]
size?: "sm" | "md"
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
/** What they react to, for a reader: "reactions" unless given. */
"aria-label"?: string
className?: string
}
const said = (r: Reaction) =>
`${r.label ?? r.emoji}, ${r.count} ${r.count === 1 ? "reaction" : "reactions"}`
export function Reactions({
reactions,
onToggle,
offer,
size = "sm",
material,
scheme,
"aria-label": label = "reactions",
className,
}: ReactionsProps) {
for (const r of reactions)
if (r.count < 0 || (r.mine && r.count < 1))
throw new Error(
`Reactions: ${r.emoji} counts ${r.count}${r.mine ? ", yours among them" : ""}`,
)
if (offer && !onToggle)
throw new Error("Reactions: an offer with no onToggle cannot be given")
const m = useMaterial(material)
const [s, here] = useSurfaceScheme<HTMLFieldSetElement>(scheme)
const [open, setOpen] = React.useState(false)
const shown = reactions.filter((r) => r.count > 0)
// Taking back a reaction nobody else gave takes its pill away with the focus it
// holds: the focus goes on to the pill now in its place, else the last, else the
// add button, once the list without it is drawn.
const refocus = React.useRef<{ group: HTMLElement; at: number } | null>(null)
React.useLayoutEffect(() => {
const r = refocus.current
if (!r) return
refocus.current = null
if (r.group.contains(document.activeElement)) return
const stops = r.group.querySelectorAll<HTMLElement>(
"[data-slot=reactions-pill], [data-slot=reactions-add]",
)
stops[Math.min(r.at, stops.length - 1)]?.focus()
})
const mine = new Set(reactions.filter((r) => r.mine).map((r) => r.emoji))
const pill = size === "sm" ? "h-7 gap-1 px-2" : "h-8 gap-1.5 px-2.5"
const glyph = size === "sm" ? "text-[14px]" : "text-[16px]"
// Plain, a pill is GitHub's outline; glass stands on a picture, where an outline
// (the rim alone, no body) leaves the count unread over a bright one: a quiet
// glass with a body, and the add button the same.
const glass = m === "glass"
const pillVariant = glass ? "secondary" : "outline"
const readout = glass
? cn(
glassVariants({ shape: "surface" }),
size === "sm" ? "[--glass-radius:14px]" : "[--glass-radius:16px]",
)
: "rounded-full bg-current/6"
return (
<MaterialProvider material={m} scheme={s}>
<fieldset
ref={here}
aria-label={label}
data-slot="reactions"
data-material={m}
className={cn(
"flex min-w-0 flex-wrap items-center gap-1.5",
schemeOf(s),
className,
)}
>
{shown.map((r) =>
onToggle ? (
<Button
key={r.emoji}
variant={pillVariant}
size={size}
corners="pill"
pressed={r.mine}
aria-label={said(r)}
data-slot="reactions-pill"
onClick={(e) => {
const group = e.currentTarget.closest("fieldset")
if (r.mine && r.count === 1 && group)
refocus.current = { group, at: shown.indexOf(r) }
onToggle(r.emoji)
}}
className={cn(pill, r.mine ? "text-current" : "text-current/75")}
>
<span aria-hidden className={glyph}>
{r.emoji}
</span>
<span aria-hidden className="tabular-nums">
{r.count}
</span>
</Button>
) : (
<span
key={r.emoji}
data-slot="reactions-pill"
className={cn(
"inline-flex items-center font-medium text-current/75",
readout,
size === "sm" ? "text-xs" : "text-[13px]",
pill,
)}
>
<span aria-hidden className={glyph}>
{r.emoji}
</span>
<span aria-hidden className="tabular-nums">
{r.count}
</span>
<span className="sr-only">{said(r)}</span>
</span>
),
)}
{offer && onToggle && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant={glass ? "secondary" : "ghost"}
size={size === "sm" ? "icon-sm" : "icon"}
corners="pill"
aria-label="add a reaction"
data-slot="reactions-add"
className="text-current/60 hover:text-current"
/>
}
>
<SmilePlus aria-hidden />
</PopoverTrigger>
<PopoverContent
side="top"
aria-label="add a reaction"
className="w-auto flex-row flex-wrap gap-0.5 p-1.5"
>
{offer.map((emoji) => (
<Button
key={emoji}
variant="ghost"
size="icon"
corners="pill"
pressed={mine.has(emoji)}
aria-label={emoji}
onClick={() => {
onToggle(emoji)
setOpen(false)
}}
className="text-[18px]"
>
{emoji}
</Button>
))}
</PopoverContent>
</Popover>
)}
</fieldset>
</MaterialProvider>
)
}