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>