A small scheduling card for a repeating reminder. A switch turns repeating on or off. Three presets (every day, weekdays, custom) fill the row of seven weekday chips, and each chip can also be toggled by hand. When a day is chosen its label rolls upward and a white tile rolls in behind it, one chip after another across the row. Turning the switch off fades and disables the repeat controls. A time field and a sentence that describes the schedule in plain words finish the card. It is a single narrow column and fits a phone screen as it is.
// Adapted from Animata (https://github.com/codse/animata/blob/main/animata/card/reminder-scheduler.tsx)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useState } from "react";
import { Bell, Droplets } from "lucide-react";
const days = [
{ key: "Mo", name: "Monday", short: "Mon" },
{ key: "Tu", name: "Tuesday", short: "Tue" },
{ key: "We", name: "Wednesday", short: "Wed" },
{ key: "Th", name: "Thursday", short: "Thu" },
{ key: "Fr", name: "Friday", short: "Fri" },
{ key: "Sa", name: "Saturday", short: "Sat" },
{ key: "Su", name: "Sunday", short: "Sun" },
];
const presets = [
{ key: "daily", label: "Every day", days: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] },
{ key: "weekdays", label: "Weekdays", days: ["Mo", "Tu", "We", "Th", "Fr"] },
{ key: "custom", label: "Custom", days: ["Mo", "We", "Sa"] },
];
const sameSet = (a: string[], b: string[]) => a.length === b.length && a.every((x) => b.includes(x));
function listDays(selected: string[]) {
const names = days.filter((d) => selected.includes(d.key)).map((d) => d.short);
if (names.length === 7) return "every day";
if (names.length <= 1) return names.length ? `every ${names[0]}` : "";
return `every ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
}
function formatTime(value: string) {
const [h, m] = value.split(":").map((part) => parseInt(part, 10));
if (isNaN(h) || isNaN(m)) return value;
return `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${h < 12 ? "am" : "pm"}`;
}
export default function RepeatReminderSchedulerCard() {
const switchId = useId();
const timeId = useId();
const [repeating, setRepeating] = useState(true);
const [selected, setSelected] = useState<string[]>([]);
const [time, setTime] = useState("08:00");
// The chips start empty and roll to the saved days just after load.
useEffect(() => {
const timer = setTimeout(() => setSelected(presets[2].days), 450);
return () => clearTimeout(timer);
}, []);
const activePreset = presets.find((p) => p.key !== "custom" && sameSet(p.days, selected))?.key ?? "custom";
const toggleDay = (key: string) => setSelected((list) => (list.includes(key) ? list.filter((k) => k !== key) : [...list, key]));
const summary = !repeating
? `One reminder, tomorrow at ${formatTime(time)}.`
: selected.length === 0
? "Pick at least one day."
: `Repeats ${listDays(selected)} at ${formatTime(time)}.`;
return (
<section className="bg-[#f4f1e4] px-4 py-14 sm:px-6">
<style>{`
.reminder-scheduler-roll { transition: transform 620ms cubic-bezier(0.65, 0, 0.15, 1); }
.reminder-scheduler-fade { transition: opacity 400ms ease-in-out; }
@media (prefers-reduced-motion: reduce) {
.reminder-scheduler-roll, .reminder-scheduler-fade { transition: none; }
}
`}</style>
<div className="mx-auto max-w-sm">
<div className="rounded-[2rem] border border-stone-200 bg-white p-5 shadow-xl shadow-stone-900/5 sm:p-6">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-lime-200 text-lime-900">
<Droplets aria-hidden="true" className="h-6 w-6" />
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-lg font-semibold text-stone-900">Water the monstera</h2>
<p className="text-sm text-stone-600">Living room, by the window</p>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<label htmlFor={switchId} className="text-base font-medium text-stone-900">
Repeat this reminder
</label>
<button
id={switchId}
type="button"
role="switch"
aria-label="Repeat this reminder"
aria-checked={repeating}
onClick={() => setRepeating((value) => !value)}
className={`relative h-8 w-14 shrink-0 rounded-full transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 motion-reduce:transition-none ${repeating ? "bg-lime-400" : "bg-stone-300"}`}
>
<span
aria-hidden="true"
className={`absolute left-1 top-1 h-6 w-6 rounded-full bg-white shadow transition-transform duration-300 motion-reduce:transition-none ${repeating ? "translate-x-6" : "translate-x-0"}`}
/>
</button>
</div>
<div className={`reminder-scheduler-fade ${repeating ? "opacity-100" : "opacity-40"}`}>
<div role="group" aria-label="How often" className="mt-5 grid grid-cols-3 gap-1 rounded-2xl bg-stone-100 p-1">
{presets.map((preset) => {
const on = activePreset === preset.key;
return (
<button
key={preset.key}
type="button"
disabled={!repeating}
aria-pressed={on}
onClick={() => setSelected(preset.days)}
className={`h-10 rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 motion-reduce:transition-none ${
on ? "bg-white text-stone-900 shadow-sm" : "text-stone-700 hover:text-stone-900"
}`}
>
{preset.label}
</button>
);
})}
</div>
<div role="group" aria-label="Days of the week" className="mt-3 grid grid-cols-7 gap-1.5 rounded-2xl bg-stone-100 p-2">
{days.map((day, i) => {
const on = selected.includes(day.key);
return (
<button
key={day.key}
type="button"
disabled={!repeating}
aria-pressed={on}
aria-label={day.name}
onClick={() => toggleDay(day.key)}
className="relative h-11 overflow-hidden rounded-xl text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900"
>
{/* Two copies of the label stacked. Both slide up one step when the day is chosen. */}
<span
aria-hidden="true"
className={`reminder-scheduler-roll absolute inset-0 flex items-center justify-center text-stone-500 ${on ? "-translate-y-full" : ""}`}
style={{ transitionDelay: `${i * 45}ms` }}
>
{day.key}
</span>
<span
aria-hidden="true"
className={`reminder-scheduler-roll absolute inset-x-0 top-full flex h-full items-center justify-center rounded-xl bg-white text-stone-900 shadow-sm ${on ? "-translate-y-full" : ""}`}
style={{ transitionDelay: `${i * 45}ms` }}
>
{day.key}
</span>
</button>
);
})}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-4">
<label htmlFor={timeId} className="text-base font-medium text-stone-900">
Time
</label>
<input
id={timeId}
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
className="h-11 rounded-xl border border-stone-200 bg-white px-3 text-base font-semibold tabular-nums text-stone-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900"
/>
</div>
<p aria-live="polite" className="mt-5 flex min-h-[3rem] items-start gap-2 rounded-2xl bg-lime-100 px-3.5 py-3 text-sm font-medium text-lime-950">
<Bell aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0" />
{summary}
</p>
<button
type="button"
className="mt-4 h-12 w-full rounded-2xl bg-stone-900 text-sm font-semibold text-white transition-colors hover:bg-stone-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
>
Save reminder
</button>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/repeat-reminder-scheduler-card-with-rolling-day-chips.json
Each chip holds two copies of its label, one stacked directly below the other, inside a box that hides overflow. Choosing the day moves both copies up by the height of the chip, so the grey label leaves through the top as the white tile arrives from the bottom.
The `selected` state is an array of day keys such as Mo, We and Sa. Lift it to a parent with props, or put the Save button inside a form and write the array to a hidden input.
The highlighted preset is worked out from the chips. If the chosen days match every day or weekdays exactly, that preset lights up; any other combination counts as custom.
Yes. Move the Sunday entry to the start of the `days` array. The summary lists days in the order of that array.
It is a native `input type="time"`, so each browser and phone shows its own picker and may use a 12 or 24 hour format. The summary formats the value itself, so the sentence is always consistent.
Adapted from Reminder Scheduler in Animata, used under its open-source licence. Licences and credits
Related: Settings Panel with Toggle Switches and Save Bar, Segmented Control with Spring Pill and Morphing Views, Multi-Step Signup Form with Step Indicator.
More forms or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.