Skip to main content

Data display

Onboarding Checklist code

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

resources/views/components/ui/onboarding-checklist.blade.php

@props([
    'title' => 'Get started',
    'description' => null,
    'steps' => [],
    'storageKey' => null,
    'completeTitle' => "You're all set!",
    'completeDescription' => 'Every step is done — nice work.',
])

@php
    /*
    | Onboarding / activation checklist. Renders a list of setup steps with a
    | circular completion ring and a "x of y" count. Steps can be ticked off
    | (state persists in localStorage when `storageKey` is set) and may carry a
    | call-to-action link. When every step is done a celebratory state shows.
    |
    | Each step: ['title' => ?, 'description' => ?, 'done' => false, 'icon' => ?,
    |             'href' => ?, 'cta' => ?]
    |
    | Usage:
    |   <x-ui.onboarding-checklist
    |       storage-key="setup"
    |       :steps="[
    |           ['title' => 'Verify your email', 'done' => true],
    |           ['title' => 'Invite your team', 'description' => 'Add a teammate.', 'href' => '/users', 'cta' => 'Invite'],
    |           ['title' => 'Create a project', 'icon' => 'folder-plus'],
    |       ]"
    |   />
    */
    $normalizedSteps = collect($steps)->map(fn (array $step): array => [
        'title' => $step['title'] ?? '',
        'description' => $step['description'] ?? null,
        'done' => (bool) ($step['done'] ?? false),
        'icon' => $step['icon'] ?? null,
        'href' => $step['href'] ?? null,
        'cta' => $step['cta'] ?? null,
    ])->values()->all();

    // Circumference of the progress ring (r = 18).
    $circumference = 2 * M_PI * 18;
@endphp

<div
    x-data="{
        steps: @js($normalizedSteps),
        storageKey: @js($storageKey),
        circumference: @js(round($circumference, 3)),
        init() {
            if (! this.storageKey) {
                return;
            }

            try {
                const saved = JSON.parse(localStorage.getItem('onboarding:' + this.storageKey) || 'null');

                if (Array.isArray(saved)) {
                    this.steps.forEach((step, index) => {
                        if (typeof saved[index] === 'boolean') {
                            step.done = saved[index];
                        }
                    });
                }
            } catch (error) {
                // Ignore malformed storage payloads.
            }
        },
        get completed() {
            return this.steps.filter((step) => step.done).length;
        },
        get total() {
            return this.steps.length;
        },
        get percent() {
            return this.total ? Math.round((this.completed / this.total) * 100) : 0;
        },
        get allDone() {
            return this.total > 0 && this.completed === this.total;
        },
        get dashOffset() {
            return this.circumference * (1 - this.percent / 100);
        },
        toggle(index) {
            this.steps[index].done = ! this.steps[index].done;
            this.persist();
        },
        persist() {
            if (! this.storageKey) {
                return;
            }

            try {
                localStorage.setItem('onboarding:' + this.storageKey, JSON.stringify(this.steps.map((step) => step.done)));
            } catch (error) {
                // Storage may be unavailable (private mode); fail silently.
            }
        },
    }"
    {{ $attributes->merge(['class' => 'overflow-hidden rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900']) }}
>
    {{-- Header --}}
    <div class="flex items-center gap-4 border-b border-gray-200 p-5 dark:border-gray-800">
        <div class="relative h-14 w-14 shrink-0">
            <svg class="h-14 w-14 -rotate-90" viewBox="0 0 44 44" aria-hidden="true">
                <circle cx="22" cy="22" r="18" fill="none" stroke-width="4" class="stroke-gray-200 dark:stroke-gray-800" />
                <circle
                    cx="22" cy="22" r="18" fill="none" stroke-width="4" stroke-linecap="round"
                    class="transition-all duration-500"
                    :class="allDone ? 'stroke-green-500' : 'stroke-brand-500'"
                    :stroke-dasharray="circumference"
                    :stroke-dashoffset="dashOffset"
                />
            </svg>
            <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold tabular-nums text-gray-700 dark:text-gray-200" x-text="percent + '%'"></span>
        </div>

        <div class="min-w-0 flex-1">
            <div class="flex items-center gap-2">
                <h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
                <x-ui.badge x-show="allDone" x-cloak variant="green" size="sm" icon="check-circle">Complete</x-ui.badge>
            </div>
            @if ($description)
                <p class="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">{{ $description }}</p>
            @endif
            <p class="mt-1 text-xs font-medium text-gray-400 dark:text-gray-500">
                <span x-text="completed"></span> of <span x-text="total"></span> complete
            </p>
        </div>
    </div>

    {{-- Steps --}}
    <ul class="divide-y divide-gray-100 dark:divide-gray-800">
        <template x-for="(step, index) in steps" :key="index">
            <li class="flex items-start gap-3 p-4 transition hover:bg-gray-50/70 dark:hover:bg-gray-800/40">
                <button
                    type="button"
                    @click="toggle(index)"
                    class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40"
                    :class="step.done ? 'border-green-500 bg-green-500 text-white' : 'border-gray-300 text-transparent hover:border-brand-400 dark:border-gray-600'"
                    :aria-pressed="step.done.toString()"
                    :aria-label="(step.done ? 'Mark incomplete: ' : 'Mark complete: ') + step.title"
                >
                    <i class="ph-bold ph-check text-sm leading-none" aria-hidden="true"></i>
                </button>

                <div class="min-w-0 flex-1">
                    <div class="flex items-center gap-1.5">
                        <template x-if="step.icon">
                            <i class="text-base leading-none text-gray-400 dark:text-gray-500" :class="'ph ph-' + step.icon" aria-hidden="true"></i>
                        </template>
                        <p
                            class="text-sm font-medium transition"
                            :class="step.done ? 'text-gray-400 line-through dark:text-gray-600' : 'text-gray-900 dark:text-white'"
                            x-text="step.title"
                        ></p>
                    </div>
                    <p x-show="step.description" class="mt-0.5 text-sm text-gray-500 dark:text-gray-400" x-text="step.description"></p>

                    <template x-if="step.href && step.cta && ! step.done">
                        <a
                            :href="step.href"
                            class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand-600 transition hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
                        >
                            <span x-text="step.cta"></span>
                            <i class="ph-bold ph-arrow-right text-xs leading-none" aria-hidden="true"></i>
                        </a>
                    </template>
                </div>
            </li>
        </template>
    </ul>

    {{-- Completed state --}}
    <div x-show="allDone" x-cloak class="flex items-center gap-3 border-t border-gray-200 bg-green-50/60 p-5 dark:border-gray-800 dark:bg-green-950/30">
        <span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-green-500/15 text-green-600 dark:text-green-400">
            <x-ui.icon name="confetti" weight="fill" size="lg" />
        </span>
        <div class="min-w-0">
            <p class="text-sm font-semibold text-gray-900 dark:text-white">{{ $completeTitle }}</p>
            <p class="text-sm text-gray-500 dark:text-gray-400">{{ $completeDescription }}</p>
        </div>
    </div>

    @if (! $slot->isEmpty())
        <div class="border-t border-gray-200 p-4 dark:border-gray-800">{{ $slot }}</div>
    @endif
</div>