Social Schedule Calendar
Full-page FullCalendar block for scheduling social media posts and campaign events.
"use client";
import * as React from "react";
import { formDataToScheduledPost, SocialScheduleCalendarPage, type SocialPostFormData, type SocialScheduledPost,} from "@/components/ui/social-schedule-calendar";
const SEED_POSTS: SocialScheduledPost[] = [ { id: "1", title: "Product launch reel", start: "2026-05-23T09:00:00", end: "2026-05-23T09:30:00", platform: "instagram", status: "scheduled", campaign: "Q2 Launch", caption: "Introducing our new feature set — swipe to explore.", }, { id: "2", title: "Founder AMA thread", start: "2026-05-23T14:00:00", platform: "x", status: "scheduled", campaign: "Community", }, { id: "3", title: "Hiring spotlight", start: "2026-05-24T10:00:00", platform: "linkedin", status: "draft", caption: "We're growing the engineering team.", }, { id: "4", title: "Weekend promo carousel", start: "2026-05-25T11:00:00", end: "2026-05-25T11:15:00", platform: "facebook", status: "scheduled", campaign: "Weekend Sale", }, { id: "5", title: "Behind the scenes", start: "2026-05-26T18:00:00", platform: "tiktok", status: "scheduled", }, { id: "6", title: "Newsletter teaser", start: "2026-05-27T08:30:00", platform: "x", status: "published", }, { id: "7", title: "Stories — flash sale", start: "2026-05-28T16:00:00", platform: "instagram", status: "failed", campaign: "Flash Sale", }, { id: "8", title: "Customer testimonial", start: "2026-05-29T12:00:00", platform: "linkedin", status: "scheduled", }, { id: "9", title: "Live event reminder", start: "2026-05-30T15:00:00", end: "2026-05-30T16:00:00", platform: "facebook", status: "scheduled", campaign: "Webinar", },];
export function SocialScheduleCalendarBasic() { const [posts, setPosts] = React.useState(SEED_POSTS);
const handleSave = async (data: SocialPostFormData, id?: string) => { const next = formDataToScheduledPost(data, id); setPosts((current) => { if (id) { return current.map((post) => (post.id === id ? next : post)); } return [...current, next]; }); };
const handleDelete = async (id: string) => { setPosts((current) => current.filter((post) => post.id !== id)); };
return ( <div className="w-full max-w-6xl p-4"> <SocialScheduleCalendarPage posts={posts} onSavePost={handleSave} onDeletePost={handleDelete} /> </div> );}Installation
Section titled “Installation”This installation provides support for all official Shadcn icon libraries. The component is otherwise identical to the non-experimental installation.
Icon support is experimental and may not be fully stable since it uses internal Shadcn APIs.
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client";
import "../styles/calendar.css";
import type { EventClickArg, EventInput } from "@fullcalendar/core";import enLocale from "@fullcalendar/core/locales/en-gb";import dayGridPlugin from "@fullcalendar/daygrid";import interactionPlugin from "@fullcalendar/interaction";import listPlugin from "@fullcalendar/list";import FullCalendar from "@fullcalendar/react";import timeGridPlugin from "@fullcalendar/timegrid";import { format, parseISO } from "date-fns";import { CalendarPlus, Megaphone, Pencil, Trash2 } from "lucide-react";import * as React from "react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,} from "@/components/ui/alert-dialog";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Card } from "@/components/ui/card";import { Input } from "@/components/ui/input";import { Label } from "@/components/ui/label";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select";import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@/components/ui/sheet";import { Textarea } from "@/components/ui/textarea";import { cn } from "@/lib/utils";
export type SocialPlatform = | "instagram" | "facebook" | "linkedin" | "x" | "tiktok";
export type SocialPostStatus = "draft" | "scheduled" | "published" | "failed";
export interface SocialScheduledPost { id: string; title: string; /** ISO 8601 datetime, e.g. `2026-05-23T09:00:00` */ start: string; end?: string; platform: SocialPlatform; status: SocialPostStatus; caption?: string; campaign?: string;}
export interface SocialPostFormData { title: string; date: string; startTime: string; endTime?: string; platform: SocialPlatform; status: SocialPostStatus; caption?: string; campaign?: string;}
export interface ScheduleSheetRenderProps { open: boolean; onClose: () => void; onSave: (data: SocialPostFormData) => void | Promise<void>; defaultValues?: SocialScheduledPost; mode: "create" | "edit";}
export interface SocialScheduleCalendarPageProps { posts: SocialScheduledPost[]; className?: string; pageTitle?: string; pageDescription?: string; maxEventsPerDay?: number; locale?: typeof enLocale; initialView?: "dayGridMonth" | "timeGridWeek" | "timeGridDay" | "listWeek"; aspectRatio?: number; slotMinTime?: string; slotMaxTime?: string; slotDuration?: string; editable?: boolean; selectable?: boolean; weekends?: boolean; nowIndicator?: boolean; showScheduleButton?: boolean; scheduleSheetOpen?: boolean; onScheduleSheetOpenChange?: (open: boolean) => void; onSavePost?: (data: SocialPostFormData, id?: string) => void | Promise<void>; onDeletePost?: (id: string) => void | Promise<void>; onEventClick?: (post: SocialScheduledPost, arg: EventClickArg) => void; renderScheduleSheet?: (props: ScheduleSheetRenderProps) => React.ReactNode;}
const PLATFORMS: SocialPlatform[] = [ "instagram", "facebook", "linkedin", "x", "tiktok",];
const STATUSES: SocialPostStatus[] = [ "draft", "scheduled", "published", "failed",];
const PLATFORM_LABELS: Record<SocialPlatform, string> = { instagram: "Instagram", facebook: "Facebook", linkedin: "LinkedIn", x: "X", tiktok: "TikTok",};
const STATUS_VARIANT: Record< SocialPostStatus, "default" | "secondary" | "outline" | "destructive"> = { draft: "secondary", scheduled: "default", published: "outline", failed: "destructive",};
type SheetMode = | { type: "idle" } | { type: "create" } | { type: "edit"; post: SocialScheduledPost };
function formatEventTime(date: Date | null) { if (!date) return "—"; return format(date, "HH:mm");}
function splitIsoDatetime(iso: string) { const [date, timePart] = iso.split("T"); const startTime = timePart?.slice(0, 5) ?? "09:00"; return { date: date ?? "", startTime };}
function postToFormData(post: SocialScheduledPost): SocialPostFormData { const { date, startTime } = splitIsoDatetime(post.start); const endTime = post.end ? splitIsoDatetime(post.end).startTime : ""; return { title: post.title, date, startTime, endTime: endTime || undefined, platform: post.platform, status: post.status, caption: post.caption ?? "", campaign: post.campaign ?? "", };}
function formDataToIso(data: SocialPostFormData) { const start = `${data.date}T${data.startTime}:00`; const end = data.endTime && data.endTime.length > 0 ? `${data.date}T${data.endTime}:00` : undefined; return { start, end };}
function emptyFormData(): SocialPostFormData { const today = format(new Date(), "yyyy-MM-dd"); return { title: "", date: today, startTime: "09:00", endTime: "", platform: "instagram", status: "scheduled", caption: "", campaign: "", };}
function postToEventDetails( post: SocialScheduledPost, start: Date | null, end: Date | null,) { return { id: post.id, title: post.title, start, end, platform: post.platform, status: post.status, caption: post.caption, campaign: post.campaign, };}
function toCalendarEvents(posts: SocialScheduledPost[]): EventInput[] { return posts.map((post) => ({ id: post.id, title: post.title, start: post.start, end: post.end, classNames: [ `fc-event-platform-${post.platform}`, `fc-event-status-${post.status}`, ], extendedProps: { platform: post.platform, status: post.status, caption: post.caption, campaign: post.campaign, postId: post.id, }, }));}
function findPostById( posts: SocialScheduledPost[], id: string | undefined,): SocialScheduledPost | undefined { if (!id) return undefined; return posts.find((post) => post.id === id);}
function SchedulePostSheet({ open, onOpenChange, mode, defaultValues, onSave, saving,}: { open: boolean; onOpenChange: (open: boolean) => void; mode: "create" | "edit"; defaultValues?: SocialScheduledPost; onSave: (data: SocialPostFormData) => void | Promise<void>; saving?: boolean;}) { const [form, setForm] = React.useState<SocialPostFormData>(emptyFormData);
React.useEffect(() => { if (!open) return; setForm(defaultValues ? postToFormData(defaultValues) : emptyFormData()); }, [open, defaultValues]);
const update = <K extends keyof SocialPostFormData>( key: K, value: SocialPostFormData[K], ) => { setForm((prev) => ({ ...prev, [key]: value })); };
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!form.title.trim() || !form.date || !form.startTime) return; await onSave({ ...form, title: form.title.trim(), caption: form.caption?.trim() || undefined, campaign: form.campaign?.trim() || undefined, endTime: form.endTime?.trim() || undefined, }); };
return ( <Sheet open={open} onOpenChange={onOpenChange}> <SheetContent className="flex w-full flex-col sm:max-w-md"> <SheetHeader> <SheetTitle> {mode === "create" ? "Schedule post" : "Edit post"} </SheetTitle> <SheetDescription> {mode === "create" ? "Add a new social post or campaign event to the calendar." : "Update this scheduled post."} </SheetDescription> </SheetHeader> <form id="social-schedule-form" onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 overflow-y-auto px-1 py-4" > <div className="space-y-2"> <Label htmlFor="post-title">Title</Label> <Input id="post-title" value={form.title} onChange={(e) => update("title", e.target.value)} placeholder="Product launch reel" required /> </div> <div className="grid grid-cols-2 gap-3"> <div className="space-y-2"> <Label htmlFor="post-platform">Platform</Label> <Select value={form.platform} onValueChange={(v) => update("platform", v as SocialPlatform)} > <SelectTrigger id="post-platform" className="w-full"> <SelectValue /> </SelectTrigger> <SelectContent> {PLATFORMS.map((p) => ( <SelectItem key={p} value={p}> {PLATFORM_LABELS[p]} </SelectItem> ))} </SelectContent> </Select> </div> <div className="space-y-2"> <Label htmlFor="post-status">Status</Label> <Select value={form.status} onValueChange={(v) => update("status", v as SocialPostStatus)} > <SelectTrigger id="post-status" className="w-full"> <SelectValue /> </SelectTrigger> <SelectContent> {STATUSES.map((s) => ( <SelectItem key={s} value={s}> {s} </SelectItem> ))} </SelectContent> </Select> </div> </div> <div className="space-y-2"> <Label htmlFor="post-date">Date</Label> <Input id="post-date" type="date" value={form.date} onChange={(e) => update("date", e.target.value)} required /> </div> <div className="grid grid-cols-2 gap-3"> <div className="space-y-2"> <Label htmlFor="post-start">Start time</Label> <Input id="post-start" type="time" value={form.startTime} onChange={(e) => update("startTime", e.target.value)} required /> </div> <div className="space-y-2"> <Label htmlFor="post-end">End time</Label> <Input id="post-end" type="time" value={form.endTime ?? ""} onChange={(e) => update("endTime", e.target.value)} /> </div> </div> <div className="space-y-2"> <Label htmlFor="post-campaign">Campaign</Label> <Input id="post-campaign" value={form.campaign ?? ""} onChange={(e) => update("campaign", e.target.value)} placeholder="Q2 Launch" /> </div> <div className="space-y-2"> <Label htmlFor="post-caption">Caption</Label> <Textarea id="post-caption" value={form.caption ?? ""} onChange={(e) => update("caption", e.target.value)} placeholder="Write your post copy…" rows={4} /> </div> </form> <SheetFooter className="gap-2 sm:gap-0"> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving} > Cancel </Button> <Button type="submit" form="social-schedule-form" disabled={saving}> {saving ? "Saving…" : mode === "create" ? "Schedule post" : "Save changes"} </Button> </SheetFooter> </SheetContent> </Sheet> );}
function EventDetailsSheet({ open, onOpenChange, event, onEdit, onDelete,}: { open: boolean; onOpenChange: (open: boolean) => void; event: ReturnType<typeof postToEventDetails> | null; onEdit?: () => void; onDelete?: () => void;}) { if (!event) return null;
return ( <Sheet open={open} onOpenChange={onOpenChange}> <SheetContent className="flex w-full flex-col sm:max-w-md"> <SheetHeader> <SheetTitle>{event.title}</SheetTitle> <SheetDescription> {event.start ? format(event.start, "EEEE, MMMM d, yyyy") : "No date"} </SheetDescription> </SheetHeader> <div className="flex-1 space-y-4 py-4 text-sm"> <div className="flex flex-wrap gap-2"> <Badge variant="outline">{PLATFORM_LABELS[event.platform]}</Badge> <Badge variant={STATUS_VARIANT[event.status]}>{event.status}</Badge> {event.campaign ? ( <Badge variant="secondary">{event.campaign}</Badge> ) : null} </div> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2"> <dt className="text-muted-foreground">Time</dt> <dd> {formatEventTime(event.start)} {event.end ? ` – ${formatEventTime(event.end)}` : ""} </dd> {event.caption ? ( <> <dt className="text-muted-foreground">Caption</dt> <dd className="text-foreground">{event.caption}</dd> </> ) : null} </dl> </div> {(onEdit || onDelete) && ( <SheetFooter className="gap-2 sm:gap-0"> {onDelete ? ( <Button type="button" variant="outline" onClick={onDelete}> <Trash2 className="size-4" /> Delete </Button> ) : null} {onEdit ? ( <Button type="button" onClick={onEdit}> <Pencil className="size-4" /> Edit </Button> ) : null} </SheetFooter> )} </SheetContent> </Sheet> );}
export function SocialScheduleCalendarPage({ posts, className, pageTitle = "Content calendar", pageDescription = "Plan and review scheduled social posts and campaign events.", maxEventsPerDay = 4, locale = enLocale, initialView = "dayGridMonth", aspectRatio = 1.6, slotMinTime = "06:00:00", slotMaxTime = "23:00:00", slotDuration = "00:30:00", editable = false, selectable = false, weekends = true, nowIndicator = true, showScheduleButton = true, scheduleSheetOpen: scheduleSheetOpenProp, onScheduleSheetOpenChange, onSavePost, onDeletePost, onEventClick, renderScheduleSheet,}: SocialScheduleCalendarPageProps) { const [sheetMode, setSheetMode] = React.useState<SheetMode>({ type: "idle" }); const [internalScheduleOpen, setInternalScheduleOpen] = React.useState(false); const [detailsOpen, setDetailsOpen] = React.useState(false); const [selectedPost, setSelectedPost] = React.useState<SocialScheduledPost | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<SocialScheduledPost | null>(null); const [saving, setSaving] = React.useState(false);
const scheduleOpen = scheduleSheetOpenProp ?? internalScheduleOpen;
const setScheduleOpen = (open: boolean) => { if (scheduleSheetOpenProp === undefined) { setInternalScheduleOpen(open); } onScheduleSheetOpenChange?.(open); if (!open) setSheetMode({ type: "idle" }); };
const openCreateSheet = () => { setDetailsOpen(false); setSheetMode({ type: "create" }); setScheduleOpen(true); };
const openEditSheet = (post: SocialScheduledPost) => { setDetailsOpen(false); setSheetMode({ type: "edit", post }); setScheduleOpen(true); };
const scheduledCount = posts.filter((p) => p.status === "scheduled").length; const draftCount = posts.filter((p) => p.status === "draft").length; const publishedCount = posts.filter((p) => p.status === "published").length;
const handleEventClick = (arg: EventClickArg) => { const post = findPostById(posts, arg.event.id); if (post) { setSelectedPost(post); setDetailsOpen(true); onEventClick?.(post, arg); } };
const handleSaveForm = async (data: SocialPostFormData) => { if (!onSavePost) return; setSaving(true); try { const id = sheetMode.type === "edit" ? sheetMode.post.id : undefined; await onSavePost(data, id); setScheduleOpen(false); } finally { setSaving(false); } };
const handleConfirmDelete = async () => { if (!deleteTarget || !onDeletePost) return; setSaving(true); try { await onDeletePost(deleteTarget.id); setDeleteTarget(null); setDetailsOpen(false); setSelectedPost(null); } finally { setSaving(false); } };
const selectedEventDetails = selectedPost ? postToEventDetails( selectedPost, selectedPost.start ? parseISO(selectedPost.start) : null, selectedPost.end ? parseISO(selectedPost.end) : null, ) : null;
const scheduleSheetProps: ScheduleSheetRenderProps = { open: scheduleOpen, onClose: () => setScheduleOpen(false), onSave: handleSaveForm, defaultValues: sheetMode.type === "edit" ? sheetMode.post : undefined, mode: sheetMode.type === "edit" ? "edit" : "create", };
return ( <div className={cn( "flex min-h-[min(720px,100vh)] w-full flex-col gap-4", className, )} > <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="space-y-1"> <div className="flex items-center gap-2"> <Megaphone className="size-5 text-primary" aria-hidden /> <h1 className="text-2xl font-semibold tracking-tight"> {pageTitle} </h1> </div> <p className="text-sm text-muted-foreground">{pageDescription}</p> <div className="flex flex-wrap gap-2 pt-1"> <Badge variant="secondary">{scheduledCount} scheduled</Badge> <Badge variant="outline">{draftCount} drafts</Badge> <Badge variant="outline">{publishedCount} published</Badge> <Badge variant="outline">{posts.length} total</Badge> </div> </div> {showScheduleButton ? ( <Button type="button" onClick={openCreateSheet} disabled={!onSavePost && !renderScheduleSheet} > <CalendarPlus className="size-4" /> Schedule post </Button> ) : null} </header>
<Card className="flex min-h-[560px] flex-1 flex-col border bg-card p-4"> <FullCalendar plugins={[ dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, ]} initialView={initialView} headerToolbar={{ left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek", }} views={{ dayGridMonth: { titleFormat: { year: "numeric", month: "long" }, dayMaxEvents: maxEventsPerDay, eventLimitClick: "popover", }, timeGridWeek: { titleFormat: { year: "numeric", month: "long", day: "2-digit" }, }, timeGridDay: { titleFormat: { year: "numeric", month: "long", day: "2-digit" }, }, listWeek: { titleFormat: { year: "numeric", month: "long" }, }, }} locale={locale} events={toCalendarEvents(posts)} eventClick={handleEventClick} height="auto" aspectRatio={aspectRatio} slotLabelFormat={{ hour: "numeric", minute: "2-digit", hour12: false, }} eventTimeFormat={{ hour: "numeric", minute: "2-digit", hour12: false, }} slotMinTime={slotMinTime} slotMaxTime={slotMaxTime} slotDuration={slotDuration} allDaySlot={false} editable={editable} selectable={selectable} weekends={weekends} nowIndicator={nowIndicator} eventContent={(arg) => { if (arg.view.type === "dayGridMonth") { return { html: `<div class="fc-event-main"><div class="fc-event-title">${arg.event.title}</div></div>`, }; } return true; }} /> </Card>
<EventDetailsSheet open={detailsOpen} onOpenChange={setDetailsOpen} event={selectedEventDetails} onEdit={ selectedPost && onSavePost ? () => openEditSheet(selectedPost) : undefined } onDelete={ selectedPost && onDeletePost ? () => { setDeleteTarget(selectedPost); } : undefined } />
{renderScheduleSheet ? ( renderScheduleSheet(scheduleSheetProps) ) : ( <SchedulePostSheet open={scheduleOpen} onOpenChange={setScheduleOpen} mode={scheduleSheetProps.mode} defaultValues={scheduleSheetProps.defaultValues} onSave={handleSaveForm} saving={saving} /> )}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }} > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete scheduled post?</AlertDialogTitle> <AlertDialogDescription> {deleteTarget ? `"${deleteTarget.title}" will be removed from the calendar. This cannot be undone.` : ""} </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel disabled={saving}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={handleConfirmDelete} disabled={saving} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {saving ? "Deleting…" : "Delete"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> );}
export function formDataToScheduledPost( data: SocialPostFormData, id?: string,): SocialScheduledPost { const { start, end } = formDataToIso(data); return { id: id ?? crypto.randomUUID(), title: data.title, start, end, platform: data.platform, status: data.status, caption: data.caption, campaign: data.campaign, };}th.fc-col-header-cell { border-width: 1px; border-color: hsl(var(--border)); background-color: hsl(var(--secondary)); padding: 0.25rem;}
.fc .fc-event { background-color: hsl(var(--primary)); border-width: 1px; border-color: hsl(var(--primary));}
.fc-timegrid-axis { background-color: hsl(var(--secondary));}
.fc-popover { z-index: 10; max-width: 28rem; border-radius: 0.5rem; background-color: hsl(var(--secondary)) !important;}
.fc-popover-body { background-color: hsl(var(--secondary));}
.fc { --fc-border-color: hsl(var(--border)); --fc-button-text-color: hsl(var(--primary-foreground)); --fc-button-bg-color: hsl(var(--primary)); --fc-button-border-color: hsl(var(--primary)); --fc-button-hover-bg-color: hsl(var(--primary) / 0.9); --fc-button-hover-border-color: hsl(var(--primary) / 0.9); --fc-button-active-bg-color: hsl(var(--primary) / 0.9); --fc-button-active-border-color: hsl(var(--primary) / 0.9); --fc-event-bg-color: hsl(var(--primary)); --fc-event-border-color: hsl(var(--primary)); --fc-page-bg-color: transparent; --fc-neutral-bg-color: hsl(var(--muted)); --fc-neutral-text-color: hsl(var(--muted-foreground)); --fc-today-bg-color: hsl(var(--primary) / 0.1); height: 100%; position: relative; width: 100%;}
.fc .fc-toolbar { padding: 0.5rem 0;}
.fc .fc-toolbar-title { color: hsl(var(--foreground)); font-size: 1.25rem; font-weight: 600;}
.fc .fc-button { font-weight: 500; border-radius: 0.375rem; height: 2.25rem; padding-left: 1rem; padding-right: 1rem; font-size: 0.875rem; line-height: 1.25rem; text-transform: none; transition: background-color 0.2s;}
.fc .fc-button:focus { box-shadow: none; outline: 2px solid transparent; outline-offset: 2px;}
.fc .fc-button:disabled { opacity: 0.5; cursor: not-allowed;}
.fc .fc-list-event { background-color: hsl(var(--primary)); border-color: hsl(var(--primary)); border-radius: 0.25rem; padding: 0.125rem 0.25rem; font-size: 0.75rem; font-weight: 500; cursor: pointer; transition: opacity 0.2s; overflow: hidden; text-overflow: ellipsis; color: hsl(var(--primary-foreground));}
.fc .fc-list-event:hover { opacity: 0.9;}
.fc .fc-daygrid-day.fc-day-today { background-color: hsl(var(--accent) / 0.1);}
.fc .fc-col-header-cell { padding: 0.5rem; font-weight: 500; color: hsl(var(--muted-foreground));}
.fc .fc-scrollgrid { border: none !important;}
.fc .fc-scrollgrid td:last-of-type { border-right: none;}
.fc .fc-scrollgrid tr:last-child td { border-bottom: none;}
.fc .fc-daygrid-day-frame { padding: 0.25rem;}
.fc .fc-daygrid-day-number { color: hsl(var(--foreground)); padding: 0.25rem 0.5rem; font-size: 0.875rem;}
.fc .fc-event { border-radius: 0.25rem; padding: 0.125rem 0.25rem; font-size: 0.75rem; font-weight: 500; cursor: pointer; transition: opacity 0.2s; color: hsl(var(--primary-foreground)); background-color: hsl(var(--primary)); overflow: hidden; text-overflow: ellipsis;}
.fc .fc-event:hover { opacity: 0.9;}
.fc .fc-timegrid-slot-label { color: hsl(var(--muted-foreground)); font-size: 0.75rem;}
.fc .fc-timegrid-axis { border-right: none;}
.fc .fc-timegrid-now-indicator-line { border-color: hsl(var(--destructive));}
.fc .fc-timegrid-now-indicator-arrow { border-color: hsl(var(--destructive));}
.fc-event-platform-instagram { --fc-event-bg-color: #e1306c; --fc-event-border-color: #e1306c; background-color: #e1306c !important; border-color: #e1306c !important;}
.fc-event-platform-facebook { --fc-event-bg-color: #1877f2; --fc-event-border-color: #1877f2; background-color: #1877f2 !important; border-color: #1877f2 !important;}
.fc-event-platform-linkedin { --fc-event-bg-color: #0a66c2; --fc-event-border-color: #0a66c2; background-color: #0a66c2 !important; border-color: #0a66c2 !important;}
.fc-event-platform-x { --fc-event-bg-color: #1d9bf0; --fc-event-border-color: #1d9bf0; background-color: #1d9bf0 !important; border-color: #1d9bf0 !important;}
.fc-event-platform-tiktok { --fc-event-bg-color: #010101; --fc-event-border-color: #25f4ee; background-color: #010101 !important; border-color: #25f4ee !important;}
.fc-event-status-draft { opacity: 0.55;}
.fc-event-status-failed { --fc-event-bg-color: hsl(var(--destructive)); --fc-event-border-color: hsl(var(--destructive)); background-color: hsl(var(--destructive)) !important; border-color: hsl(var(--destructive)) !important;}
@media (max-width: 640px) { .fc .fc-toolbar { display: flex; flex-direction: column; gap: 1rem; padding: 1rem 0; }
.fc .fc-toolbar-chunk { display: flex; gap: 0.5rem; justify-content: center; }
.fc .fc-button { padding: 0.5rem; }}Update the import paths to match your project setup.
Install the block and import the theme overrides in your global CSS (FullCalendar v6 injects its base styles automatically):
npx shadcn@latest add https://woxcn-registry.woxware.io/r/social-schedule-calendar.json@import "@/styles/social-schedule-calendar.css";Wire posts and onSavePost so Schedule post opens a built-in sheet and persists new/edited events. Optional onDeletePost enables delete from the event details sheet.
"use client";
import { useState } from "react";import { SocialScheduleCalendarPage, formDataToScheduledPost, type SocialPostFormData, type SocialScheduledPost,} from "@/components/blocks/social-schedule-calendar";
export default function CalendarRoute() { const [posts, setPosts] = useState<SocialScheduledPost[]>([]);
const handleSave = async (data: SocialPostFormData, id?: string) => { const next = formDataToScheduledPost(data, id); setPosts((current) => id ? current.map((p) => (p.id === id ? next : p)) : [...current, next], ); // await api.posts.upsert(next); };
const handleDelete = async (id: string) => { setPosts((current) => current.filter((p) => p.id !== id)); // await api.posts.delete(id); };
return ( <SocialScheduleCalendarPage posts={posts} onSavePost={handleSave} onDeletePost={handleDelete} /> );}Click any calendar event to view details. Use Edit or Delete when the corresponding handlers are provided.
Controlled schedule sheet
Section titled “Controlled schedule sheet”Manage sheet visibility from the parent (e.g. open from another button elsewhere on the page):
const [open, setOpen] = useState(false);
<SocialScheduleCalendarPage posts={posts} scheduleSheetOpen={open} onScheduleSheetOpenChange={setOpen} onSavePost={handleSave}/>Override the schedule form
Section titled “Override the schedule form”<SocialScheduleCalendarPage posts={posts} onSavePost={handleSave} renderScheduleSheet={({ open, onClose, onSave, defaultValues, mode }) => ( <MyCustomPostForm open={open} onClose={onClose} onSubmit={onSave} defaultValues={defaultValues} mode={mode} /> )}/>| Prop | Type | Default | Description |
|---|---|---|---|
posts | SocialScheduledPost[] | — | Required. Posts shown on the calendar |
onSavePost | (data, id?) => void | Promise<void> | — | Persists create (id omitted) or edit (id set). Enables schedule sheet + Edit |
onDeletePost | (id) => void | Promise<void> | — | Removes a post. Enables Delete on event details |
onEventClick | (post, arg) => void | — | Fired when a calendar event is clicked |
className | string | — | Root wrapper classes |
pageTitle | string | "Content calendar" | Page heading |
pageDescription | string | (see component) | Subtitle under the title |
maxEventsPerDay | number | 4 | Day-grid overflow limit before “+N more” |
locale | FullCalendar locale | en-gb | Calendar locale |
initialView | "dayGridMonth" | "timeGridWeek" | "timeGridDay" | "listWeek" | "dayGridMonth" | Starting view |
aspectRatio | number | 1.6 | Calendar width/height ratio |
slotMinTime | string | "06:00:00" | Earliest time in week/day views |
slotMaxTime | string | "23:00:00" | Latest time in week/day views |
slotDuration | string | "00:30:00" | Slot height in time views |
editable | boolean | false | Drag-and-drop events |
selectable | boolean | false | Click-drag to select slots |
weekends | boolean | true | Show Saturday/Sunday |
nowIndicator | boolean | true | Current-time line in time views |
showScheduleButton | boolean | true | Header Schedule post button |
scheduleSheetOpen | boolean | — | Controlled open state for schedule sheet |
onScheduleSheetOpenChange | (open) => void | — | Pair with scheduleSheetOpen |
renderScheduleSheet | (props) => ReactNode | — | Replace built-in schedule form |
Helpers
Section titled “Helpers”formDataToScheduledPost(data, id?)
Section titled “formDataToScheduledPost(data, id?)”Maps form values to a SocialScheduledPost. Generates a UUID when id is omitted.
const post = formDataToScheduledPost(formData);// { id: "…", title, start: "2026-05-23T09:00:00", end?, platform, status, … }type SocialPlatform = "instagram" | "facebook" | "linkedin" | "x" | "tiktok";type SocialPostStatus = "draft" | "scheduled" | "published" | "failed";
interface SocialScheduledPost { id: string; title: string; start: string; // ISO datetime end?: string; platform: SocialPlatform; status: SocialPostStatus; caption?: string; campaign?: string;}
interface SocialPostFormData { title: string; date: string; // YYYY-MM-DD startTime: string; // HH:mm endTime?: string; platform: SocialPlatform; status: SocialPostStatus; caption?: string; campaign?: string;}Posts are color-coded by platform. Draft posts appear faded; failed posts use the destructive token.