- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Research companion
Playback speed1x
Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/agent-creator-01
Pass an agent and an onChange callback to manage the profile in your application. Preferences are values for the host application to use; the block does not save data or request permissions.
Source
"use client";
import {
RiCloseLine,
RiGlobalLine,
RiNotification3Line,
RiPauseFill,
RiPlayFill,
RiVolumeUpLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
export const shapes = [
"star",
"heart",
"cloud",
"diamond",
"shield",
"circle",
"square",
"flower",
"drop",
] as const;
export type Shape = (typeof shapes)[number];
const paths: Record<Shape, string> = {
star: "M50 5 61 34 92 34 67 54 76 85 50 67 24 85 33 54 8 34 39 34Z",
heart:
"M50 88 C35 76 8 58 8 34 C8 10 37 8 50 28 C63 8 92 10 92 34 C92 58 65 76 50 88Z",
cloud:
"M21 79 C-3 79 2 42 24 41 C23 25 35 18 45 21 C50 4 75 8 79 27 C103 25 105 52 94 68 C89 76 82 79 70 79Z",
diamond: "M50 4 91 50 50 96 9 50Z",
shield:
"M50 5 Q70 18 89 19 L89 55 Q87 78 50 95 Q13 78 11 55 L11 19 Q30 18 50 5Z",
circle: "M50 5 A45 45 0 1 1 49.99 5Z",
square:
"M20 8 H80 Q92 8 92 20 V80 Q92 92 80 92 H20 Q8 92 8 80 V20 Q8 8 20 8Z",
flower:
"M50 9 C62 -1 69 13 68 22 C82 15 94 26 85 39 C101 44 99 59 84 63 C92 78 80 88 67 80 C65 97 49 99 44 84 C32 96 19 86 23 71 C7 72 2 57 16 49 C4 38 12 24 27 26 C27 11 41 4 50 9Z",
drop: "M50 5 C63 25 87 46 87 64 A37 32 0 0 1 13 64 C13 46 37 25 50 5Z",
};
/** The swatch values the creator offers; agents must use these to show as selected. */
export const agentColors = [
"var(--agent-avatar-1, var(--chart-1))",
"var(--agent-avatar-2, var(--chart-2))",
"var(--agent-avatar-3, var(--chart-3))",
"var(--agent-avatar-4, var(--chart-4))",
"var(--agent-avatar-5, var(--chart-5))",
"var(--agent-avatar-6, var(--chart-1))",
"var(--agent-avatar-7, var(--chart-2))",
"var(--agent-avatar-8, var(--chart-3))",
"var(--agent-avatar-9, var(--chart-4))",
"rainbow",
];
/** Accessible names for the swatches, in the order of `agentColors`. */
const agentColorNames = [
"Blue",
"Teal",
"Violet",
"Pink",
"Red",
"Orange",
"Cyan",
"Lime",
"Green",
"Rainbow",
];
const fields = [
["name", "Name"],
["label", "Label"],
["description", "Description"],
] as const;
const languages = ["Auto-detect", "English", "French", "German"];
const voices = ["Alice", "Ben", "Clara"];
const speeds = [0.75, 1, 1.25, 1.5];
// 500ms, 12px rise with a 3px blur clearing, staggered per row. Set on a
// plain element, with the row's delay in `--delay`.
const enter = "agent-creator-01-enter";
export interface AgentProfile {
name: string;
label: string;
description: string;
shape: Shape;
color: string;
face: number;
language: string;
voice: string;
notifications: boolean;
/** Playback speed of the voice, from 0.75 to 1.5. Defaults to 1. */
speed?: number;
}
/** Ink, the shading and the rainbow come from the theme with fallbacks. */
const rainbowStops = [
"var(--agent-avatar-5, var(--destructive))",
"var(--agent-avatar-6, var(--chart-2))",
"var(--agent-avatar-8, var(--chart-3))",
"var(--agent-avatar-9, var(--chart-4))",
"var(--agent-avatar-1, var(--primary))",
"var(--agent-avatar-3, var(--chart-5))",
"var(--agent-avatar-4, var(--chart-1))",
];
const rainbowBase = "var(--agent-avatar-3, var(--chart-5))";
const ink = "var(--agent-avatar-ink, var(--foreground))";
const shine = "var(--agent-avatar-shine, white)";
const shade = "var(--agent-avatar-shade, black)";
const eyeY = 57;
/** Sixteen expressions: eyes, brows and a mouth, in the avatar's ink. */
function Features({ index }: { index: number }) {
const oval = (cx: number, rx = 5.5, ry = 8) => (
<ellipse cx={cx} cy={eyeY} rx={rx} ry={ry} />
);
const glint = (cx: number) => (
<circle
cx={cx + 1.5}
cy={eyeY - 3}
r="1.7"
fill={shine}
stroke="none"
opacity="0.75"
/>
);
const face = index % 16;
const faces: React.ReactNode[] = [
<>
{oval(36)}
{oval(64)}
{glint(36)}
{glint(64)}
</>,
<path
key="happy-eyes"
d="M24 58 Q33 44 42 58 M58 58 Q67 44 76 58"
fill="none"
/>,
<>
{oval(36, 7, 9)}
{oval(64, 7, 9)}
{glint(36)}
{glint(64)}
<path d="M26 38 L44 44 M74 38 L56 44" fill="none" />
</>,
<>
{oval(36, 4.5, 6)}
<path d="M57 58 H72" fill="none" />
<path d="M44 74 Q50 79 56 74" fill="none" />
</>,
<>
<path d="M26 56 H44 M56 56 H74" fill="none" />
<path d="M40 74 Q50 80 60 74" fill="none" />
</>,
<>
{oval(34, 8, 8)}
{oval(66, 8, 8)}
<circle cx="37" cy={eyeY} r="3" fill={shine} stroke="none" />
<circle cx="69" cy={eyeY} r="3" fill={shine} stroke="none" />
</>,
<>
{oval(36, 4, 9)}
{oval(64, 4, 9)}
<path d="M28 38 Q36 33 44 38 M56 38 Q64 33 72 38" fill="none" />
</>,
<>
<path d="M26 54 Q35 64 44 54 M56 54 Q65 64 74 54" fill="none" />
<ellipse cx="50" cy="76" rx="5" ry="4" />
</>,
<>
{oval(36, 6, 6)}
<path d="M56 58 Q65 49 74 58" fill="none" />
<path d="M42 74 Q50 80 58 74" fill="none" />
</>,
<>
{oval(34, 5, 10)}
{oval(66, 5, 10)}
{glint(34)}
{glint(66)}
</>,
<>
<path d="M26 52 L44 60 L26 66 M74 52 L56 60 L74 66" fill="none" />
</>,
<>
{oval(36, 7, 7)}
{oval(64, 7, 7)}
<path d="M26 44 Q36 40 44 46 M56 46 Q64 40 74 44" fill="none" />
</>,
<>
<path d="M26 60 Q35 52 44 60 M56 60 Q65 52 74 60" fill="none" />
<path d="M44 74 H56" fill="none" />
</>,
<>
{oval(36, 5, 7)}
{oval(64, 5, 7)}
<ellipse cx="50" cy="76" rx="4" ry="5" />
</>,
<>
<path d="M28 56 H44" fill="none" />
{oval(64, 5, 7)}
<path d="M42 74 Q50 79 58 72" fill="none" />
</>,
<>
{oval(36, 6, 6)}
{oval(64, 6, 6)}
<path d="M28 44 L44 40 M72 44 L56 40" fill="none" />
<path d="M42 76 Q50 70 58 76" fill="none" />
</>,
];
const content = faces[face];
return (
<g
fill={ink}
stroke={ink}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
>
{content}
</g>
);
}
function Face({ index, color }: { index: number; color: string }) {
return (
<span className="relative block size-full">
<ShapeIcon shape="circle" color={color} />
<svg
viewBox="0 0 100 100"
aria-hidden="true"
className="absolute inset-0 size-full overflow-visible"
>
<Features index={index} />
</svg>
</span>
);
}
/**
* A shape with depth: a stack of darker copies for the thickness, a face lit
* from the top left and a soft highlight on the edge. A face is drawn on it
* when `face` is set. Fills its parent unless sized.
*/
export function ShapeIcon({
shape,
color,
face,
className,
}: {
shape: Shape;
color: string;
face?: number;
className?: string;
}) {
const id = React.useId();
const rainbow = color === "rainbow";
const base = rainbow ? rainbowBase : color;
const side = `color-mix(in oklab, ${base} 66%, ${shade})`;
const d = paths[shape];
return (
<svg
viewBox="0 0 100 100"
aria-hidden="true"
className={cn("size-full overflow-visible", className)}
>
<defs>
{rainbow && (
<radialGradient id={`${id}r`} cx="-0.2" cy="-0.2" r="1.6">
{rainbowStops.map((stop, i) => (
<stop
key={stop}
offset={`${(i / (rainbowStops.length - 1)) * 100}%`}
stopColor={stop}
/>
))}
</radialGradient>
)}
<radialGradient id={`${id}l`} cx="0.05" cy="-0.1" r="1.5">
<stop offset="0" stopColor={shine} stopOpacity="0.42" />
<stop offset="0.5" stopColor={shine} stopOpacity="0" />
<stop offset="1" stopColor={shade} stopOpacity="0.2" />
</radialGradient>
<radialGradient id={`${id}h`} cx="0.3" cy="0.22" r="0.55">
<stop offset="0" stopColor={shine} stopOpacity="0.4" />
<stop offset="1" stopColor={shine} stopOpacity="0" />
</radialGradient>
</defs>
<path d={d} fill={shade} opacity="0.14" transform="translate(6 9)" />
{[5, 4, 3, 2, 1].map((step) => (
<path
key={step}
d={d}
fill={side}
transform={`translate(${step * 1.1} ${step * 1.5})`}
/>
))}
<path
d={d}
fill={rainbow ? `url(#${id}r)` : color}
className="transition-colors duration-200 ease-out motion-reduce:transition-none"
/>
<path d={d} fill={`url(#${id}l)`} />
<path d={d} fill={`url(#${id}h)`} />
<path
d={d}
fill="none"
stroke={shine}
strokeOpacity="0.28"
strokeWidth="1.5"
/>
{face !== undefined && (
<svg x="22" y="20" width="56" height="60" viewBox="0 0 100 100">
<Features index={face} />
</svg>
)}
</svg>
);
}
/** A glossy colour dot, or the rainbow one. */
function Swatch({ color }: { color: string }) {
const rainbow = color === "rainbow";
return (
<span
className="agent-creator-01-swatch relative block size-full rounded-full bg-(image:--swatch) group-hover/toggle:scale-110"
style={
{
"--swatch": rainbow
? `conic-gradient(from 200deg, ${rainbowStops.join(", ")}, ${rainbowStops[0]})`
: `linear-gradient(${color}, ${color})`,
} as React.CSSProperties
}
>
<span className="agent-creator-01-swatch-shine absolute inset-0 rounded-full" />
</span>
);
}
// The shapes sit on a shallow arc of five slots. The window slides so the
// chosen shape is always in view; neighbours animate into their slots.
const arcSlots = 5;
const arcX = [-132, -68, 0, 68, 132];
const arcY = [0, 34, 48, 34, 0];
function arcPosition(slot: number) {
const clamped = Math.min(Math.max(slot, 0), arcSlots - 1);
return { x: arcX[clamped], y: arcY[clamped] };
}
export function AgentCreator({
agent,
onChange,
className,
popupClassName,
onClose,
bare = false,
}: {
agent: AgentProfile;
onChange: (agent: AgentProfile) => void;
className?: string;
popupClassName?: string;
/** Shows a close button in the corner when set. */
onClose?: () => void;
/** Drops the card surface, for a column on the page itself. */
bare?: boolean;
}) {
const notificationId = React.useId();
const [playing, setPlaying] = React.useState(false);
const update = <K extends keyof AgentProfile>(
key: K,
value: AgentProfile[K]
) => onChange({ ...agent, [key]: value });
const selected = shapes.indexOf(agent.shape);
const start = Math.min(
Math.max(selected - 2, 0),
Math.max(shapes.length - arcSlots, 0)
);
const speed = agent.speed ?? 1;
const language = languages.includes(agent.language)
? agent.language
: languages[0];
const voice = voices.includes(agent.voice) ? agent.voice : voices[0];
return (
<Card
radius={18}
size="flush"
variant={bare ? "flat" : "default"}
role="region"
aria-label="Agent creator"
className={cn(
"@container h-[600px] w-[280px] max-w-full",
// The inspector sits on the page, so the groups read against it.
// oxlint-disable-next-line shadcn/no-restyle -- reference: the bare inspector has no card fill
bare && "bg-transparent",
className
)}
>
<div className="shrink-0 px-3 pt-3">
<CardHeader>
<CardTitle
// oxlint-disable-next-line shadcn/no-restyle -- reference: 14px title (the flush card does not shrink it)
className="text-center text-sm"
>
{agent.name || "Untitled agent"}
</CardTitle>
{onClose && (
<Button
variant="close"
size="icon-xs"
tooltip="Close editor"
className="absolute top-3 right-3 z-10"
onClick={onClose}
>
<RiCloseLine />
</Button>
)}
</CardHeader>
</div>
<div className="mx-auto mt-3 w-[320px] shrink-0 @max-[319px]:[zoom:0.86]">
<div className="relative mx-auto aspect-square w-[260px]">
{Array.from({ length: 16 }, (_, i) => {
const angle = (i * 2 * Math.PI) / 16 - Math.PI / 2;
return (
<div
key={i}
style={
{
"--delay": `${i * 18}ms`,
"--x": `${Math.cos(angle) * 114}px`,
"--y": `${Math.sin(angle) * 114}px`,
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(50%+var(--y)-17px)] left-[calc(50%+var(--x)-17px)] size-[34px]",
enter
)}
>
<Toggle
aria-label={`Expression ${i + 1}`}
pressed={agent.face === i}
onPressedChange={(pressed) => {
if (pressed) {
update("face", i);
}
}}
variant="swatch"
size="icon"
className="size-full min-w-0"
>
<span className="block size-full transition-all duration-150 ease-out group-hover/toggle:scale-110 motion-reduce:transition-none motion-reduce:group-hover/toggle:scale-100">
<Face index={i} color={agent.color} />
</span>
</Toggle>
</div>
);
})}
<div className="absolute inset-[58px]">
<div
key={`${agent.shape}-${agent.face}`}
className="agent-creator-01-shape-in size-full"
>
<ShapeIcon
shape={agent.shape}
color={agent.color}
face={agent.face}
/>
</div>
</div>
</div>
<div
className="relative mx-auto -mt-4 h-[112px] w-full"
role="group"
aria-label="Avatar shape"
>
{shapes.map((shape, index) => {
const slot = index - start;
const visible = slot >= 0 && slot < arcSlots;
const { x, y } = arcPosition(
Math.min(Math.max(slot, -1), arcSlots)
);
return (
<div
key={shape}
style={
{
"--x": `${x}px`,
"--y": `${y}px`,
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(var(--y)+2px)] left-[calc(50%+var(--x)-31px)] size-[62px] transition-all duration-300 ease-out motion-reduce:transition-none",
!visible && "pointer-events-none opacity-0"
)}
>
<Toggle
aria-label={shape}
pressed={agent.shape === shape}
disabled={!visible}
onPressedChange={(pressed) => {
if (pressed) {
update("shape", shape);
}
}}
variant="ghost"
size="icon"
// oxlint-disable-next-line shadcn/no-restyle -- reference: the selected shape tile has a 1px border and 16px corners
className="size-full min-w-0 rounded-[16px] border border-transparent data-pressed:border-border"
>
<span className="block size-full p-2.5">
<ShapeIcon shape={shape} color={agent.color} />
</span>
</Toggle>
</div>
);
})}
</div>
</div>
<div
className="mx-auto mt-3 flex shrink-0 justify-center gap-1.5 rounded-full bg-muted p-1.5"
role="group"
aria-label="Avatar color"
>
{agentColors.map((color, i) => (
<div
key={color}
className={cn("size-6 @max-[299px]:size-5", enter)}
style={{ "--delay": `${200 + i * 16}ms` } as React.CSSProperties}
>
<Toggle
aria-label={agentColorNames[i]}
pressed={agent.color === color}
onPressedChange={(pressed) => {
if (pressed) {
update("color", color);
}
}}
variant="swatch"
size="icon"
className="size-full min-w-0"
>
<Swatch color={color} />
</Toggle>
</div>
))}
</div>
<div className="mt-3 min-h-0 flex-1 overflow-y-auto px-3 pb-3">
<CardContent className="gap-4">
<div className="rounded-[20px] bg-surface-sunken px-3">
{fields.map(([key, label], index) => (
<React.Fragment key={key}>
{index > 0 && <Separator />}
<div
className={cn("py-2", enter)}
style={
{ "--delay": `${index * 40}ms` } as React.CSSProperties
}
>
<Label
variant="inline"
className="grid grid-cols-[76px_1fr] items-start"
>
<span className="pt-2">{label}</span>
{key === "description" ? (
<Textarea
rows={2}
autoResize
maxRows={5}
className="min-h-[56px]"
value={agent[key]}
onChange={(event) => update(key, event.target.value)}
/>
) : (
<Input
value={agent[key]}
onChange={(event) => update(key, event.target.value)}
/>
)}
</Label>
</div>
</React.Fragment>
))}
</div>
<div className="rounded-[20px] bg-surface-sunken px-3 text-sm">
<div
className={cn("flex items-center gap-2 py-3", enter)}
style={{ "--delay": "120ms" } as React.CSSProperties}
>
<RiGlobalLine
aria-hidden="true"
className="size-5 shrink-0 text-muted-foreground @max-[248px]:hidden"
/>
<span className="min-w-0 flex-1 truncate">Language</span>
<PreferenceSelect
label="Language"
value={language}
options={languages}
onValueChange={(value) => update("language", value)}
popupClassName={popupClassName}
/>
</div>
<Separator />
<div
className={cn("py-3", enter)}
style={{ "--delay": "160ms" } as React.CSSProperties}
>
<Label htmlFor={notificationId} variant="inline">
<RiNotification3Line
aria-hidden="true"
className="size-5 shrink-0 text-muted-foreground @max-[248px]:hidden"
/>
<span className="min-w-0 flex-1">
<span className="block truncate">Notifications</span>
<span className="block truncate text-xs font-normal text-muted-foreground">
Show a notice when a reply is ready.
</span>
</span>
<Switch
id={notificationId}
size="sm"
checked={agent.notifications}
onCheckedChange={(checked) =>
update("notifications", checked)
}
/>
</Label>
</div>
</div>
<div className="rounded-[20px] bg-surface-sunken px-3 text-sm">
<div
className={cn("flex items-center gap-2 py-3", enter)}
style={{ "--delay": "200ms" } as React.CSSProperties}
>
<RiVolumeUpLine
aria-hidden="true"
className="size-5 shrink-0 text-muted-foreground @max-[248px]:hidden"
/>
<span className="min-w-0 flex-1 truncate">Voice</span>
<Button
variant="ghost-muted"
size="icon-sm"
aria-pressed={playing}
tooltip={playing ? "Stop preview" : "Play preview"}
onClick={() => setPlaying(!playing)}
>
{playing ? <RiPauseFill /> : <RiPlayFill />}
</Button>
<PreferenceSelect
label="Voice"
value={voice}
options={voices}
onValueChange={(value) => update("voice", value)}
popupClassName={popupClassName}
/>
</div>
<Separator />
<div
className={cn("flex flex-col gap-2 py-3", enter)}
style={{ "--delay": "240ms" } as React.CSSProperties}
>
<div className="flex items-center justify-between text-muted-foreground">
<span>Playback speed</span>
<span className="tabular-nums">{speed}x</span>
</div>
<Slider
aria-label="Playback speed"
min={speeds[0]}
max={speeds.at(-1)}
step={0.25}
value={[speed]}
showTooltip={false}
onValueChange={(value) =>
update("speed", Array.isArray(value) ? value[0] : value)
}
/>
</div>
</div>
</CardContent>
</div>
</Card>
);
}
function PreferenceSelect({
label,
value,
options,
onValueChange,
popupClassName,
}: {
label: string;
value: string;
options: string[];
onValueChange: (value: string) => void;
popupClassName?: string;
}) {
return (
<Select
items={options.map((option) => ({ label: option, value: option }))}
value={value}
onValueChange={(next) => {
if (next) {
onValueChange(next);
}
}}
>
<SelectTrigger
aria-label={label}
size="sm"
className="max-w-[130px] min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent
side="top"
align="end"
alignItemWithTrigger={false}
className={cn("w-[160px]", popupClassName)}
>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}