# Create form

> A new-project form with a URL derived from the name, visibility as radio cards, and validation that replaces a hint with its error. Renders in any Satori UI design system (cueit, skita, folio or a generated one): install a system first.

A shadcn/ui block from the [@satoriui](https://satoriui.site/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/satoriui/form-01).

- Page, with a live preview: https://shadcn.exchange/r/satoriui/form-01
- Screenshot: https://shadcn.exchange/shot/satoriui/form-01/light.png
- Registry item: https://satoriui.site/r/form-01.json
- Listed: 2026-11-04, source last changed 2026-11-04

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/satoriui/form-01.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`
- Registry items: `button`, `card`, `checkbox`, `input`, `label`, `radio-group`, `textarea`

## Files

### `design-systems/blocks/form-01.tsx`

```tsx
"use client";

import * as React from "react";
import { Globe, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";

/**
 * A "create project" form: name with a derived URL, description,
 * visibility as radio cards and an opt-in, with validation that replaces a
 * field's hint with its error.
 *
 * Written against the block contract only, so it takes the look of
 * whichever design system scopes it. `onCreate` receives the values once
 * they validate; without it the form only validates.
 */

export type ProjectValues = {
  name: string;
  slug: string;
  description: string;
  visibility: "private" | "public";
  invite: boolean;
};

const toSlug = (s: string) =>
  s
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 40);

export type Form01Props = Omit<React.ComponentProps<"form">, "onSubmit"> & {
  /** Shown before the slug, such as your app's domain. */
  urlPrefix?: string;
  onCreate?: (values: ProjectValues) => void | Promise<void>;
  onCancel?: () => void;
};

export function Form01({ urlPrefix = "northwind.app/", onCreate, onCancel, className, ...props }: Form01Props) {
  const [name, setName] = React.useState("");
  const [slug, setSlug] = React.useState("");
  const [slugEdited, setSlugEdited] = React.useState(false);
  const [errors, setErrors] = React.useState<Partial<Record<"name" | "slug", string>>>({});
  const [pending, setPending] = React.useState(false);
  const shownSlug = slugEdited ? slug : toSlug(name);

  async function submit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const next: typeof errors = {};
    if (name.trim().length < 2) next.name = "Give the project a name of at least two characters.";
    if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(shownSlug)) next.slug = "Use lowercase letters, numbers and single hyphens.";
    setErrors(next);
    if (Object.keys(next).length) return;
    setPending(true);
    try {
      await onCreate?.({
        name: name.trim(),
        slug: shownSlug,
        description: String(data.get("description") ?? ""),
        visibility: data.get("visibility") === "public" ? "public" : "private",
        invite: data.get("invite") === "on",
      });
    } finally {
      setPending(false);
    }
  }

  return (
    <form noValidate onSubmit={submit} className={cn("@container/form w-full max-w-2xl", className)} {...props}>
      <Card className="gap-6">
        <CardHeader>
          <CardTitle>New project</CardTitle>
          <CardDescription>You can change all of this later in settings.</CardDescription>
        </CardHeader>

        <CardContent className="flex flex-col gap-5">
          <div className="grid gap-5 @lg/form:grid-cols-2">
            <div className="flex flex-col gap-2">
              <Label htmlFor="project-name">Project name</Label>
              <Input
                id="project-name"
                name="name"
                value={name}
                onChange={(e) => setName(e.target.value)}
                placeholder="Marketing site"
                aria-invalid={errors.name ? true : undefined}
                aria-describedby="project-name-msg"
              />
              <p
                id="project-name-msg"
                className={cn("ds-small", errors.name ? "text-destructive" : "text-muted-foreground")}
              >
                {errors.name ?? "What your team will call it."}
              </p>
            </div>
            <div className="flex flex-col gap-2">
              <Label htmlFor="project-slug">URL</Label>
              <div className="flex items-center">
                <span className="ds-meta shrink-0 pr-2">{urlPrefix}</span>
                <Input
                  id="project-slug"
                  name="slug"
                  value={shownSlug}
                  onChange={(e) => {
                    setSlugEdited(true);
                    setSlug(e.target.value);
                  }}
                  placeholder="marketing-site"
                  aria-invalid={errors.slug ? true : undefined}
                  aria-describedby="project-slug-msg"
                />
              </div>
              <p
                id="project-slug-msg"
                className={cn("ds-small", errors.slug ? "text-destructive" : "text-muted-foreground")}
              >
                {errors.slug ?? "Filled in from the name until you edit it."}
              </p>
            </div>
          </div>

          <div className="flex flex-col gap-2">
            <Label htmlFor="project-description">
              Description <span className="font-normal text-muted-foreground">(optional)</span>
            </Label>
            <Textarea id="project-description" name="description" rows={3} placeholder="What is it for?" />
          </div>

          <fieldset className="flex flex-col gap-2">
            <legend className="ds-small mb-2 font-medium">Visibility</legend>
            <RadioGroup name="visibility" defaultValue="private" className="grid gap-3 @lg/form:grid-cols-2">
              {[
                { value: "private", label: "Private", hint: "Only people you invite.", icon: Lock },
                { value: "public", label: "Public", hint: "Anyone with the link can view.", icon: Globe },
              ].map(({ value, label, hint, icon: Icon }) => (
                <Label
                  key={value}
                  htmlFor={`visibility-${value}`}
                  className="ds-tile ds-hover flex cursor-pointer items-start gap-3 p-4"
                >
                  <span className="ds-well flex size-8 shrink-0 items-center justify-center">
                    <Icon className="size-4" aria-hidden="true" />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block">{label}</span>
                    <span className="ds-small mt-0.5 block font-normal text-muted-foreground">{hint}</span>
                  </span>
                  <RadioGroupItem id={`visibility-${value}`} value={value} className="mt-0.5" />
                </Label>
              ))}
            </RadioGroup>
          </fieldset>

          <div className="flex items-start gap-3">
            <Checkbox id="project-invite" name="invite" defaultChecked className="mt-0.5" />
            <div>
              <Label htmlFor="project-invite">Invite my team</Label>
              <p className="ds-small mt-0.5 text-muted-foreground">Everyone in Northwind gets an email.</p>
            </div>
          </div>
        </CardContent>

        <CardFooter className="justify-end gap-2 border-t pt-6">
          <Button type="button" variant="ghost" onClick={onCancel}>
            Cancel
          </Button>
          <Button type="submit" disabled={pending}>
            {pending ? "Creating…" : "Create project"}
          </Button>
        </CardFooter>
      </Card>
    </form>
  );
}
```
