Skip to content
ilinxa/pro-ui

Engagement Bar

alphav0.4.0

Social action row — like, comment, share, bookmark, custom actions, and a multi-reaction picker with realtime counts and burst animation.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/engagement-bar

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/engagement-bar-fixtures

Preview

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 from comment-thread (see post-card for the full inline pattern).
  • Share — opens the v0.2.0 <ShareMenu> sub-export (searchable user picker).
  • Bookmark — right-aligned via default align rule; uncontrolled toggle, fill flips on click.

Demo source

demo.tsxtsx

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

  • actions order is preserved within each align group. Default rule: bookmark + view-count right; everything else left. Per-action align overrides.
  • Stacked variant ignores align — actions render in a single vertical column.
  • Memoize subscribe via useCallback. New identity = re-subscription (clean teardown + re-call).
  • engagementReducer + useEngagementState are public exports — drive your own state machine if you need cross-component coordination.
  • Heart-burst CSS lives in a sibling .css file shipped via shadcn registry:file. Import is automatic; consumer's globals.css is 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

Tags

engagement-barsocialengagementlikecommentsharebookmarkrealtimeheart-burstlikersreactions

Dependencies

shadcn primitives: avatar, button, input, popover
npm peer deps: lucide-react@^1.11.0