Team Feedback Loop
alphav0.2.1Non-blocking celebration layer — a brief skippable overlay when team progress advances, plus a gentle dismissible next-task nudge.
Context
The gamification-system's feedback layer (E6, Competence) — closes the engagement + progression loops. The host pushes a FeedbackEvent (controlled event prop OR imperative celebrate() — both funnel into one reducer, newest wins, never stack) and the component renders a brief celebration band and a standing next-task nudge. The cardinal constraint is D-10 NON-BLOCKING: the board stays fully interactive during a celebration (pointer-events:none except the skip button), focus is never moved or trapped, and celebrationDurationMs is clamped to <1000ms so a lingering modal is impossible; skip via ✕ or Esc. Reduced-motion is a real static branch (no movement, no confetti, still time-boxed + skippable). The default flourish is token CSS reveal-up (zero library); an opt-in canvas-confetti burst is React.lazy for milestone/badge only, so the default consumer never loads it. Ships as a shadcn-style compound — headless TeamFeedbackLoopRoot (the reducer + timer + reduced-motion + imperative handle) + flat parts (TeamFeedbackCelebration, TeamFeedbackNudge) + Tier-C primitives (CelebrationOverlay, NextTaskNudge, lazy ConfettiBurst). D-16: neither this nor team-trophy-shelf triggers the other; the host routes each event kind to exactly one celebrator (set the shelf's animateAward=false to let this own it). Cooperative + team-scoped (D-08): no individual-subject copy, no per-member call-out, no inter-team/public surface. Portable: zero next/*, SSR-safe, imports no other registry component. Third component of the gamification-system.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/team-feedback-loopAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/team-feedback-loop-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Fire a celebration
Imperative celebrate() — a brief (<1s), skippable, NON-BLOCKING band at the bottom. Click behind it: the page stays fully interactive. Press Esc or ✕ to skip.
Options
Confetti is opt-in + lazy (milestone/badge only, never under reduced motion). Toggle your OS reduced-motion setting to see the static branch.
Live loop (celebration + nudge)
The inline nudge renders here; accept/dismiss are penalty-free. Watch the console for callbacks.
Composed / lighter (nudge only)
Hand-assembled Root + only TeamFeedbackNudge — no celebration, and the confetti chunk never loads.
Demo source
"use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { TeamFeedbackLoop } from "./team-feedback-loop";import { TeamFeedbackLoopRoot } from "./parts/team-feedback-loop-root";import { TeamFeedbackNudge } from "./parts/team-feedback-nudge";import { FEEDBACK_EVENTS, LONG_NEXT_TASK, LONG_TITLE_EVENT, NEXT_TASK,} from "./dummy-data";import type { FeedbackEvent, NextTaskSuggestion, TeamFeedbackLoopHandle,} from "./types"; function Section({ title, hint, children,}: { title: string; hint?: string; children: React.ReactNode;}) { return ( <section className="flex flex-col gap-3 rounded-lg border border-border bg-card p-5"> <div className="flex flex-col gap-0.5"> <h3 className="text-sm font-semibold text-foreground">{title}</h3> {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null} </div> {children} </section> );} export default function TeamFeedbackLoopDemo() { const ref = React.useRef<TeamFeedbackLoopHandle>(null); const [confetti, setConfetti] = React.useState(true); const [placement, setPlacement] = React.useState<"inline" | "corner">("inline"); const [showNudge, setShowNudge] = React.useState(true); // Composed section: drive the nudge from local state so accepting/dismissing // has a visible effect. In a real app `onNextTask` navigates to the task (a // consumer hand-off) — here we mirror that by clearing the prompt. const [composedTask, setComposedTask] = React.useState< NextTaskSuggestion | undefined >(NEXT_TASK); const [composedStarted, setComposedStarted] = React.useState<string | null>( null, ); const fire = (event: FeedbackEvent) => ref.current?.celebrate(event); const rapid = () => { // Newest wins — no stacking, single timer. fire(FEEDBACK_EVENTS["task-complete"]); window.setTimeout(() => fire(FEEDBACK_EVENTS.badge), 120); window.setTimeout(() => fire(FEEDBACK_EVENTS.milestone), 240); }; return ( <div className="mx-auto flex w-full max-w-2xl flex-col gap-5"> <Section title="Fire a celebration" hint="Imperative celebrate() — a brief (<1s), skippable, NON-BLOCKING band at the bottom. Click behind it: the page stays fully interactive. Press Esc or ✕ to skip." > <div className="flex flex-wrap gap-2"> <Button size="sm" onClick={() => fire(FEEDBACK_EVENTS.milestone)}> Milestone </Button> <Button size="sm" onClick={() => fire(FEEDBACK_EVENTS.badge)}> Badge </Button> <Button size="sm" onClick={() => fire(FEEDBACK_EVENTS["task-complete"])}> Task complete </Button> <Button size="sm" variant="outline" onClick={() => fire(LONG_TITLE_EVENT)}> Long title </Button> <Button size="sm" variant="outline" onClick={rapid}> Rapid ×3 (newest wins) </Button> </div> </Section> <Section title="Options" hint="Confetti is opt-in + lazy (milestone/badge only, never under reduced motion). Toggle your OS reduced-motion setting to see the static branch." > <div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5"> <input type="checkbox" checked={confetti} onChange={(e) => setConfetti(e.target.checked)} /> enableConfetti </label> <label className="flex items-center gap-1.5"> <input type="checkbox" checked={showNudge} onChange={(e) => setShowNudge(e.target.checked)} /> show nudge </label> <label className="flex items-center gap-1.5"> nudge placement: <select value={placement} onChange={(e) => setPlacement(e.target.value as "inline" | "corner") } className="rounded border border-border bg-background px-1.5 py-0.5" > <option value="inline">inline</option> <option value="corner">corner</option> </select> </label> </div> </Section> <Section title="Live loop (celebration + nudge)" hint="The inline nudge renders here; accept/dismiss are penalty-free. Watch the console for callbacks." > <div className="rounded-lg border border-dashed border-border p-4"> <TeamFeedbackLoop ref={ref} teamId="T-001" enableConfetti={confetti} nextTask={ showNudge ? placement === "corner" ? LONG_NEXT_TASK : NEXT_TASK : undefined } nudgePlacement={placement} onNextTask={(s) => console.info("[demo] accept next task", s.taskId)} onNudgeDismiss={(s) => console.info("[demo] dismiss nudge", s.taskId)} onCelebrationDismiss={(e, reason) => console.info("[demo] celebration dismissed", e.kind, reason) } /> </div> </Section> <Section title="Composed / lighter (nudge only)" hint="Hand-assembled Root + only TeamFeedbackNudge — no celebration, and the confetti chunk never loads." > <div className="flex flex-col gap-2"> <TeamFeedbackLoopRoot teamId="T-001" nextTask={composedTask} onNextTask={(s) => { console.info("[demo] accept", s.taskId); // Start = consumer hand-off (navigate to the task). We surface it // by clearing the prompt and noting what was started. setComposedStarted(s.label); setComposedTask(undefined); }} onNudgeDismiss={() => { setComposedStarted(null); setComposedTask(undefined); }} > {/* Only the nudge is mounted — no celebration part, no confetti chunk. */} <TeamFeedbackNudge /> </TeamFeedbackLoopRoot> {composedTask === undefined ? ( <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <span> {composedStarted ? `Started “${composedStarted}” — onNextTask fired (the hand-off is the consumer's job).` : "Nudge dismissed — penalty-free."} </span> <Button size="sm" variant="ghost" onClick={() => { setComposedStarted(null); setComposedTask(NEXT_TASK); }} > Reset </Button> </div> ) : null} </div> </Section> </div> );} Usage
When to use
Reach for TeamFeedbackLoop to close the cooperative feedback loops: a brief, skippable, NON-BLOCKING celebration when the host says "team progress just advanced," followed by a gentle next-task nudge. It owns no milestone/badge/task state — the host triggers it, it renders the moment and gets out of the way. Every reward is about the team, never an individual (D-08).
The cardinal constraint: non-blocking (D-10)
- The board stays fully interactive during a celebration — clicks pass through.
- Never moves or traps focus; the skip button is the only clickable element.
- Auto-dismisses in < 1s —
celebrationDurationMsis clamped to[200, 999), so a lingering modal is impossible. - Skip with a click on ✕ or Esc at any time.
Two trigger paths (one reducer)
import { TeamFeedbackLoop } from "@/components/team-feedback-loop"
// Controlled (declarative hosts): set event → open; null → close.
<TeamFeedbackLoop
teamId={team.id}
event={lastEvent} // { kind: "milestone", title: "Your team…" }
enableConfetti
nextTask={nextTask} // { taskId, label: "Pick up: wire the win screen" }
onNextTask={(s) => openTask(s.taskId)}
/>
// Imperative (event-driven hosts): fire from a callback.
const ref = useRef<TeamFeedbackLoopHandle>(null)
ref.current?.celebrate({ kind: "task-complete", title: "Your team cleared the column" })Both funnel into one internal reducer — identical behavior regardless of entry point. Newest event wins; overlays never stack.
Confetti + reduced motion
enableConfetti(default off) adds a lazycanvas-confettiburst formilestone/badgeonly. The default CSS flourish keeps the confetti chunk out of the bundle entirely.- Under
prefers-reduced-motion: reducethe celebration renders static — no movement, no confetti — still time-boxed and skippable.
D-16 celebration ownership
If you also use team-trophy-shelf, route each event kind to exactly one celebrator: set the shelf's animateAward={false} and push badge/milestonehere, OR let the shelf own the in-place reveal and don't push that kind here. Neither component triggers the other.
Notes
- Compound: drop
TeamFeedbackCelebrationorTeamFeedbackNudgefor a subset.onEventis accepted for symmetry but E6 emits nothing. - Portable + SSR-safe: no
next/*, no animation on first paint; only the shadcnbuttonprimitive + lucide + the lazy confetti dep.
Features
- Brief (<1s), skippable, NON-BLOCKING celebration overlay — clamped timer, click-through, no focus trap (D-10)
- Two trigger paths (controlled event prop + imperative celebrate()) funnel into one reducer — newest wins, never stack
- Gentle, penalty-free next-task nudge (inline or corner), independent of the celebration lifecycle
- Reduced-motion static branch — no movement, no confetti, still time-boxed + skippable
- Opt-in canvas-confetti burst, React.lazy for milestone/badge — the default CSS flourish keeps it out of the bundle
- Team-scoped copy only — never an individual (D-08); onEvent accepted for symmetry, emits nothing (E6)
- Compound: headless Root + flat parts + lazy confetti; drop either surface for a subset
- D-16 celebration ownership — set the trophy shelf's animateAward=false to defer the moment here