Engagement Bar
alphav0.4.0Social action row — like, comment, share, bookmark, custom actions, and a multi-reaction picker with realtime counts and burst animation.
Context
Highest-leverage primitive in the social-posts-system arc. Three variants: default (post body), compact (news/event card retrofits), stacked (TikTok/Reels overlay). Per-action controlled vs uncontrolled mode (controlled props win per-render). subscribe contract is the same Subscribe<EngagementDelta> shape comment-thread will use. Heart-burst is a sibling RSC-compatible sub-export — retrofit consumers that don't import it pay zero animation cost. Migration origin: kasder kas-social-front-v0 PostEngagementPanel.tsx (468 LOC); we extract only the action-row concern, decomposing comments → comment-thread (next ship) and likers carousel → likersPreview slot. Fourth ship in the 8-component social-posts-system arc.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/engagement-barAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/engagement-bar-fixturesPreview
Fully-wired post-style engagement row. Every action is uncontrolled — internal mirror drives visuals; callbacks log to console.
- Heart icon — toggles like (mirror flips visibly).
- Like count — split-tap target via
like.onCountClick; opens the v0.2.0<LikersStrip>sub-export (swipable horizontal avatar row + "+N" pill). - Comment — opens a placeholder panel with input + Send. Atomic bar fires
onClick; real comment thread comes fromcomment-thread(seepost-cardfor the full inline pattern). - Share — opens the v0.2.0
<ShareMenu>sub-export (searchable user picker). - Bookmark — right-aligned via default
alignrule; uncontrolled toggle, fill flips on click.
Demo source
Usage
When to use
Anywhere a row of like / comment / share / bookmark / view-count actions is needed under content. Designed for social posts but retrofits cleanly into news cards, event cards, video overlays, and product cards via the actions slot pattern.
Minimal usage
import { EngagementBar } from "@/components/engagement-bar"
<EngagementBar
actions={[
{ kind: "like", count: 142, liked: false, onToggle: (next) => onLike(post.id, next) },
{ kind: "comment", count: 23, onClick: () => openComments(post.id) },
{ kind: "share", onClick: () => share(post.id) },
{ kind: "bookmark", bookmarked: false, onToggle: (next) => onBookmark(post.id, next) },
]}
/>Split heart vs count (kasder UX)
Pass onCountClick on a like action and the bar splits the heart icon and the count number into two separate click targets. Heart fires onToggle; count fires onCountClick. Typical use: heart toggles like, count opens a likers panel.
<EngagementBar
actions={[
{
kind: "like",
count: 142,
liked: false,
onToggle: (next) => onLike(post.id, next),
onCountClick: () => openLikersPanel(post.id),
},
{ kind: "comment", count: 23, onClick: () => openComments(post.id) },
]}
/>Backwards-compatible: omit onCountClick and the bar renders the heart + count as a single button (the original behavior).
Controlled vs uncontrolled (per action)
Per-action: pass liked / bookmarked → controlled (host owns state, must update on toggle). Omit them → uncontrolled (component flips state internally on click).
// Uncontrolled — component manages liked/bookmarked internally
<EngagementBar
actions={[
{ kind: "like", count: 0, onToggle: console.log },
{ kind: "bookmark", onToggle: console.log },
]}
/>
// Controlled — host owns state
const [liked, setLiked] = useState(false)
<EngagementBar
actions={[
{ kind: "like", count: 142, liked, onToggle: setLiked },
]}
/>Realtime via subscribe
Pass a memoized subscribe function. In uncontrolled mode, deltas patch internal state. In controlled mode, deltas only fire onSubscribeDelta — you translate them into prop updates.
const subscribe = useCallback(
(handler) => channel.on("post.delta", handler),
[channel],
)
<EngagementBar
actions={[...]}
subscribe={subscribe}
onSubscribeDelta={(delta) => analytics.track("post.delta", delta)}
/>Heart-burst (Instagram-style)
EngagementHeartBurst is a sibling sub-export — RSC compatible (no "use client"), CSS-keyframe driven. Host increments a counter to trigger; key={trigger} remounts the burst, restarting the animation.
import { EngagementBar, EngagementHeartBurst } from "@/components/engagement-bar"
const barRef = useRef<EngagementBarHandle>(null)
const [burstKey, setBurstKey] = useState(0)
<div className="relative">
<MediaCarousel
items={post.media}
onDoubleTap={() => {
barRef.current?.triggerLike()
setBurstKey((k) => k + 1)
}}
/>
<EngagementHeartBurst
trigger={burstKey}
className="absolute inset-0 flex items-center justify-center pointer-events-none"
/>
</div>
<EngagementBar ref={barRef} actions={[...]} />Custom action
import { Wand2 } from "lucide-react"
<EngagementBar
actions={[
{ kind: "like", count: 89, onToggle },
{
kind: "custom",
id: "remix",
label: "Remix",
icon: <Wand2 className="h-5 w-5" />,
onClick: openRemixSheet,
},
]}
/>News-card retrofit
Drop into news-card's actions slot. variant="compact" keeps the bar tight; no framer-motion cost (heart-burst not imported).
<NewsCard
title={article.title}
/* ... */
actions={
<EngagementBar
variant="compact"
actions={[
{ kind: "like", count: article.likes, liked: article.viewerLiked, onToggle },
{ kind: "share", onClick: () => share(article) },
{ kind: "bookmark", bookmarked: article.saved, onToggle },
]}
/>
}
/>Notes
actionsorder is preserved within each align group. Default rule:bookmark+view-countright; everything else left. Per-actionalignoverrides.- Stacked variant ignores
align— actions render in a single vertical column. - Memoize
subscribeviauseCallback. New identity = re-subscription (clean teardown + re-call). engagementReducer+useEngagementStateare public exports — drive your own state machine if you need cross-component coordination.- Heart-burst CSS lives in a sibling
.cssfile shipped via shadcnregistry:file. Import is automatic; consumer'sglobals.cssis untouched.
Features
- Discriminated actions[] — like / comment / share / bookmark / view-count / custom / reaction — order preserved
- Split heart-vs-count tap targets via `like.onCountClick` — heart fires onToggle, count fires onCountClick (kasder UX). Backwards-compatible: omit onCountClick for the bundled-button behavior.
- Three variants — default / compact / stacked (vertical for video overlays)
- Per-action controlled vs uncontrolled mode (hybrid; controlled props win per-render)
- Realtime via Subscribe<EngagementDelta> contract — host owns transport
- onSubscribeDelta callback — fires for every delta regardless of mode
- Built-in optimistic state via engagementReducer (public export)
- Heart-burst as sibling RSC sub-export — CSS-keyframe-driven, zero framer-motion
- Sibling .css file via shadcn registry:file (first-of-kind precedent in pro-ui)
- Likers preview as ReactNode slot — host wires avatar pile / popover / etc.
- Imperative ref handle — triggerLike / triggerBookmark / triggerReaction / getCurrentState / getCurrentReaction / reset
- formatEngagementCount helper — humanizes (1.2k / 12k / 1.2m / 1.2b)
- labels.formatCount escape hatch for locale-specific count formatting
- Default align rule — bookmark + view-count right; per-action align? override
- Stacked variant ignores align — single vertical column
- a11y — aria-pressed for like/bookmark; group role for view-count; aria-live counts
- i18n via 12-key labels object with English defaults (incl. `openLikersPanel` / `openReactionsPanel` for the split count buttons' aria-labels + `react` / `removeReaction` / `reactionPickerLabel` for the reaction picker)
- React.memo per action part + at root — cheap re-renders
- Subscription effect uses controlledRef pattern — re-runs only on subscribe identity change
- LikersStrip sub-export — horizontal swipable avatar strip + +N pill (touch swipe + desktop drag-to-scroll). v0.2.0
- ShareMenu sub-export — searchable user picker (sync filter + optional async onSearch). v0.2.0
- Reaction kind — FB/LinkedIn-style multi-kind reactions with single `kinds` catalog (key/icon/label/count/color), pop-out picker, 350ms long-press, configurable `clearOnTap`, hybrid-with-like coexistence. v0.3.0
- ReactionPicker sub-export — kinds-row content + Remove button + arrow-key nav; parent owns the popover. v0.3.0
- ReactionAction sub-export — full popover assembly with tap-vs-long-press matrix + Defense 1 microtask defer. v0.3.0
- reactionsPreview slot — parallel to likersPreview; renders below the action row in all 3 variants. v0.3.0
- Defense 2 (structural resync guard) — internal viewerReaction syncs to controlled prop changes, prevents stale state across mode transitions. v0.3.0