# Project Planner

> Multi-step project scoping planner UI.

A shadcn/ui block from the [@atroui](https://www.atroui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/atroui/project-planner).

- Page, with a live preview: https://shadcn.exchange/r/atroui/project-planner
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/atroui/project-planner-light-c41eac5f3fb2917d.png
- Registry item: https://www.atroui.com/r/project-planner.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.atroui.com/r/project-planner.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`, `next`
- Registry items: `@atroui/utils`, `@atroui/project-brief`

## Files

### `registry/default/blocks/project-planner.tsx`

```tsx
"use client";

import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";

import { cn } from "@/lib/utils";
import { buildOgHref, type ProjectBrief } from "@/lib/project-brief";

type PlannerState = {
  projectType: "mvp" | "full" | "";
  features: string[];
  aiRequired: boolean | null;
  aiType: string;
  budget: string;
  timeline: string;
  name: string;
  email: string;
};

const STEPS = [
  "Project type",
  "Features",
  "AI needs",
  "Budget",
  "Contact",
  "Estimate",
] as const;

const FEATURE_OPTIONS = [
  "User authentication",
  "Payments / billing",
  "Admin dashboard",
  "Real-time features",
  "AI / ML feature",
  "Mobile-responsive",
  "Third-party integrations",
];

const AI_TYPES = [
  "Document processing",
  "Chat / conversational",
  "Content generation",
  "Search / RAG",
  "Classification / tagging",
];

const BUDGETS = [
  { id: "<2k", label: "Under $2k" },
  { id: "2k-5k", label: "$2k - $5k" },
  { id: "5k-10k", label: "$5k - $10k" },
  { id: "10k+", label: "$10k+" },
];

function estimateProject(state: PlannerState): {
  service: string;
  serviceId: string;
  priceRange: string;
  timeline: string;
  summary: string;
} {
  const featureCount = state.features.length;
  const hasAI =
    state.aiRequired === true || state.features.includes("AI / ML feature");

  if (state.projectType === "mvp" && featureCount <= 3 && !hasAI) {
    return {
      service: "7-Day MVP Sprint",
      serviceId: "mvp-sprint",
      priceRange: "$4,800",
      timeline: "7 days",
      summary: "A focused sprint to ship your core workflow fast.",
    };
  }

  if (hasAI && featureCount <= 4) {
    return {
      service: "AI Feature Integration",
      serviceId: "ai-integration",
      priceRange: "$2,400 - $4,000",
      timeline: "1-2 weeks",
      summary:
        "Add a well-designed AI feature to your existing or new product.",
    };
  }

  if (state.features.includes("Admin dashboard") && featureCount >= 4) {
    return {
      service: "Full-Stack Product Build",
      serviceId: "full-stack-build",
      priceRange: "$8,000 - $15,000",
      timeline: "4-8 weeks",
      summary: "End-to-end product development with multiple features.",
    };
  }

  if (state.projectType === "mvp") {
    return {
      service: "7-Day MVP Sprint",
      serviceId: "mvp-sprint",
      priceRange: "$4,800 - $6,000",
      timeline: "7-10 days",
      summary:
        "MVP sprint with scoped features - we'll narrow scope on the intro call.",
    };
  }

  return {
    service: "Full-Stack Product Build",
    serviceId: "full-stack-build",
    priceRange: "$8,000+",
    timeline: "4-8 weeks",
    summary: "A complete product build tailored to your requirements.",
  };
}

const fieldInput = cn(
  "w-full border border-border-subtle bg-background px-3.5 py-2.5 text-base text-foreground sm:text-sm",
  "placeholder:text-muted-foreground/60",
  "transition-[border-color,box-shadow] duration-200",
  "focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20",
);

function ChoiceButton({
  pressed,
  onClick,
  children,
  className,
}: {
  pressed: boolean;
  onClick: () => void;
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-pressed={pressed}
      className={cn(
        "border px-4 py-3 text-left transition-colors active:scale-[0.99]",
        pressed
          ? "border-brand bg-brand/8 text-foreground"
          : "border-border-subtle bg-background text-muted-foreground hover:border-border hover:text-foreground",
        className,
      )}
    >
      {children}
    </button>
  );
}

export function ProjectPlanner() {
  const router = useRouter();
  const reduce = useReducedMotion();
  const [step, setStep] = useState(0);
  const [state, setState] = useState<PlannerState>({
    projectType: "",
    features: [],
    aiRequired: null,
    aiType: "",
    budget: "",
    timeline: "",
    name: "",
    email: "",
  });

  const estimate = useMemo(
    () => (step === 5 ? estimateProject(state) : null),
    [step, state],
  );

  const progress = ((step + 1) / STEPS.length) * 100;

  const canNext = () => {
    switch (step) {
      case 0:
        return !!state.projectType;
      case 1:
        return state.features.length > 0;
      case 2:
        return state.aiRequired !== null;
      case 3:
        return !!state.budget;
      case 4:
        return !!state.name.trim() && !!state.email.trim();
      default:
        return true;
    }
  };

  const toggleFeature = (f: string) => {
    setState((s) => ({
      ...s,
      features: s.features.includes(f)
        ? s.features.filter((x) => x !== f)
        : [...s.features, f],
    }));
  };

  const goToContact = () => {
    const params = new URLSearchParams({
      service: estimate?.serviceId ?? "",
      planner: "1",
    });
    router.push(`/contact?${params.toString()}`);
  };

  return (
    <div className="grid grid-cols-1 lg:grid-cols-12">
      {/* Step rail */}
      <aside className="hidden border-r border-border-subtle p-8 lg:col-span-4 lg:block lg:p-10">
        <p className="ms-stamp">Steps</p>
        <ol className="mt-6 space-y-1">
          {STEPS.map((label, i) => {
            const done = i < step;
            const active = i === step;
            return (
              <li
                key={label}
                className={cn(
                  "flex items-center gap-3 border-l-2 py-2.5 pl-4 text-sm",
                  active
                    ? "border-brand text-foreground"
                    : done
                      ? "border-brand/40 text-muted-foreground"
                      : "border-transparent text-muted-foreground/50",
                )}
              >
                <span className="font-mono text-[11px] tabular-nums">
                  {String(i + 1).padStart(2, "0")}
                </span>
                <span className={cn(active && "font-medium")}>{label}</span>
              </li>
            );
          })}
        </ol>
      </aside>

      {/* Wizard */}
      <div className="flex min-h-0 flex-col lg:col-span-8 lg:min-h-105">
        <div className="border-b border-border-subtle ms-shell-pad py-4">
          <div className="flex items-center justify-between gap-3">
            <p className="ds-mono-label">
              Step {step + 1} / {STEPS.length}
            </p>
            <p className="text-xs text-muted-foreground lg:hidden">
              {STEPS[step]}
            </p>
          </div>
          <div
            className="mt-3 h-px overflow-hidden bg-border-subtle"
            role="progressbar"
            aria-valuenow={Math.round(progress)}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-label={`Project planner progress: step ${step + 1} of ${STEPS.length}`}
          >
            <div
              className="h-full bg-brand transition-[width] duration-300"
              style={{ width: `${progress}%` }}
            />
          </div>
        </div>

        <div className="flex flex-1 flex-col p-6 sm:p-8 lg:p-10">
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={step}
              className="flex flex-1 flex-col"
              initial={reduce ? false : { opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? undefined : { opacity: 0, y: -8 }}
              transition={{ duration: 0.22, ease: [0.32, 0.72, 0, 1] }}
            >
          {step === 0 ? (
            <div className="space-y-4">
              <div>
                <h2 className="ds-headline text-2xl text-foreground">
                  What are you building?
                </h2>
                <p className="mt-2 text-sm text-muted-foreground">
                  MVP to validate fast, or a fuller product build?
                </p>
              </div>
              <div className="grid gap-3 sm:grid-cols-2">
                {(
                  [
                    {
                      id: "mvp" as const,
                      label: "MVP Sprint",
                      desc: "Validate in 7-14 days",
                    },
                    {
                      id: "full" as const,
                      label: "Full Build",
                      desc: "Complete product in 4-8 weeks",
                    },
                  ] as const
                ).map((opt) => (
                  <ChoiceButton
                    key={opt.id}
                    pressed={state.projectType === opt.id}
                    onClick={() =>
                      setState((s) => ({ ...s, projectType: opt.id }))
                    }
                    className="p-5"
                  >
                    <div className="font-medium text-foreground">
                      {opt.label}
                    </div>
                    <div className="mt-1 text-xs text-muted-foreground">
                      {opt.desc}
                    </div>
                  </ChoiceButton>
                ))}
              </div>
            </div>
          ) : null}

          {step === 1 ? (
            <div className="space-y-4">
              <div>
                <h2 className="ds-headline text-2xl text-foreground">
                  Key features needed
                </h2>
                <p className="mt-2 text-sm text-muted-foreground">
                  Select all that apply.
                </p>
              </div>
              <div className="flex flex-wrap gap-2">
                {FEATURE_OPTIONS.map((f) => (
                  <ChoiceButton
                    key={f}
                    pressed={state.features.includes(f)}
                    onClick={() => toggleFeature(f)}
                    className="rounded-md px-3.5 py-2 text-sm"
                  >
                    {f}
                  </ChoiceButton>
                ))}
              </div>
            </div>
          ) : null}

          {step === 2 ? (
            <div className="space-y-4">
              <div>
                <h2 className="ds-headline text-2xl text-foreground">
                  AI features required?
                </h2>
                <p className="mt-2 text-sm text-muted-foreground">
                  Streaming chat, document AI, RAG - or none for now.
                </p>
              </div>
              <div className="grid grid-cols-2 gap-3">
                {[true, false].map((val) => (
                  <ChoiceButton
                    key={String(val)}
                    pressed={state.aiRequired === val}
                    onClick={() =>
                      setState((s) => ({ ...s, aiRequired: val }))
                    }
                    className="p-4 text-center text-sm font-medium"
                  >
                    {val ? "Yes" : "No"}
                  </ChoiceButton>
                ))}
              </div>
              {state.aiRequired ? (
                <div className="border-t border-border-subtle pt-4">
                  <p className="text-xs font-medium text-foreground">
                    AI integration type
                  </p>
                  <div className="mt-2 flex flex-wrap gap-2">
                    {AI_TYPES.map((t) => (
                      <ChoiceButton
                        key={t}
                        pressed={state.aiType === t}
                        onClick={() => setState((s) => ({ ...s, aiType: t }))}
                        className="min-h-10 rounded-md px-3.5 py-2 text-xs"
                      >
                        {t}
                      </ChoiceButton>
                    ))}
                  </div>
                </div>
              ) : null}
            </div>
          ) : null}

          {step === 3 ? (
            <div className="space-y-4">
              <div>
                <h2 className="ds-headline text-2xl text-foreground">
                  Budget range
                </h2>
                <p className="mt-2 text-sm text-muted-foreground">
                  Ballpark only - we confirm on the call.
                </p>
              </div>
              <div className="divide-y divide-border-subtle border-y border-border-subtle">
                {BUDGETS.map((b) => (
                  <button
                    key={b.id}
                    type="button"
                    onClick={() => setState((s) => ({ ...s, budget: b.id }))}
                    aria-pressed={state.budget === b.id}
                    className={cn(
                      "flex w-full items-center justify-between px-1 py-4 text-left text-sm transition-colors active:scale-[0.99]",
                      state.budget === b.id
                        ? "font-medium text-foreground"
                        : "text-muted-foreground hover:text-foreground",
                    )}
                  >
                    <span>{b.label}</span>
                    {state.budget === b.id ? (
                      <span className="size-1.5 rounded-full bg-brand" aria-hidden />
                    ) : null}
                  </button>
                ))}
              </div>
            </div>
          ) : null}

          {step === 4 ? (
            <div className="space-y-5">
              <div>
                <h2 className="ds-headline text-2xl text-foreground">
                  Almost there
                </h2>
                <p className="mt-2 text-sm text-muted-foreground">
                  So we can send the estimate and follow up.
                </p>
              </div>
              <div className="flex flex-col gap-2">
                <label
                  htmlFor="planner-name"
                  className="text-xs font-medium text-foreground"
                >
                  Name
                </label>
                <input
                  id="planner-name"
                  type="text"
                  value={state.name}
                  onChange={(e) =>
                    setState((s) => ({ ...s, name: e.target.value }))
                  }
                  className={fieldInput}
                  placeholder="Your name"
                  autoComplete="name"
                  required
                />
              </div>
              <div className="flex flex-col gap-2">
                <label
                  htmlFor="planner-email"
                  className="text-xs font-medium text-foreground"
                >
                  Email
                </label>
                <input
                  id="planner-email"
                  type="email"
                  value={state.email}
                  onChange={(e) =>
                    setState((s) => ({ ...s, email: e.target.value }))
                  }
                  className={fieldInput}
                  placeholder="you@company.com"
                  autoComplete="email"
                  required
                />
              </div>
            </div>
          ) : null}

          {step === 5 && estimate ? (
            <div className="flex flex-col gap-8">
              <div>
                <div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-border-subtle bg-background text-brand">
                  <CheckCircle2 className="size-5" aria-hidden />
                </div>
                <p className="ms-stamp">Recommendation</p>
                <h2 className="ds-headline mt-3 text-2xl text-foreground sm:text-3xl">
                  {estimate.service}
                </h2>
                <p className="mt-3 max-w-md text-sm leading-relaxed text-muted-foreground">
                  {estimate.summary}
                </p>
              </div>

              <dl className="grid grid-cols-1 divide-y divide-border-subtle border-y border-border-subtle sm:grid-cols-2 sm:divide-x sm:divide-y-0">
                <div className="py-5 sm:pr-6">
                  <dt className="text-xs text-muted-foreground">
                    Estimated price
                  </dt>
                  <dd className="ds-display mt-2 text-xl break-words text-foreground sm:text-2xl md:text-3xl">
                    {estimate.priceRange}
                  </dd>
                </div>
                <div className="py-5 sm:pl-6">
                  <dt className="text-xs text-muted-foreground">Timeline</dt>
                  <dd className="ds-display mt-2 text-xl text-foreground sm:text-2xl md:text-3xl">
                    {estimate.timeline}
                  </dd>
                </div>
              </dl>

              <p className="text-xs text-muted-foreground">
                Ballpark only - final scope and price confirmed on the intro
                call.
              </p>

              <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:flex-wrap">
                <button type="button" onClick={goToContact} className="ms-cta">
                  Continue to contact
                  <ArrowRight className="size-4" aria-hidden />
                </button>
                <Link
                  href={buildOgHref({
                    name: estimate.service,
                    oneLiner: estimate.summary,
                    audience: "",
                    pages: state.features,
                    tone: "",
                    constraints: [
                      estimate.timeline,
                      estimate.priceRange,
                    ].filter(Boolean),
                    ogTitle: estimate.service,
                    ogSubtitle: estimate.summary,
                  } satisfies ProjectBrief)}
                  className="ms-cta-ghost"
                >
                  Preview social card
                </Link>
                <Link
                  href={`/services/${estimate.serviceId}`}
                  className="ms-cta-ghost"
                >
                  View service details
                </Link>
              </div>
            </div>
          ) : null}

          {step < 5 ? (
            <div className="mt-auto flex items-center justify-between gap-3 border-t border-border-subtle pt-6">
              <button
                type="button"
                onClick={() => setStep((s) => Math.max(0, s - 1))}
                disabled={step === 0}
                className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
              >
                <ArrowLeft className="size-3.5" aria-hidden />
                Back
              </button>
              <button
                type="button"
                onClick={() => setStep((s) => s + 1)}
                disabled={!canNext()}
                className="ms-cta min-w-[9.5rem] justify-center disabled:pointer-events-none disabled:opacity-50"
              >
                <AnimatePresence mode="wait" initial={false}>
                  <motion.span
                    key={step === 4 ? "estimate" : "continue"}
                    className="inline-flex items-center gap-1.5"
                    initial={reduce ? false : { opacity: 0, y: 4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={reduce ? undefined : { opacity: 0, y: -4 }}
                    transition={{ duration: 0.16 }}
                  >
                    {step === 4 ? "See estimate" : "Continue"}
                    <ArrowRight className="size-4" aria-hidden />
                  </motion.span>
                </AnimatePresence>
              </button>
            </div>
          ) : null}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}
```
