Onboarding Permissions
Original · freepermissions request screen
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/onboard-permissions.json"use client";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type PermissionId = "location" | "notifications" | "camera" | "contacts" | "calendar";
type PermissionStatus = "idle" | "requesting" | "granted" | "denied";
type Permission = {
id: PermissionId;
label: string;
purpose: string;
detail: string;
required: boolean;
icon: (props: { className?: string }) => JSX.Element;
};
function LocationIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M12 21s7-5.686 7-11a7 7 0 1 0-14 0c0 5.314 7 11 7 11Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="12" cy="10" r="2.6" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
function BellIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M6 9a6 6 0 1 1 12 0c0 3.2.7 5 1.6 6.1.4.5 0 1.4-.7 1.4H5.1c-.7 0-1.1-.9-.7-1.4C5.3 14 6 12.2 6 9Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M9.8 19.5a2.4 2.4 0 0 0 4.4 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function CameraIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M3.5 8.5A2 2 0 0 1 5.5 6.5h1.7a1 1 0 0 0 .84-.46l.92-1.42a1 1 0 0 1 .84-.46h4.4a1 1 0 0 1 .84.46l.92 1.42a1 1 0 0 0 .84.46h1.7a2 2 0 0 1 2 2v8.2a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V8.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="12" cy="12.6" r="3.2" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
function ContactsIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="9" cy="9" r="3.2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M3.6 19.2c.6-2.9 2.8-4.6 5.4-4.6s4.8 1.7 5.4 4.6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path d="M16.6 7.4a3 3 0 0 1 0 5.8M18.2 18.8h2.4c-.3-1.7-1.2-3-2.6-3.7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function CalendarIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<rect x="3.6" y="5.4" width="16.8" height="15" rx="2.2" stroke="currentColor" strokeWidth="1.6" />
<path d="M3.6 10h16.8M8.4 3.6v3.4M15.6 3.6v3.4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<rect x="7" y="13" width="3.2" height="3" rx="0.8" fill="currentColor" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="m5 12.8 4.2 4.2L19 7.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CrossIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="m6.6 6.6 10.8 10.8M17.4 6.6 6.6 17.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function ShieldIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M12 3.2 5 6v5.6c0 4.2 2.9 7.6 7 9.2 4.1-1.6 7-5 7-9.2V6l-7-2.8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="m9.2 12.2 2 2 3.6-3.8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
const PERMISSIONS: Permission[] = [
{
id: "location",
label: "Precise location",
purpose: "Match you to depots within 40 km",
detail:
"Read once when you open the dispatch map. Coordinates are rounded to 500 m before they leave the device and never stored against your profile.",
required: true,
icon: LocationIcon,
},
{
id: "notifications",
label: "Push notifications",
purpose: "Alert you when a run is reassigned",
detail:
"Roughly 4 alerts per shift: route changes, dock delays, and end-of-shift sign-off. Marketing pushes are a separate opt-in you will not find here.",
required: true,
icon: BellIcon,
},
{
id: "camera",
label: "Camera",
purpose: "Scan pallet barcodes and log damage",
detail:
"Opens only inside the scanner sheet. Photos stay in the app sandbox until you attach them to a delivery report, and never touch your gallery.",
required: false,
icon: CameraIcon,
},
{
id: "contacts",
label: "Contacts",
purpose: "Call the site foreman without typing numbers",
detail:
"We read names and phone numbers for the 12 sites on your rota. Nothing is uploaded — the lookup runs on your handset.",
required: false,
icon: ContactsIcon,
},
{
id: "calendar",
label: "Calendar",
purpose: "Block out shifts you have accepted",
detail:
"Write-only. Freight Desk adds and removes its own events and cannot read anything you or anyone else has scheduled.",
required: false,
icon: CalendarIcon,
},
];
const REQUIRED_IDS = PERMISSIONS.filter((permission) => permission.required).map((permission) => permission.id);
export default function OnboardPermissions() {
const reduceMotion = useReducedMotion();
const headingId = useId();
const panelBaseId = useId();
const [statuses, setStatuses] = useState<Record<PermissionId, PermissionStatus>>({
location: "idle",
notifications: "idle",
camera: "idle",
contacts: "idle",
calendar: "idle",
});
const [expanded, setExpanded] = useState<PermissionId | null>(null);
const [announcement, setAnnouncement] = useState<string>("");
const timers = useRef<number[]>([]);
const settle = useCallback(
(id: PermissionId, next: Exclude<PermissionStatus, "idle" | "requesting">, label: string) => {
setStatuses((current) => ({ ...current, [id]: "requesting" }));
const timer = window.setTimeout(
() => {
setStatuses((current) => ({ ...current, [id]: next }));
setAnnouncement(`${label} ${next === "granted" ? "allowed" : "kept off"}.`);
},
reduceMotion ? 0 : 460,
);
timers.current.push(timer);
},
[reduceMotion],
);
const decide = useCallback(
(permission: Permission, next: "granted" | "denied") => {
if (statuses[permission.id] === "requesting") return;
settle(permission.id, next, permission.label);
},
[settle, statuses],
);
const allowAll = useCallback(() => {
PERMISSIONS.forEach((permission, index) => {
const timer = window.setTimeout(
() => settle(permission.id, "granted", permission.label),
reduceMotion ? 0 : index * 90,
);
timers.current.push(timer);
});
setAnnouncement("Requesting all five permissions.");
}, [reduceMotion, settle]);
const grantedCount = useMemo(
() => PERMISSIONS.filter((permission) => statuses[permission.id] === "granted").length,
[statuses],
);
const requiredMet = useMemo(
() => REQUIRED_IDS.every((id) => statuses[id] === "granted"),
[statuses],
);
const pendingRequired = useMemo(
() => REQUIRED_IDS.filter((id) => statuses[id] !== "granted").length,
[statuses],
);
const progress = (grantedCount / PERMISSIONS.length) * 100;
return (
<section
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
>
<style>{`
@keyframes obpm-pulse-ring {
0% { transform: scale(0.85); opacity: 0.55; }
70% { transform: scale(1.5); opacity: 0; }
100% { transform: scale(1.5); opacity: 0; }
}
@keyframes obpm-drift {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -14px, 0); }
}
@keyframes obpm-sheen {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.obpm-ring::after {
content: "";
position: absolute;
inset: -6px;
border-radius: 9999px;
border: 1px solid currentColor;
animation: obpm-pulse-ring 1.6s ease-out infinite;
}
.obpm-drift { animation: obpm-drift 11s ease-in-out infinite; }
.obpm-sheen {
background-size: 200% 100%;
animation: obpm-sheen 2.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.obpm-ring::after,
.obpm-drift,
.obpm-sheen {
animation: none !important;
}
}
`}</style>
<div
aria-hidden="true"
className="obpm-drift pointer-events-none absolute -top-24 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-200/50 via-violet-200/40 to-sky-200/50 blur-3xl dark:from-indigo-500/10 dark:via-violet-500/10 dark:to-sky-500/10"
/>
<div className="relative mx-auto w-full max-w-3xl">
<div className="mb-10 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<ShieldIcon className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
Step 3 of 4 · Freight Desk setup
</span>
<h2 id={headingId} className="mt-5 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Choose what the app can reach
</h2>
<p className="mx-auto mt-3 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
Nothing is switched on until you say so, and every choice here can be reversed later from Settings ›
Privacy. Two permissions are needed for dispatch to function at all.
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<div className="flex items-center gap-3">
<div className="flex h-9 w-24 items-center" aria-hidden="true">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<motion.div
className="h-full rounded-full bg-indigo-600 dark:bg-indigo-400"
initial={false}
animate={{ width: `${progress}%` }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 220, damping: 30 }}
/>
</div>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
<span className="font-semibold text-slate-900 tabular-nums dark:text-slate-100">{grantedCount}</span> of{" "}
{PERMISSIONS.length} allowed
</p>
</div>
<button
type="button"
onClick={allowAll}
className="rounded-lg px-3 py-1.5 text-sm font-medium text-indigo-700 transition-colors hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-300 dark:hover:bg-indigo-950/60 dark:focus-visible:ring-offset-slate-900"
>
Allow all five
</button>
</div>
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{PERMISSIONS.map((permission) => {
const status = statuses[permission.id];
const Icon = permission.icon;
const isOpen = expanded === permission.id;
const panelId = `${panelBaseId}-${permission.id}`;
const granted = status === "granted";
const denied = status === "denied";
const busy = status === "requesting";
return (
<li key={permission.id} className="px-5 py-4">
<div className="flex items-start gap-4">
<span
className={[
"relative mt-0.5 grid h-10 w-10 shrink-0 place-items-center rounded-xl border transition-colors",
granted
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-400"
: denied
? "border-slate-200 bg-slate-100 text-slate-400 dark:border-slate-800 dark:bg-slate-800/60 dark:text-slate-500"
: "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-800 dark:bg-slate-800/60 dark:text-slate-300",
busy && !reduceMotion ? "obpm-ring text-indigo-600 dark:text-indigo-400" : "",
].join(" ")}
>
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{permission.label}</h3>
{permission.required ? (
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300">
Required
</span>
) : (
<span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-400">
Optional
</span>
)}
</div>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{permission.purpose}
</p>
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setExpanded(isOpen ? null : permission.id)}
className="mt-2 inline-flex items-center gap-1 rounded text-xs font-medium text-slate-500 underline-offset-4 transition-colors hover:text-slate-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-500 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={`h-3.5 w-3.5 transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
>
<path d="m9.5 6.5 5.5 5.5-5.5 5.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{isOpen ? "Hide the fine print" : "What exactly is read?"}
</button>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
key="detail"
id={panelId}
initial={reduceMotion ? false : { height: 0, opacity: 0 }}
animate={reduceMotion ? { opacity: 1 } : { height: "auto", opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<p className="mt-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs leading-relaxed text-slate-600 dark:border-slate-800 dark:bg-slate-950/60 dark:text-slate-400">
{permission.detail}
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
{granted ? (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 px-2.5 py-1.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400">
<CheckIcon className="h-3.5 w-3.5" />
Allowed
</span>
) : null}
{denied ? (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-rose-50 px-2.5 py-1.5 text-xs font-semibold text-rose-700 dark:bg-rose-950/50 dark:text-rose-400">
<CrossIcon className="h-3.5 w-3.5" />
Off
</span>
) : null}
{busy ? (
<span className="obpm-sheen inline-flex items-center rounded-lg bg-gradient-to-r from-slate-100 via-slate-200 to-slate-100 px-2.5 py-1.5 text-xs font-semibold text-slate-500 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800 dark:text-slate-400">
Asking…
</span>
) : null}
{status === "idle" ? (
<>
<button
type="button"
onClick={() => decide(permission, "denied")}
className="rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Not now
</button>
<button
type="button"
onClick={() => decide(permission, "granted")}
className="rounded-lg bg-slate-900 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
>
Allow
</button>
</>
) : null}
{granted || denied ? (
<button
type="button"
onClick={() => setStatuses((current) => ({ ...current, [permission.id]: "idle" }))}
className="rounded-lg px-2 py-1.5 text-xs font-medium text-slate-500 underline-offset-4 transition-colors hover:text-slate-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">Change decision for {permission.label}: </span>
Change
</button>
) : null}
</div>
</div>
</li>
);
})}
</ul>
<div className="flex flex-col gap-3 border-t border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-500">
{requiredMet
? "Both required permissions are on. You're clear to continue."
: `${pendingRequired} required permission${pendingRequired === 1 ? "" : "s"} still off — dispatch can't route you without ${pendingRequired === 1 ? "it" : "them"}.`}
</p>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Back
</button>
<button
type="button"
disabled={!requiredMet}
aria-describedby={requiredMet ? undefined : `${panelBaseId}-gate`}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-600 dark:focus-visible:ring-offset-slate-900"
>
Continue to vehicle check
</button>
</div>
</div>
</div>
<p id={`${panelBaseId}-gate`} className="mt-4 text-center text-xs text-slate-500 dark:text-slate-500">
Freight Desk never sells device data and holds no third-party trackers. Read the{" "}
<a
href="#permissions-policy"
className="rounded font-medium text-slate-700 underline underline-offset-4 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:text-slate-300 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-950"
>
data handling notice
</a>
.
</p>
<p aria-live="polite" role="status" className="sr-only">
{announcement}
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Onboarding Welcome
Originalwelcome screen with steps

Onboarding Tour
Originalproduct tour with prev/next dots

Onboarding Checklist
Originalsetup checklist with progress

Onboarding Spotlight
Originalfeature spotlight highlight

Onboarding Tooltip Tour
Originalguided tooltip walkthrough

Onboarding Progress
Originalonboarding progress header

Onboarding Slides
Originalintro slides carousel

Onboarding Setup Wizard
Originalmulti-step setup wizard

Onboarding Get Started
Originalget started action cards

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

