blurrow/ui
editorialclient

reaction bar

emoji reactions that count up when readers tap them.

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
β€Ίrather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add reaction-bar

or by hand: drop this file into components/ui and import from there.

components/ui/ReactionBar.tsxtsx
"use client";
import React, { useState, useCallback, useRef } from "react";
export interface ReactionItem {
emoji: string;
label: string;
count: number;
active?: boolean;
}
interface ReactionBarProps {
reactions?: ReactionItem[];
className?: string;
}
const defaultReactions: ReactionItem[] = [
{ emoji: "πŸ‘", label: "clap", count: 0 },
{ emoji: "❀️", label: "love", count: 0 },
{ emoji: "πŸ”₯", label: "fire", count: 0 },
{ emoji: "πŸ’‘", label: "insightful", count: 0 },
{ emoji: "πŸŽ‰", label: "celebrate", count: 0 },
];
export function ReactionBar({
reactions: initialReactions,
className = "",
}: ReactionBarProps) {
const [reactions, setReactions] = useState<ReactionItem[]>(
initialReactions ?? defaultReactions
);
const [animatingKey, setAnimatingKey] = useState<string | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClick = useCallback(
(index: number) => {
setReactions((prev) =>
prev.map((r, i) => {
if (i !== index) return r;
return {
...r,
active: !r.active,
count: r.active ? r.count - 1 : r.count + 1,
};
})
);
const key = reactions[index].label;
setAnimatingKey(key);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setAnimatingKey(null), 300);
},
[reactions]
);
return (
<div
className={["flex flex-wrap gap-2", className].filter(Boolean).join(" ")}
>
{reactions.map((reaction, index) => {
const isAnimating = animatingKey === reaction.label;
return (
<button
key={reaction.label}
type="button"
onClick={() => handleClick(index)}
aria-pressed={!!reaction.active}
aria-label={`${reaction.label}, ${reaction.count}`}
className={[
"inline-flex items-center gap-1.5 rounded-[11px] pl-2.5 pr-3 py-[7px] text-[13px] font-medium cursor-pointer",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
reaction.active ? "text-white" : "text-neutral-600 hover:text-neutral-900",
].join(" ")}
style={{
/* Off is a raised face; on is the dark face β€” pressed in and lit */
background: reaction.active ? "var(--face-dark)" : "var(--face-light)",
boxShadow: reaction.active ? "var(--face-dark-shadow)" : "var(--face-light-shadow)",
textShadow: reaction.active ? "0 -1px 0 rgba(0,0,0,0.4)" : "0 1px 0 rgba(255,255,255,0.9)",
transform: isAnimating ? "scale(1.08)" : "scale(1)",
transition:
"transform 250ms var(--ease-spring), color 200ms ease, box-shadow 200ms ease",
}}
>
<span aria-hidden className="text-[15px] leading-none">{reaction.emoji}</span>
<span
className="tabular-nums leading-none"
style={{
transform: isAnimating ? "scale(1.15)" : "scale(1)",
display: "inline-block",
transition:
"transform 250ms cubic-bezier(0.34, 1.4, 0.64, 1)",
}}
>
{reaction.count}
</span>
</button>
);
})}
</div>
);
}

usage

tsx
import { ReactionBar } from "@blurrow/ui";
tsx
<ReactionBar />

props

straight from the source, so they're always up to date.

propsts
interface ReactionItem {
emoji: string;
label: string;
count: number;
active?: boolean;
}
interface ReactionBarProps {
reactions?: ReactionItem[];
className?: string;
}