Skip to content
ilinxa/pro-ui

Team Challenge

alphav0.2.1

Cooperative team challenge card — one shared goal, collective progress, a whole-team reward, and penalty-free opt-in.

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

Context

The Relatedness surface of the gamification pack (E3). Renders one Challenge for one Team — label + member stack, a collective progress meter (current / target, never per-member), a whole-team reward chip (system D-08), and a team-level opt-in control where opted-out is a first-class, neutral, joinable state (the CRITICAL never-forced rule made literal: joining is a prominent invite, leaving is one click with no confirm/guilt, and a visible no-penalty hint states refusing is cost-free). On done, a lightweight inline earned acknowledgement (a 'Completed together' pill, non-blocking, no modal) — the heavy celebration overlay belongs to team-feedback-loop (E6), composed alongside; neither triggers the other. Opt-in is controlled-only; omitting onOptInChange collapses to a read-only card. Ships as a light shadcn-style compound — headless TeamChallengeRoot (controlled echo + telemetry chokepoint) + flat parts (Header, Progress, Reward, OptIn) + context-free primitives (ChallengeProgressMeter, ChallengeRewardChip, OptInToggle, TeamMemberStack, Skeleton) + the TeamChallenge assembly — so a card-body-only or opt-in-control-only subset falls out. Emits challenge.opened (first mount, double-emit-guarded) + challenge.opt-in via onEvent. Portable: zero next/*, own types.ts slice, no other registry import; SSR-safe. 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-challenge

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/team-challenge-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

Interactive — join / leave (penalty-free)

Controlled opt-in: joining is a prominent invite, leaving is one click with no guilt. Emits challenge.opened + challenge.opt-in.

Optional

All 5 members commit a task this morning

Team Aurora

2 / 5
The team earns: A shared afternoon off

Optional — no penalty for sitting this one out

Joinable (opted-out)

A neutral, first-class invitation — the goal + reward shown as available if you join, never greyed-as-failure.

Optional

All 5 members commit a task this morning

Team Aurora

2 / 5
The team earns: A shared afternoon off

Optional — no penalty for sitting this one out

Active + Completed

Opted-in and in motion → whole-team earned treatment on done (lightweight inline ack, no modal).

Active

Close every stale review by Friday

Team Aurora

3 / 5
The team earns: Team lunch on the house

Optional — no penalty for sitting this one out

Completed together

Ship the onboarding flow together

Team Aurora

5 / 5
The team earned: Team lunch on the house

Optional — no penalty for sitting this one out

Read-only (omit onOptInChange)

Capability-gating — with no handler the opt-in control hides; a pure progress + reward card falls out for free.

Active

Close every stale review by Friday

Team Aurora

3 / 5
The team earns: Team lunch on the house

Composed / lighter (bare parts, no card chrome)

Just the penalty-free toggle + the avatar pile — the compound tree-shakes to a subset (no Root, no card).

Optional — no penalty for sitting this one out

Team sizes

Single-member (no +N) ↔ overflowing stack (+N chip).

Active

Close every stale review by Friday

Team Nimbus

3 / 5
The team earns: Team lunch on the house

Optional — no penalty for sitting this one out

Optional

All 5 members commit a task this morning

Team Meridian

2 / 5
The team earns: A shared afternoon off

Optional — no penalty for sitting this one out

Edge cases

No reward (chip hides) · target 0 (0 / 0, never NaN) · long label (truncates) · loading skeleton.

Active

Pair on one tricky bug this week

Team Aurora

1 / 4

Optional — no penalty for sitting this one out

Optional

Warm-up challenge (no target set yet)

Team Aurora

0 / 0

Optional — no penalty for sitting this one out

Optional

Every member records at least one asynchronous stand-up update before noon so nobody is blocked over the long weekend

Team Meridian

4 / 7
The team earns: The team picks the next sprint's focus together

Optional — no penalty for sitting this one out

Demo source

demo.tsxtsx
"use client"; import * as React from "react"; import { TeamChallenge } from "./team-challenge";import { OptInToggle } from "./parts/team-challenge-optin";import { TeamMemberStack } from "./parts/team-member-stack";import { TeamChallengeSkeleton } from "./parts/team-challenge-skeleton";import {  CHALLENGE_ACTIVE,  CHALLENGE_COMPLETE,  CHALLENGE_JOINABLE,  CHALLENGE_LONG_LABEL,  CHALLENGE_NO_REWARD,  CHALLENGE_TARGET_ZERO,  TEAM_AURORA,  TEAM_LARGE,  TEAM_SOLO,} from "./dummy-data";import type { Challenge } from "./types"; function Section({  title,  hint,  children,}: {  title: string;  hint?: string;  children: React.ReactNode;}) {  return (    <section className="flex flex-col gap-3">      <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>  );} /** Controlled host — proves the opt-in echo + the penalty-free join/leave loop. */function InteractiveChallenge() {  const [optedIn, setOptedIn] = React.useState(false);  const challenge: Challenge = { ...CHALLENGE_JOINABLE, optedIn };  return (    <TeamChallenge      challenge={challenge}      team={TEAM_AURORA}      onOptInChange={setOptedIn}      onEvent={(e) => console.info("[demo] gamification event", e)}    />  );} export default function TeamChallengeDemo() {  return (    <div className="mx-auto flex w-full max-w-md flex-col gap-8">      <Section        title="Interactive — join / leave (penalty-free)"        hint="Controlled opt-in: joining is a prominent invite, leaving is one click with no guilt. Emits challenge.opened + challenge.opt-in."      >        <InteractiveChallenge />      </Section>       <Section        title="Joinable (opted-out)"        hint="A neutral, first-class invitation — the goal + reward shown as available if you join, never greyed-as-failure."      >        <TeamChallenge          challenge={CHALLENGE_JOINABLE}          team={TEAM_AURORA}          onOptInChange={() => {}}        />      </Section>       <Section        title="Active + Completed"        hint="Opted-in and in motion → whole-team earned treatment on done (lightweight inline ack, no modal)."      >        <div className="flex flex-col gap-4">          <TeamChallenge            challenge={CHALLENGE_ACTIVE}            team={TEAM_AURORA}            onOptInChange={() => {}}          />          <TeamChallenge            challenge={CHALLENGE_COMPLETE}            team={TEAM_AURORA}            onOptInChange={() => {}}          />        </div>      </Section>       <Section        title="Read-only (omit onOptInChange)"        hint="Capability-gating — with no handler the opt-in control hides; a pure progress + reward card falls out for free."      >        <TeamChallenge challenge={CHALLENGE_ACTIVE} team={TEAM_AURORA} />      </Section>       <Section        title="Composed / lighter (bare parts, no card chrome)"        hint="Just the penalty-free toggle + the avatar pile — the compound tree-shakes to a subset (no Root, no card)."      >        <div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-5">          <TeamMemberStack members={TEAM_AURORA.members} />          <OptInToggle optedIn={false} onOptInChange={() => {}} />        </div>      </Section>       <Section        title="Team sizes"        hint="Single-member (no +N) ↔ overflowing stack (+N chip)."      >        <div className="flex flex-col gap-4">          <TeamChallenge            challenge={{ ...CHALLENGE_ACTIVE, id: "solo" }}            team={TEAM_SOLO}            onOptInChange={() => {}}          />          <TeamChallenge            challenge={{ ...CHALLENGE_JOINABLE, id: "large" }}            team={TEAM_LARGE}            onOptInChange={() => {}}          />        </div>      </Section>       <Section        title="Edge cases"        hint="No reward (chip hides) · target 0 (0 / 0, never NaN) · long label (truncates) · loading skeleton."      >        <div className="flex flex-col gap-4">          <TeamChallenge            challenge={CHALLENGE_NO_REWARD}            team={TEAM_AURORA}            onOptInChange={() => {}}          />          <TeamChallenge            challenge={CHALLENGE_TARGET_ZERO}            team={TEAM_AURORA}            onOptInChange={() => {}}          />          <TeamChallenge            challenge={CHALLENGE_LONG_LABEL}            team={TEAM_LARGE}            onOptInChange={() => {}}          />          <TeamChallengeSkeleton />        </div>      </Section>    </div>  );} 

Usage

When to use

Reach for TeamChallenge when a team app wants a safe-by-design cooperative challenge — one shared goal, collective progress, and a whole-team reward, with a penalty-free opt-in. It is the Relatedness surface of the gamification pack. The single rule it exists to protect: never-forced — opting in is a deliberate team choice, refusing is cost-free and neutral, and the reward belongs to the whole team equally, never an individual.

Basic example

import { TeamChallenge } from "@/components/team-challenge"

export function TeamChallengeZone({ challenge, team }) {
  return (
    <TeamChallenge
      challenge={challenge}   // { label, optedIn, progress, reward, done }
      team={team}             // { name, members }
      onOptInChange={(optedIn) => persistOptIn(team.id, challenge.id, optedIn)}
      onEvent={(e) => analytics.track(e.type, e)} // host adds the envelope
    />
  )
}

Controlled opt-in (never forced)

challenge.optedIn is the value — the host owns it. Joining is a prominent invite; leaving is one click, no confirm, no guilt. Omit onOptInChange and the control hides entirely — a read-only progress + reward card falls out for free.

Compose a lighter version

It ships as a light shadcn-style compound. Drop the card chrome and keep only the penalty-free toggle (or the bare avatar pile):

import { OptInToggle, TeamMemberStack } from "@/components/team-challenge"

// A context-free toggle, droppable on any surface:
<OptInToggle optedIn={challenge.optedIn} onOptInChange={join} />

// …or just the identity pile:
<TeamMemberStack members={team.members} />

Notes

  • Collective progress only. One bar + current / target — never a per-member breakdown or ranking. The avatar stack is identity, not a progress display.
  • Whole-team reward. The reward reads as the team earns / earned — never first-person, never per-member.
  • Done is a lightweight inline ack. A Completed together pill (non-blocking, no modal). The heavy celebration overlay is team-feedback-loop (E6) — compose it alongside; neither triggers the other.
  • Telemetry. onEvent emits challenge.opened once on first mount (StrictMode-safe) and challenge.opt-in on toggle. No SDK, no next/*.
  • Portable. Own types.ts slice, no other registry import — only the shadcn progress, avatar, button, badge, and skeleton primitives.

Features

  • One shared team goal — label + member stack, collective progress (current / target), whole-team reward
  • Never-forced opt-in: opted-out is a neutral, first-class, cost-free invitation — never greyed-as-failure
  • Penalty-free leave: one click, no confirm dialog, no guilt copy; a visible no-penalty hint in both states
  • Collective progress only — never a per-member breakdown or ranking; the avatar stack is identity, not progress
  • Whole-team reward framing (the team earns / earned) — never first-person, never per-member
  • Done = a lightweight inline 'Completed together' ack (non-blocking, no modal); heavy celebration deferred to E6
  • challenge.opened + challenge.opt-in telemetry via onEvent — no SDK, no next/*
  • Light compound: headless Root + flat parts + context-free primitives; drop to a card-body-only or toggle-only subset

Tags

team-challengegamificationchallengerelatednessopt-inteamcooperativetelemetry

Dependencies

shadcn primitives: progress, avatar, button, badge, skeleton
npm peer deps: lucide-react@^1.11.0