- 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
Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/meeting-scheduler-01
Pass host and meeting details and an IANA timezone (for example, Europe/Amsterdam). Slots represent 09:00–18:30 in that host time zone and can be displayed in the local time zone or UTC. onChange receives the selected host date and time; onBook receives that value, its corresponding instant, and the host timezone. Connect onBook to your booking service. Invalid or omitted time zones fall back to the browser time zone.
Source
"use client";
import {
RiCalendarLine,
RiArrowDownSLine,
RiTimeLine,
RiFlagLine,
RiGlobeLine,
RiVideoLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
export interface MeetingSchedulerValue {
date: Date;
time: string | null;
}
export interface MeetingSchedulerProps {
host?: { name: string; email: string; avatarInitial: string };
meeting?: {
title: string;
description?: string;
durationMinutes: number;
language?: string;
conferencing?: string;
};
timezone?: string;
value?: MeetingSchedulerValue | null;
defaultValue?: MeetingSchedulerValue | null;
onChange?: (value: MeetingSchedulerValue | null) => void;
/** Called when a valid slot is confirmed. */
onBook?: (booking: {
value: MeetingSchedulerValue;
instant: Date;
timezone: string;
}) => void;
defaultOpen?: boolean;
className?: string;
popupClassName?: string;
"aria-label"?: string;
triggerLabel?: string;
}
const PANEL_HEIGHT = 431;
const slots = Array.from(
{ length: 20 },
(_, index) =>
`${String(9 + Math.floor(index / 2)).padStart(2, "0")}:${index % 2 ? "30" : "00"}`
);
function formatTime(time: string, use24h: boolean) {
if (use24h) {
return time;
}
const [hour, minute] = time.split(":").map(Number);
return `${hour % 12 || 12}:${String(minute).padStart(2, "0")} ${hour >= 12 ? "PM" : "AM"}`;
}
// Convert a wall-clock slot in the host zone to an instant, accounting for DST.
function slotInstant(date: Date, time: string, zone: string): Date | null {
const [hour, minute] = time.split(":").map(Number);
const target = Date.UTC(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute
);
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: zone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
let instant = target;
for (let i = 0; i < 4; i += 1) {
const parts = Object.fromEntries(
formatter.formatToParts(instant).map(({ type, value }) => [type, value])
);
const actual = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute)
);
const difference = target - actual;
if (difference === 0) {
return new Date(instant);
}
instant += difference;
}
// A wall time skipped by a daylight-saving transition.
return null;
}
function validTimeZone(zone: string) {
try {
return Boolean(new Intl.DateTimeFormat(undefined, { timeZone: zone }));
} catch {
return false;
}
}
export function MeetingScheduler({
host = { name: "Alex Morgan", email: "alex@example.com", avatarInitial: "A" },
meeting = {
title: "Project conversation",
description: "Let's talk about your next project.",
durationMinutes: 30,
language: "English",
conferencing: "Video call",
},
timezone = "Local time",
value,
defaultValue = null,
onChange,
onBook,
defaultOpen = false,
className,
popupClassName,
"aria-label": ariaLabel = "Schedule meeting",
triggerLabel = "Schedule a meeting",
}: MeetingSchedulerProps) {
const [internalValue, setInternalValue] =
React.useState<MeetingSchedulerValue | null>(
() => defaultValue ?? { date: new Date(), time: null }
);
const committed = value === undefined ? internalValue : value;
const [pending, setPending] = React.useState<MeetingSchedulerValue | null>(
committed
);
const [open, setOpen] = React.useState(false);
const [use24h, setUse24h] = React.useState(true);
const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const hostZone = validTimeZone(timezone) ? timezone : localZone;
const [selectedZone, setSelectedZone] = React.useState<string | null>(null);
const displayZone = selectedZone ?? hostZone;
const zones = [...new Set([hostZone, localZone, "UTC"])];
React.useEffect(() => {
if (defaultOpen && window.matchMedia("(min-width: 640px)").matches) {
setOpen(true);
}
}, [defaultOpen]);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const [fits, setFits] = React.useState(true);
const openChange = (next: boolean) => {
if (next) {
setPending(committed);
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
// The panel keeps its natural size: flip when a side has room,
// otherwise stay below and let the page scroll.
const needed = PANEL_HEIGHT + 16;
setFits(
rect.top >= needed || window.innerHeight - rect.bottom >= needed
);
}
}
setOpen(next);
};
return (
<Popover open={open} onOpenChange={openChange}>
<PopoverTrigger
render={
<Button ref={triggerRef} variant="outline" className={className} />
}
>
<RiCalendarLine data-icon="inline-start" />
<span className="px-1">{triggerLabel}</span>
<RiArrowDownSLine
className={cn(
"size-4 transition-transform duration-200 motion-reduce:transition-none",
open && "rotate-180"
)}
/>
</PopoverTrigger>
<PopoverContent
aria-label={ariaLabel}
align="end"
size="flush"
radius={24}
sideOffset={8}
collisionAvoidance={
fits
? { side: "flip", align: "shift" }
: { side: "none", align: "shift" }
}
className={cn(
"max-h-none w-auto max-w-[calc(100vw-16px)] overflow-y-auto",
popupClassName
)}
>
<div className="flex w-[min(100%,650px)] flex-col gap-4 text-sm font-medium sm:h-[429px] sm:w-max sm:flex-row">
<div className="flex min-w-0 flex-col gap-4 px-4 pt-4 sm:w-[160px] sm:shrink-0 sm:py-4 sm:pe-0">
<div className="flex flex-col items-start gap-2">
<Avatar color="blue">
<AvatarFallback>{host.avatarInitial}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col break-words">
<span>{host.name}</span>
<span className="text-muted-foreground">{host.email}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-base font-medium">{meeting.title}</p>
{meeting.description && (
<p className="text-xs text-muted-foreground">
{meeting.description}
</p>
)}
</div>
<div className="mt-auto flex flex-col gap-3">
<div className="flex items-center gap-1.25">
<RiTimeLine className="size-5" />
{meeting.durationMinutes} minutes
</div>
{meeting.language && (
<div className="flex items-center gap-1.25">
<RiFlagLine className="size-5" />
{meeting.language}
</div>
)}
{meeting.conferencing && (
<div className="flex items-center gap-1.25">
<RiVideoLine className="size-5" />
{meeting.conferencing}
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-col gap-3 px-4 pb-3 sm:px-0 sm:pt-2">
<Calendar
mode="single"
selected={pending?.date}
onSelect={(date) =>
date &&
setPending((previous) => ({
date,
time: previous?.time ?? null,
}))
}
showOutsideDays={false}
aria-label={ariaLabel}
formatters={{
formatWeekdayName: (date) => {
const short = new Intl.DateTimeFormat(undefined, {
weekday: "short",
}).format(date);
return /^\p{Script=Latin}/u.test(short)
? short.slice(0, 2)
: new Intl.DateTimeFormat(undefined, {
weekday: "narrow",
}).format(date);
},
}}
/>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 rounded-[10px] border border-border bg-popover px-2 py-2 shadow-xs">
{pending && (
<>
<span>
{new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(pending.date)}
</span>
<span className="text-muted-foreground">
{pending.time ?? "Select a time"}
</span>
</>
)}
</span>
<Button
disabled={
!pending?.time ||
!slotInstant(pending.date, pending.time, hostZone)
}
onClick={() => {
if (value === undefined) {
setInternalValue(pending);
}
onChange?.(pending);
if (pending?.time) {
const instant = slotInstant(
pending.date,
pending.time,
hostZone
);
if (instant) {
onBook?.({ value: pending, instant, timezone: hostZone });
}
}
setOpen(false);
}}
>
Confirm
</Button>
</div>
</div>
<div className="flex min-h-0 flex-col gap-3 px-4 pb-4 sm:h-full sm:w-[150px] sm:shrink-0 sm:overflow-hidden sm:pt-4 sm:pe-4 sm:ps-0">
<div className="flex items-center justify-between">
<div className="flex min-w-0 items-center gap-1.25">
<RiGlobeLine className="size-5 shrink-0" />
<Select
items={zones.map((zone) => ({
value: zone,
label: zone.replaceAll("_", " "),
}))}
value={displayZone}
onValueChange={(zone) => zone && setSelectedZone(zone)}
>
<SelectTrigger
aria-label="Display time zone"
variant="ghost"
className="-mx-2.5 min-w-0 whitespace-normal data-[size=default]:h-auto"
>
<SelectValue>
{(zone: string) =>
(zone.split("/").pop() ?? zone).replaceAll("_", " ")
}
</SelectValue>
</SelectTrigger>
<SelectContent className={popupClassName}>
<SelectGroup>
{zones.map((zone) => (
<SelectItem key={zone} value={zone}>
{zone.replaceAll("_", " ")}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
{pending && (
<Badge variant="subtle">
<span className="mr-0.75">
{new Intl.DateTimeFormat(undefined, {
weekday: "short",
}).format(pending.date)}
</span>
<span className="text-muted-foreground">
{pending.date.getDate()}
</span>
</Badge>
)}
<ToggleGroup
variant="segmented"
value={[use24h ? "24h" : "12h"]}
onValueChange={(next) => setUse24h(next[0] === "24h")}
aria-label="Time format"
>
<ToggleGroupItem value="12h">12h</ToggleGroupItem>
<ToggleGroupItem value="24h">24h</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex max-h-48 min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-3 sm:max-h-none meeting-scheduler-01-fade [mask-image:linear-gradient(to_bottom,transparent,black_16px,black_100%)]">
{slots.map((slot) => {
const instant = pending
? slotInstant(pending.date, slot, hostZone)
: null;
const display = instant
? new Intl.DateTimeFormat(undefined, {
timeZone: displayZone,
hour: "numeric",
minute: "2-digit",
hour12: !use24h,
}).format(instant)
: formatTime(slot, use24h);
const displayDate = instant
? new Intl.DateTimeFormat(undefined, {
timeZone: displayZone,
month: "short",
day: "numeric",
}).format(instant)
: "";
const hostDate = instant
? new Intl.DateTimeFormat(undefined, {
timeZone: hostZone,
month: "short",
day: "numeric",
}).format(instant)
: "";
return (
<Button
variant={pending?.time === slot ? "default" : "ghost"}
key={slot}
aria-pressed={pending?.time === slot}
disabled={!instant}
onClick={() =>
setPending((previous) => ({
date: previous?.date ?? new Date(),
time: slot,
}))
}
className="w-full shrink-0"
>
{display}
{displayDate !== hostDate && ` (${displayDate})`}
</Button>
);
})}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
} On this page