Skip to main content

Forms

Subscription Calendar code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/subscription-calendar.blade.php

@props([
    'value' => '2026-01-16',
    'monthlyTotal' => '$156.23',
    'events' => [
        ['date' => '2026-01-02', 'mark' => 'N', 'tone' => 'text-red-600 dark:text-red-400', 'billing' => 'monthly'],
        ['date' => '2026-01-04', 'mark' => '✳', 'tone' => 'text-accent-500 dark:text-accent-400', 'billing' => 'monthly'],
        ['date' => '2026-01-07', 'mark' => 'A', 'tone' => 'text-red-500 dark:text-red-400', 'billing' => 'monthly'],
        ['date' => '2026-01-10', 'mark' => '◉', 'tone' => 'text-brand-700 dark:text-brand-300', 'billing' => 'yearly'],
        ['date' => '2026-01-12', 'mark' => '●', 'tone' => 'text-gray-500', 'billing' => 'monthly'],
        ['date' => '2026-01-15', 'mark' => '◈', 'tone' => 'text-gray-600 dark:text-gray-400', 'billing' => 'monthly'],
        ['date' => '2026-01-20', 'mark' => '◒', 'tone' => 'text-brand-500 dark:text-brand-400', 'billing' => 'monthly'],
        ['date' => '2026-01-25', 'mark' => '✣', 'tone' => 'text-amber-500 dark:text-amber-400', 'billing' => 'yearly'],
        ['date' => '2026-01-28', 'mark' => '●', 'tone' => 'text-accent-600 dark:text-accent-300', 'billing' => 'monthly'],
    ],
])

@php
    /*
    | A compact billing calendar that visualises scheduled subscriptions by
    | renewal date, cadence and total monthly spend.
    */
@endphp

<section
    x-data="{
        selected: @js($value),
        view: new Date(@js($value.'T00:00:00')),
        events: @js($events),
        months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
        weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
        get label() { return `${this.months[this.view.getMonth()]}, ${this.view.getFullYear()}`; },
        format(date) {
            return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
        },
        get days() {
            const year = this.view.getFullYear();
            const month = this.view.getMonth();
            const first = new Date(year, month, 1);
            const offset = (first.getDay() + 6) % 7;

            return Array.from({ length: 35 }, (_, index) => {
                const date = new Date(year, month, index - offset + 1);

                return {
                    date: this.format(date),
                    day: date.getDate(),
                    current: date.getMonth() === month,
                };
            });
        },
        eventFor(date) { return this.events.find((event) => event.date === date); },
        move(months) { this.view = new Date(this.view.getFullYear(), this.view.getMonth() + months, 1); },
        choose(day) {
            this.selected = day.date;
            this.$dispatch('subscription-calendar-selected', { date: day.date });
        },
        today() {
            const date = new Date();
            this.view = new Date(date.getFullYear(), date.getMonth(), 1);
            this.selected = this.format(date);
        },
    }"
    {{ $attributes->merge(['class' => 'w-full max-w-xl overflow-hidden rounded-[1.75rem] border border-gray-200/80 bg-gray-50 text-gray-950 shadow-[0_22px_55px_-35px_rgba(15,23,42,0.45)] dark:border-gray-800 dark:bg-gray-900 dark:text-white']) }}
    aria-label="Subscription calendar"
>
    <div class="flex items-center gap-2 px-5 pb-4 pt-5 sm:px-6">
        <h2 class="text-lg font-semibold tracking-tight" x-text="label"></h2>
        <button type="button" @click="today()" class="rounded-full border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">Today</button>
        <div class="ms-auto flex items-center gap-1">
            <button type="button" @click="move(-1)" class="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-white" aria-label="Previous month"><x-ui.icon name="caret-left" weight="bold" size="sm" /></button>
            <button type="button" @click="move(1)" class="rounded-full p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-white" aria-label="Next month"><x-ui.icon name="caret-right" weight="bold" size="sm" /></button>
        </div>
        <button type="button" @click="$dispatch('subscription-calendar-add')" class="ms-1 grid size-10 place-items-center rounded-full bg-brand-600 text-xl font-light text-white shadow-lg shadow-brand-600/25 transition hover:bg-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900" aria-label="Add subscription">+</button>
    </div>

    <div class="border-y border-gray-200/80 bg-white px-4 py-4 dark:border-gray-800 dark:bg-gray-950/40 sm:px-5">
        <div class="grid grid-cols-7 gap-1.5 text-center">
            <template x-for="weekday in weekdays" :key="weekday"><span class="rounded-xl bg-gray-50 py-2 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:bg-gray-800/70 dark:text-gray-500" x-text="weekday"></span></template>
            <template x-for="day in days" :key="day.date">
                <button
                    type="button"
                    @click="choose(day)"
                    :aria-pressed="selected === day.date"
                    :class="{
                        'border-brand-300 bg-brand-50 text-brand-700 shadow-sm dark:border-brand-700 dark:bg-brand-950/30 dark:text-brand-300': selected === day.date,
                        'border-transparent bg-gray-50/80 text-gray-500 hover:border-gray-200 hover:bg-white dark:bg-gray-800/50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-gray-800': selected !== day.date && day.current,
                        'border-transparent bg-[repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(148,163,184,.08)_4px,rgba(148,163,184,.08)_5px)] text-gray-300 dark:text-gray-700': ! day.current,
                    }"
                    class="relative flex aspect-square min-h-13 flex-col items-center justify-between rounded-xl border px-1 py-1.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
                >
                    <span class="font-mono text-xs" x-text="day.day"></span>
                    <span class="grid h-5 place-items-center text-lg font-bold leading-none" :class="eventFor(day.date)?.tone" x-text="eventFor(day.date)?.mark"></span>
                    <span x-show="eventFor(day.date)" class="absolute end-1.5 top-1.5 size-1.5 rounded-full" :class="eventFor(day.date)?.billing === 'yearly' ? 'bg-amber-400' : 'bg-accent-500'"></span>
                </button>
            </template>
        </div>

        <div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
            <span class="inline-flex items-center gap-2"><i class="size-2 rounded-full bg-accent-500"></i>Monthly</span>
            <span class="inline-flex items-center gap-2"><i class="size-2 rounded-full bg-amber-400"></i>Yearly</span>
            <span class="ms-auto font-mono tracking-normal text-gray-400"><strong class="text-gray-900 dark:text-white" x-text="events.length"></strong> subscriptions&nbsp;&nbsp;/&nbsp;&nbsp;<strong class="text-gray-900 dark:text-white">3</strong> new</span>
        </div>
    </div>

    <footer class="flex items-center justify-between gap-4 px-5 py-4 sm:px-6">
        <div class="flex items-center gap-3 text-gray-400" aria-label="Calendar tools">
            <button type="button" class="rounded-lg p-1 transition hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-white" aria-label="More calendar actions">⋮</button>
            <span class="h-5 w-px bg-gray-200 dark:bg-gray-700"></span>
            <x-ui.icon name="magnifying-glass" size="sm" />
            <x-ui.icon name="download-simple" size="sm" />
        </div>
        <p class="text-right font-mono text-[11px] uppercase tracking-[0.1em] text-gray-400">Monthly total: <strong class="ms-2 text-sm tracking-normal text-gray-900 dark:text-white">{{ $monthlyTotal }}</strong></p>
    </footer>
</section>