A controlled month/year grid, built from scratch on date-fns (not a shadcn primitive). Only tracks the displayed year internally — the selected month is fully controlled by the caller.
npx chandra-asri-ui add month-pickerClick the trigger to open the year/month grid. Months after the current one are disabled, and the "next year" button disables itself once the next year hasn't started yet.
"use client";
import { buttonVariants } from "@/app/components/ui/button";
import { MonthPicker } from "@/app/components/ui/month-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/components/ui/popover";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";
/**
* The only real date-picker usage pattern on the source platform: a button
* trigger showing the picked month, opening a Popover whose content is the
* MonthPicker grid. Styled with `buttonVariants` directly on `PopoverTrigger`
* rather than wrapping `Button` with `asChild` — `Button` here is a plain
* function component (not `forwardRef`), so it can't accept the ref Radix's
* `asChild` needs to attach.
*/
export function MonthPickerDemo() {
const [month, setMonth] = React.useState(new Date());
return (
<Popover>
<PopoverTrigger
className={cn(
buttonVariants({ variant: "outline" }),
"w-[220px] justify-start text-left font-normal",
)}
>
<CalendarIcon className="text-gray-90 mr-2 h-4 w-4 opacity-50" />
<span className="text-dark-70 text-base">
{format(month, "MMMM yyyy")}
</span>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker currentMonth={month} onMonthChange={setMonth} />
</PopoverContent>
</Popover>
);
}
MonthPicker is a static grid with no animation of its own — only the hover/focus transition on each month button. The smooth open/close seen above comes entirely from the wrapping Popover.
"use client";
import { buttonVariants } from "@/app/components/ui/button";
import { cn } from "@/lib/utils";
import {
addYears,
eachMonthOfInterval,
endOfYear,
format,
isEqual,
isFuture,
startOfMonth,
startOfYear,
subYears,
} from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
interface MonthPickerProps {
currentMonth: Date;
onMonthChange: (newMonth: Date) => void;
}
/**
* Custom-built (not a shadcn primitive) — a controlled month/year grid
* picker, typically used as the content of a Popover triggered by a button
* (see the docs example). Two fixes from the source: (1) the source colors
* this with raw Tailwind `slate-*` steps instead of this bundle's brand
* scale — unified here on `primary-70`/`gray-70`/`dark-70`; (2) dark-mode
* classes are dropped since this platform is strictly light mode.
*/
export function MonthPicker({ currentMonth, onMonthChange }: MonthPickerProps) {
const [currentYear, setCurrentYear] = React.useState(currentMonth);
const months = eachMonthOfInterval({
start: startOfYear(currentYear),
end: endOfYear(currentYear),
});
const nextYear = addYears(currentYear, 1);
const nextYearIsFuture = isFuture(startOfYear(nextYear));
return (
<div className="p-3">
<div className="space-y-4">
<div className="relative flex items-center justify-center pt-1">
<button
type="button"
aria-label="Previous year"
onClick={() => setCurrentYear(subYears(currentYear, 1))}
className={cn(
buttonVariants({ variant: "outline" }),
"absolute left-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
<div aria-live="polite" className="text-dark-70 text-sm font-medium">
{format(currentYear, "yyyy")}
</div>
<button
type="button"
aria-label="Next year"
disabled={nextYearIsFuture}
onClick={() => setCurrentYear(nextYear)}
className={cn(
buttonVariants({ variant: "outline" }),
"absolute right-1 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-2">
{months.map((month) => {
const isSelected = isEqual(month, startOfMonth(currentMonth));
const isCurrentMonth = isEqual(month, startOfMonth(new Date()));
return (
<div
key={month.toISOString()}
className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20"
>
<button
type="button"
disabled={isFuture(month)}
aria-pressed={isSelected}
onClick={() => onMonthChange(month)}
className={cn(
"text-dark-70 inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal transition-colors",
"hover:bg-gray-70 hover:text-dark-70",
"focus-visible:ring-primary-30 focus-visible:ring-1 focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
isSelected &&
"bg-primary-70 hover:bg-primary-70 text-white hover:text-white",
!isSelected &&
isCurrentMonth &&
"bg-gray-70 text-dark-70 hover:bg-gray-70",
)}
>
{format(month, "MMM")}
</button>
</div>
);
})}
</div>
</div>
</div>
);
}