All blocks
Onboardingintermediate
Team Invitations
Collect invites into draft flow data, then review and send them.
Installation
Add it with the shadcn CLI, open it in v0, or read the source.
$ npx shadcn@latest add https://stepperize.com/r/team-invites.jsonDependencies
- @stepperize/react
- lucide-react
Requirements
- React 18 or later
- Tailwind CSS
Source
import { defineStepper } from "@stepperize/react";
import { Check, Plus, Send, Trash2, Users } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Invite = { email: string; role: "Member" | "Admin" };
const team = defineStepper([
{ id: "invite", title: "Invite your team" },
{ id: "review", title: "Review invites" },
{ id: "sent", title: "Invites sent" },
]);
const { Stepper } = team;
/**
* Draft values and review: invites are stored in `stepper.data` as they're
* added, then the review step reads them back before sending. Inner components
* call `team.useStepper()` to read the same instance the Root provides.
*/
export function TeamInvitesBlock() {
return (
<Stepper.Root
linear
className="w-full max-w-sm rounded-xl border bg-background p-5 shadow-sm"
>
{() => (
<>
<Header />
<InviteStep />
<ReviewStep />
<SentStep />
</>
)}
</Stepper.Root>
);
}
function useInvites() {
const stepper = team.useStepper();
const invites = (stepper.data.get("invite") as Invite[] | undefined) ?? [];
return { stepper, invites };
}
function Header() {
const { stepper, invites } = useInvites();
return (
<div className="mb-4 flex items-center gap-2.5">
<span className="grid size-8 place-items-center rounded-lg bg-primary/10 text-primary">
<Users className="size-4" />
</span>
<div>
<p className="text-sm font-semibold">{stepper.current.title}</p>
<p className="text-xs text-muted-foreground">
{invites.length} {invites.length === 1 ? "invite" : "invites"} so far
</p>
</div>
</div>
);
}
function InviteStep() {
const { stepper, invites } = useInvites();
const [email, setEmail] = useState("");
const [role, setRole] = useState<Invite["role"]>("Member");
const add = () => {
if (!email.includes("@")) return;
stepper.data.set("invite", [...invites, { email, role }]);
setEmail("");
};
const remove = (index: number) =>
stepper.data.set(
"invite",
invites.filter((_, i) => i !== index),
);
return (
<Stepper.Content step="invite" className="space-y-3">
<div className="flex gap-2">
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="name@company.com"
className="min-w-0 flex-1"
/>
<Select
value={role}
onValueChange={(value) => setRole(value as Invite["role"])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Member">Member</SelectItem>
<SelectItem value="Admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button size="icon" onClick={add} aria-label="Add invite">
<Plus />
</Button>
</div>
<ul className="space-y-1.5">
{invites.map((invite, i) => (
<li
key={invite.email}
className="flex items-center gap-2 rounded-lg border bg-muted/20 px-3 py-2 text-sm"
>
<span className="flex-1 truncate">{invite.email}</span>
<Badge variant="secondary">{invite.role}</Badge>
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(i)}
aria-label={`Remove ${invite.email}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</li>
))}
</ul>
<Button
className="w-full"
disabled={invites.length === 0}
onClick={() => stepper.next()}
>
Review {invites.length > 0 ? `${invites.length} ` : ""}invites
</Button>
</Stepper.Content>
);
}
function ReviewStep() {
const { stepper, invites } = useInvites();
return (
<Stepper.Content step="review" className="space-y-3">
<p className="text-sm text-muted-foreground">
You're inviting <b className="text-foreground">{invites.length}</b>{" "}
{invites.length === 1 ? "person" : "people"}.
</p>
<ul className="space-y-1.5">
{invites.map((invite) => (
<li
key={invite.email}
className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2 text-sm"
>
<span className="truncate">{invite.email}</span>
<Badge variant="secondary">{invite.role}</Badge>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-3">
<Button variant="ghost" size="sm" onClick={() => stepper.prev()}>
Edit
</Button>
<Button
onClick={() => {
stepper.setComplete("review");
stepper.next();
}}
>
<Send /> Send invites
</Button>
</div>
</Stepper.Content>
);
}
function SentStep() {
const { stepper, invites } = useInvites();
return (
<Stepper.Content
step="sent"
className="grid place-items-center gap-2 py-4 text-center"
>
<span className="grid size-12 place-items-center rounded-full bg-chart-2/15 text-chart-2">
<Check className="size-6" />
</span>
<p className="text-sm font-medium">{invites.length} invites sent</p>
<p className="text-xs text-muted-foreground">
Your teammates will get an email to join.
</p>
<Button
variant="link"
size="sm"
onClick={() => {
stepper.data.reset();
stepper.reset();
}}
>
Invite more
</Button>
</Stepper.Content>
);
}
When to use it
When you need to gather a list across steps and confirm it before committing — invites, recipients, line items.
Accessibility
The running list is announced as it grows; remove buttons are labelled per row.
Customization
Invites are stored in `stepper.data`; swap the email rows for any repeatable input and submit on `complete`.