Repeat Reminder Scheduler Card with Rolling Day Chips

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How is the rolling label made?

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.

How do I read the chosen days in my own code?

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.

Why does picking a single day switch the preset to Custom?

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.

Can the week start on Sunday?

Yes. Move the Sunday entry to the start of the `days` array. The summary lists days in the order of that array.

Does the time field look the same in every browser?

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.

More in Forms

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.

Product

Who It's For

Features

Resources

Company

View Sitemap