Web InnoventixFreeCode

Onboarding Permissions

Original · free

permissions request screen

byWeb InnoventixReact + Tailwind
onboardpermissionsonboarding
Open

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
onboard-permissions.tsx
"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 &middot; 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 &rsaquo;
            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&hellip;
                        </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

motion

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 quote

Similar components

Browse all →