Skip to content
ilinxa/pro-ui

Team Feedback Loop

alphav0.2.1

Non-blocking celebration layer — a brief skippable overlay when team progress advances, plus a gentle dismissible next-task nudge.

Category: GamificationUpdated: 2026-08-11Created: 2026-07-01Author: ilinxa

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

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
Install the component
pnpm dlx shadcn@latest add @ilinxa/team-feedback-loop

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/team-feedback-loop-fixtures

CLI 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.

Next upPick up: wire the win screen

Composed / lighter (nudge only)

Hand-assembled Root + only TeamFeedbackNudge — no celebration, and the confetti chunk never loads.

Next upPick up: wire the win screen

Demo source

demo.tsxtsx
"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 — celebrationDurationMs is 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 lazy canvas-confetti burst for milestone/badge only. The default CSS flourish keeps the confetti chunk out of the bundle entirely.
  • Under prefers-reduced-motion: reduce the 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 TeamFeedbackCelebration or TeamFeedbackNudge for a subset. onEvent is accepted for symmetry but E6 emits nothing.
  • Portable + SSR-safe: no next/*, no animation on first paint; only the shadcn button primitive + 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

Tags

team-feedback-loopgamificationcelebrationconfettinudgenon-blockingteamcooperative

Dependencies

shadcn primitives: button
npm peer deps: canvas-confetti@^1.9.4, lucide-react@^1.11.0