Meeting Scheduler
Date + time-slot scheduler popover.
Scheduler
Date plus time-slot selection.
function MeetingSchedulerDemo() {
return (
<div className="flex flex-wrap items-start gap-4">
<MeetingScheduler host={MEETING_SCHEDULER_HOST} meeting={MEETING_SCHEDULER_MEETING} />
</div>
);
}function MeetingSchedulerDemo() {
return (
<div className="flex flex-wrap items-start gap-4">
<MeetingScheduler host={MEETING_SCHEDULER_HOST} meeting={MEETING_SCHEDULER_MEETING} />
</div>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/meeting-scheduler.jsonnpx shadcn@latest add https://boardcn.dev/r/meeting-scheduler.jsonnpm packages
- @internationalized/date
- @remixicon/react
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Calendar, Dialog, Popover } from "react-aria-components";
import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { RiCalendarLine, RiFlagLine, RiGlobalLine } from "@remixicon/react";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { ChevronDownSmall } from "@/components/foundations/icons/chevrons";
import { MonthPanel, popoverClassName, triggerButtonClassName } from "@/components/base/date-picker/shared";
import { cx } from "@/utils/cx";
import { useDismissOnOutsidePress } from "@/utils/use-dismiss-on-outside-press";
export interface MeetingSchedulerHost {
name: string;
email: string;
avatarInitial: string;
}
export interface MeetingSchedulerDetails {
title: string;
description?: string;
durationMinutes: number;
language?: string;
conferencing?: string;
}
export interface MeetingSchedulerValue {
date: CalendarDate;
/** 24h "HH:mm", or `null` when a date is picked but no time slot yet. */
time: string | null;
}
export interface MeetingSchedulerProps {
host: MeetingSchedulerHost;
meeting: MeetingSchedulerDetails;
timezone?: string;
value?: MeetingSchedulerValue | null;
defaultValue?: MeetingSchedulerValue | null;
onChange?: (value: MeetingSchedulerValue | null) => void;
/** Render the panel open on mount. Default `false`. */
defaultOpen?: boolean;
className?: string;
"aria-label"?: string;
/** Trigger button text. Default "Schedule a meeting". */
triggerLabel?: string;
}
function useTimeSlots() {
return useMemo(() => {
const slots: string[] = [];
for (let minutes = 9 * 60; minutes <= 18 * 60 + 30; minutes += 30) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
slots.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
}
return slots;
}, []);
}
function formatTime(time: string, use24h: boolean) {
const [h, m] = time.split(":").map(Number);
if (use24h) return time;
const period = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 === 0 ? 12 : h % 12;
return `${hour12}:${String(m).padStart(2, "0")} ${period}`;
}
function formatChipDateTime(value: MeetingSchedulerValue) {
const date = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(
value.date.toDate(getLocalTimeZone()),
);
return { date, time: value.time ?? "Select a time" };
}
function InfoRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-center gap-[5px] text-body-medium text-text-primary">
{icon}
{children}
</div>
);
}
function GlyphIcon({ icon: Icon }: { icon: typeof RiGlobalLine }) {
return <Icon className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />;
}
function GoogleMeetIcon() {
return <img src="/brand/google_meet.png" alt="" width={20} height={20} className="size-5 shrink-0" />;
}
function HourFormatToggle({ use24h, onChange }: { use24h: boolean; onChange: (use24h: boolean) => void }) {
return (
<div className="flex shrink-0 items-start gap-0.5 rounded-2lg">
{(["12h", "24h"] as const).map((label, i) => {
const selected = i === 1 ? use24h : !use24h;
return (
<button
key={label}
type="button"
onClick={() => onChange(i === 1)}
className={cx(
"cursor-pointer rounded-md px-2.5 py-1 text-body-medium transition-colors duration-150 ease",
selected ? "bg-background-primary-default text-text-primary shadow-2xs" : "text-text-secondary hover:text-text-primary",
)}
>
{label}
</button>
);
})}
</div>
);
}
export function MeetingScheduler({
host,
meeting,
timezone = "Amsterdam",
value,
defaultValue = null,
onChange,
defaultOpen = false,
className,
"aria-label": ariaLabel = "Schedule meeting",
triggerLabel = "Schedule a meeting",
}: MeetingSchedulerProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLElement>(null);
const timeSlots = useTimeSlots();
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = useState<MeetingSchedulerValue | null>(
defaultValue ?? { date: today(getLocalTimeZone()), time: null },
);
const committedValue = isControlled ? value : internalValue;
const [pendingValue, setPendingValue] = useState<MeetingSchedulerValue | null>(committedValue);
const [use24h, setUse24h] = useState(true);
// Never honor `defaultOpen` on the initial render: the ~700px-wide panel
// being in the very first paint makes mobile Safari compute its layout
// viewport from that width, permanently zooming the whole page out even
// after the panel closes. Open it post-mount instead, and only on
// viewports wide enough to show the panel without overflowing.
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (!defaultOpen) return;
if (window.matchMedia("(min-width: 640px)").matches) {
setIsOpen(true);
}
}, [defaultOpen]);
const commit = (next: MeetingSchedulerValue | null) => {
if (!isControlled) setInternalValue(next);
onChange?.(next);
setIsOpen(false);
};
useDismissOnOutsidePress(isOpen, () => setIsOpen(false), [triggerRef, popoverRef]);
const chip = pendingValue ? formatChipDateTime(pendingValue) : null;
const dayChip = pendingValue
? {
weekday: new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(pendingValue.date.toDate(getLocalTimeZone())).slice(0, 2),
day: pendingValue.date.day,
}
: null;
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => {
if (!isOpen) setPendingValue(committedValue);
setIsOpen((open) => !open);
}}
className={cx(triggerButtonClassName, className)}
>
<RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
<span className="flex items-center justify-center whitespace-nowrap px-1 text-body-medium text-text-primary">
{triggerLabel}
</span>
<ChevronDownSmall
className={cx("size-4 shrink-0 text-text-secondary transition-transform duration-200 ease", isOpen && "rotate-180")}
/>
</button>
<Popover
ref={popoverRef}
triggerRef={triggerRef}
isOpen={isOpen}
onOpenChange={setIsOpen}
placement="bottom end"
offset={8}
isNonModal
className={popoverClassName}
>
<Dialog aria-label={ariaLabel} className="outline-none">
<Calendar
aria-label={ariaLabel}
value={pendingValue?.date ?? null}
onChange={(date) => setPendingValue((prev) => ({ date, time: prev?.time ?? null }))}
>
<div className="flex h-[429px] gap-4">
{/* Host + meeting summary */}
<div className="flex w-[207px] shrink-0 flex-col gap-4 py-4 pl-4">
<div className="flex flex-col items-start gap-2">
<Avatar size="md" color="blue" initials={host.avatarInitial} />
<div className="flex flex-col text-body-medium">
<span className="text-text-primary">{host.name}</span>
<span className="text-text-secondary">{host.email}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-title-3-medium text-text-primary">{meeting.title}</p>
{meeting.description && (
<p className="text-body-2-medium text-text-secondary">{meeting.description}</p>
)}
</div>
<div className="mt-auto flex flex-col gap-3">
<InfoRow icon={<GlyphIcon icon={RiGlobalLine} />}>{meeting.durationMinutes} minutes</InfoRow>
{meeting.language && <InfoRow icon={<GlyphIcon icon={RiFlagLine} />}>{meeting.language}</InfoRow>}
{meeting.conferencing && <InfoRow icon={<GoogleMeetIcon />}>{meeting.conferencing}</InfoRow>}
</div>
</div>
{/* Calendar + footer */}
<div className="flex flex-col gap-3 pt-2 pb-3">
<MonthPanel offset={0} showPrev showNext />
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 rounded-2lg border border-border-button-default bg-background-primary-default px-2 py-2 text-body-medium text-text-primary shadow-xs">
{chip && (
<>
<span>{chip.date}</span>
<span className="text-text-secondary">{chip.time}</span>
</>
)}
</span>
<Button onClick={() => commit(pendingValue)} disabled={!pendingValue?.time}>
Send meeting
</Button>
</div>
</div>
{/* Timezone + hour format + time slots */}
<div className="flex h-full w-[170px] shrink-0 flex-col gap-3 overflow-hidden pt-4 pr-4">
<button
type="button"
className="flex cursor-pointer items-center justify-between rounded-2lg outline-none"
>
<span className="flex items-center gap-[5px] text-body-medium text-text-primary">
<RiGlobalLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
{timezone}
</span>
<ChevronDownSmall className="size-4 shrink-0 text-text-secondary" />
</button>
<div className="flex items-center justify-between">
{dayChip && (
<span className="inline-flex items-center gap-[3px] rounded-2lg bg-background-tertiary-default px-2 py-1 text-body-medium">
<span className="text-text-primary">{dayChip.weekday}</span>
<span className="text-text-secondary">{dayChip.day}</span>
</span>
)}
<HourFormatToggle use24h={use24h} onChange={setUse24h} />
</div>
<div
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-3"
style={{
maskImage: "linear-gradient(to bottom, transparent, black 16px, black 100%)",
WebkitMaskImage: "linear-gradient(to bottom, transparent, black 16px, black 100%)",
}}
>
{timeSlots.map((slot) => (
<button
key={slot}
type="button"
onClick={() =>
setPendingValue((prev) => ({ date: prev?.date ?? today(getLocalTimeZone()), time: slot }))
}
className={cx(
"w-full shrink-0 cursor-pointer rounded-2lg px-2 py-1.5 text-center text-body-medium text-text-primary transition-colors duration-150 ease",
pendingValue?.time === slot
? "bg-background-tertiary-default"
: "hover:bg-background-secondary-hover",
)}
>
{formatTime(slot, use24h)}
</button>
))}
</div>
</div>
</div>
</Calendar>
</Dialog>
</Popover>
</>
);
}"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Calendar, Dialog, Popover } from "react-aria-components";
import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { RiCalendarLine, RiFlagLine, RiGlobalLine } from "@remixicon/react";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { ChevronDownSmall } from "@/components/foundations/icons/chevrons";
import { MonthPanel, popoverClassName, triggerButtonClassName } from "@/components/base/date-picker/shared";
import { cx } from "@/utils/cx";
import { useDismissOnOutsidePress } from "@/utils/use-dismiss-on-outside-press";
export interface MeetingSchedulerHost {
name: string;
email: string;
avatarInitial: string;
}
export interface MeetingSchedulerDetails {
title: string;
description?: string;
durationMinutes: number;
language?: string;
conferencing?: string;
}
export interface MeetingSchedulerValue {
date: CalendarDate;
/** 24h "HH:mm", or `null` when a date is picked but no time slot yet. */
time: string | null;
}
export interface MeetingSchedulerProps {
host: MeetingSchedulerHost;
meeting: MeetingSchedulerDetails;
timezone?: string;
value?: MeetingSchedulerValue | null;
defaultValue?: MeetingSchedulerValue | null;
onChange?: (value: MeetingSchedulerValue | null) => void;
/** Render the panel open on mount. Default `false`. */
defaultOpen?: boolean;
className?: string;
"aria-label"?: string;
/** Trigger button text. Default "Schedule a meeting". */
triggerLabel?: string;
}
function useTimeSlots() {
return useMemo(() => {
const slots: string[] = [];
for (let minutes = 9 * 60; minutes <= 18 * 60 + 30; minutes += 30) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
slots.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
}
return slots;
}, []);
}
function formatTime(time: string, use24h: boolean) {
const [h, m] = time.split(":").map(Number);
if (use24h) return time;
const period = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 === 0 ? 12 : h % 12;
return `${hour12}:${String(m).padStart(2, "0")} ${period}`;
}
function formatChipDateTime(value: MeetingSchedulerValue) {
const date = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(
value.date.toDate(getLocalTimeZone()),
);
return { date, time: value.time ?? "Select a time" };
}
function InfoRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-center gap-[5px] text-body-medium text-text-primary">
{icon}
{children}
</div>
);
}
function GlyphIcon({ icon: Icon }: { icon: typeof RiGlobalLine }) {
return <Icon className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />;
}
function GoogleMeetIcon() {
return <img src="/brand/google_meet.png" alt="" width={20} height={20} className="size-5 shrink-0" />;
}
function HourFormatToggle({ use24h, onChange }: { use24h: boolean; onChange: (use24h: boolean) => void }) {
return (
<div className="flex shrink-0 items-start gap-0.5 rounded-2lg">
{(["12h", "24h"] as const).map((label, i) => {
const selected = i === 1 ? use24h : !use24h;
return (
<button
key={label}
type="button"
onClick={() => onChange(i === 1)}
className={cx(
"cursor-pointer rounded-md px-2.5 py-1 text-body-medium transition-colors duration-150 ease",
selected ? "bg-background-primary-default text-text-primary shadow-2xs" : "text-text-secondary hover:text-text-primary",
)}
>
{label}
</button>
);
})}
</div>
);
}
export function MeetingScheduler({
host,
meeting,
timezone = "Amsterdam",
value,
defaultValue = null,
onChange,
defaultOpen = false,
className,
"aria-label": ariaLabel = "Schedule meeting",
triggerLabel = "Schedule a meeting",
}: MeetingSchedulerProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLElement>(null);
const timeSlots = useTimeSlots();
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = useState<MeetingSchedulerValue | null>(
defaultValue ?? { date: today(getLocalTimeZone()), time: null },
);
const committedValue = isControlled ? value : internalValue;
const [pendingValue, setPendingValue] = useState<MeetingSchedulerValue | null>(committedValue);
const [use24h, setUse24h] = useState(true);
// Never honor `defaultOpen` on the initial render: the ~700px-wide panel
// being in the very first paint makes mobile Safari compute its layout
// viewport from that width, permanently zooming the whole page out even
// after the panel closes. Open it post-mount instead, and only on
// viewports wide enough to show the panel without overflowing.
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (!defaultOpen) return;
if (window.matchMedia("(min-width: 640px)").matches) {
setIsOpen(true);
}
}, [defaultOpen]);
const commit = (next: MeetingSchedulerValue | null) => {
if (!isControlled) setInternalValue(next);
onChange?.(next);
setIsOpen(false);
};
useDismissOnOutsidePress(isOpen, () => setIsOpen(false), [triggerRef, popoverRef]);
const chip = pendingValue ? formatChipDateTime(pendingValue) : null;
const dayChip = pendingValue
? {
weekday: new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(pendingValue.date.toDate(getLocalTimeZone())).slice(0, 2),
day: pendingValue.date.day,
}
: null;
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => {
if (!isOpen) setPendingValue(committedValue);
setIsOpen((open) => !open);
}}
className={cx(triggerButtonClassName, className)}
>
<RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
<span className="flex items-center justify-center whitespace-nowrap px-1 text-body-medium text-text-primary">
{triggerLabel}
</span>
<ChevronDownSmall
className={cx("size-4 shrink-0 text-text-secondary transition-transform duration-200 ease", isOpen && "rotate-180")}
/>
</button>
<Popover
ref={popoverRef}
triggerRef={triggerRef}
isOpen={isOpen}
onOpenChange={setIsOpen}
placement="bottom end"
offset={8}
isNonModal
className={popoverClassName}
>
<Dialog aria-label={ariaLabel} className="outline-none">
<Calendar
aria-label={ariaLabel}
value={pendingValue?.date ?? null}
onChange={(date) => setPendingValue((prev) => ({ date, time: prev?.time ?? null }))}
>
<div className="flex h-[429px] gap-4">
{/* Host + meeting summary */}
<div className="flex w-[207px] shrink-0 flex-col gap-4 py-4 pl-4">
<div className="flex flex-col items-start gap-2">
<Avatar size="md" color="blue" initials={host.avatarInitial} />
<div className="flex flex-col text-body-medium">
<span className="text-text-primary">{host.name}</span>
<span className="text-text-secondary">{host.email}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-title-3-medium text-text-primary">{meeting.title}</p>
{meeting.description && (
<p className="text-body-2-medium text-text-secondary">{meeting.description}</p>
)}
</div>
<div className="mt-auto flex flex-col gap-3">
<InfoRow icon={<GlyphIcon icon={RiGlobalLine} />}>{meeting.durationMinutes} minutes</InfoRow>
{meeting.language && <InfoRow icon={<GlyphIcon icon={RiFlagLine} />}>{meeting.language}</InfoRow>}
{meeting.conferencing && <InfoRow icon={<GoogleMeetIcon />}>{meeting.conferencing}</InfoRow>}
</div>
</div>
{/* Calendar + footer */}
<div className="flex flex-col gap-3 pt-2 pb-3">
<MonthPanel offset={0} showPrev showNext />
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 rounded-2lg border border-border-button-default bg-background-primary-default px-2 py-2 text-body-medium text-text-primary shadow-xs">
{chip && (
<>
<span>{chip.date}</span>
<span className="text-text-secondary">{chip.time}</span>
</>
)}
</span>
<Button onClick={() => commit(pendingValue)} disabled={!pendingValue?.time}>
Send meeting
</Button>
</div>
</div>
{/* Timezone + hour format + time slots */}
<div className="flex h-full w-[170px] shrink-0 flex-col gap-3 overflow-hidden pt-4 pr-4">
<button
type="button"
className="flex cursor-pointer items-center justify-between rounded-2lg outline-none"
>
<span className="flex items-center gap-[5px] text-body-medium text-text-primary">
<RiGlobalLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
{timezone}
</span>
<ChevronDownSmall className="size-4 shrink-0 text-text-secondary" />
</button>
<div className="flex items-center justify-between">
{dayChip && (
<span className="inline-flex items-center gap-[3px] rounded-2lg bg-background-tertiary-default px-2 py-1 text-body-medium">
<span className="text-text-primary">{dayChip.weekday}</span>
<span className="text-text-secondary">{dayChip.day}</span>
</span>
)}
<HourFormatToggle use24h={use24h} onChange={setUse24h} />
</div>
<div
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-3"
style={{
maskImage: "linear-gradient(to bottom, transparent, black 16px, black 100%)",
WebkitMaskImage: "linear-gradient(to bottom, transparent, black 16px, black 100%)",
}}
>
{timeSlots.map((slot) => (
<button
key={slot}
type="button"
onClick={() =>
setPendingValue((prev) => ({ date: prev?.date ?? today(getLocalTimeZone()), time: slot }))
}
className={cx(
"w-full shrink-0 cursor-pointer rounded-2lg px-2 py-1.5 text-center text-body-medium text-text-primary transition-colors duration-150 ease",
pendingValue?.time === slot
? "bg-background-tertiary-default"
: "hover:bg-background-secondary-hover",
)}
>
{formatTime(slot, use24h)}
</button>
))}
</div>
</div>
</div>
</Calendar>
</Dialog>
</Popover>
</>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
MeetingScheduler
| Prop | Type | Default | Description |
|---|---|---|---|
| hostrequired | MeetingSchedulerHost | — | — |
| meetingrequired | MeetingSchedulerDetails | — | — |
| aria-label | string | Schedule meeting | — |
| className | string | — | — |
| defaultOpen | boolean | false | Render the panel open on mount. Default `false`. |
| defaultValue | MeetingSchedulerValue | null | — |
| onChange | (value: MeetingSchedulerValue) => void | — | — |
| timezone | string | Amsterdam | — |
| triggerLabel | string | Schedule a meeting | Trigger button text. Default "Schedule a meeting". |
| value | MeetingSchedulerValue | — | — |