Skip to content
Domphy

signup03

A Auth block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call signup03() with no arguments for a working demo, or edit the code below live.

Props

PropTypeDescription
companyNamestring
logoHrefstring
titlestring
subtitlestring
fullNameLabelstring
fullNamePlaceholderstring
emailLabelstring
emailPlaceholderstring
passwordLabelstring
confirmPasswordLabelstring
passwordCaptionstring
submitLabelstring
signInPromptstring
signInLinkTextstring
signInHrefstring
termsHrefstring
privacyHrefstring
onSubmit(event: SubmitEvent) => void
Implementation notes

Single-column form on a full-viewport muted background (dataTone='shift-2' edge-anchor on the root, with the required literal backgroundColor+color pair for doctor's dataTone-surface-contract). Centered logo row, uncarded content block, Full Name, Email(+caption), then Password/Confirm Password as a 2-column CSS grid sharing one caption below the grid, solid submit button, sign-in footer line, and a centered legal line (Terms of Service / Privacy Policy) below the block — matching the researchNote that this is the only variant with no social button and the only one (besides signup04) with the legal disclaimer. Same authFieldInput() local patch as signup01/02. Direct-source-diff fix (2026-07-05): Rendered uncarded on the muted page — upstream signup-03 wraps the header+form in a visible Card, matching its sibling login03. Wrapped in card({color:'neutral'}).

Status: ported · Reference: shadcn/ui original

import type { DomphyElement, Listener, PartialElement } from "@domphy/core";
import { rawHtml } from "@domphy/core";
import {
  themeColor,
  themeDensity,
  themeFluidSpacing,
  themeSize,
  themeSpacing,
} from "@domphy/theme";
import {
  button,
  card,
  heading,
  icon,
  label,
  link,
  paragraph,
  small,
} from "@domphy/ui";
import { fixed } from "../../shared/typography.js";

// Generic monochrome mark — an original, brand-neutral logo glyph placeholder.
const LOGO_ICON =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor">' +
  '<path d="M12 3l8 16H4z" />' +
  "</svg>";

/**
 * Visual formula for a bounded text-like `<input>`, matching @domphy/ui's
 * `inputText()` patch. Written as a local patch instead of reusing
 * `inputText()` directly because that patch forces `type="text"` via
 * `_onSchedule`, which would silently unmask `type="password"`/`"email"`
 * fields — see the port's fidelity notes.
 */
function authFieldInput(): PartialElement {
  return {
    style: {
      fontFamily: fixed("inherit"),
      lineHeight: fixed("inherit"),
      width: "100%",
      boxSizing: "border-box",
      paddingInline: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 3),
      paddingBlock: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 1),
      borderRadius: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 1),
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      border: "none",
      outlineOffset: "-1px",
      outline: (listener: Listener) =>
        `1px solid ${themeColor(listener, "shift-4", "neutral")}`,
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      "&::placeholder": {
        color: (listener: Listener) =>
          themeColor(listener, "shift-7", "neutral"),
      },
      "&:hover:not([disabled]), &:focus-visible": {
        outline: (listener: Listener) =>
          `${themeSpacing(0.5)} solid ${themeColor(listener, "shift-6", "neutral")}`,
      },
      "&[disabled]": {
        opacity: 0.7,
        cursor: "not-allowed",
        backgroundColor: (listener: Listener) =>
          themeColor(listener, "shift-2", "neutral"),
      },
    },
  };
}

interface FieldConfig {
  id: string;
  labelText: string;
  type?: "text" | "email" | "password";
  placeholder?: string;
}

function field(config: FieldConfig): DomphyElement<"div"> {
  const { id, labelText, type = "text", placeholder } = config;
  return {
    div: [
      { label: labelText, for: id, $: [label()] },
      {
        input: null,
        id,
        name: id,
        type,
        placeholder,
        required: true,
        ...(type === "password" ? { minlength: 8 } : {}),
        $: [authFieldInput()],
      },
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
    },
  };
}

function logoMark(): DomphyElement<"span"> {
  return {
    span: [{ span: rawHtml(LOGO_ICON), $: [icon({ color: "inherit" })] }],
    dataTone: "shift-16",
    style: {
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      width: (listener: Listener) => themeSpacing(themeDensity(listener) * 5),
      height: (listener: Listener) => themeSpacing(themeDensity(listener) * 5),
      borderRadius: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 1),
      flexShrink: 0,
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
    },
  };
}

function logoRow(companyName: string, href: string): DomphyElement<"a"> {
  return {
    a: [logoMark(), companyName],
    href,
    $: [link({ color: "neutral" })],
    style: {
      display: "inline-flex",
      alignItems: "center",
      // Upstream logo link is font-medium (500) across the whole anchor,
      // including the "Acme Inc." wordmark — not a bold <strong>.
      fontWeight: fixed(500),
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
    },
  };
}

function legalLine(
  termsHref: string,
  privacyHref: string,
): DomphyElement<"small"> {
  return {
    small: [
      "By clicking continue, you agree to our ",
      {
        a: "Terms of Service",
        href: termsHref,
        style: { textDecoration: fixed("underline") },
        $: [link({ color: "primary" })],
      },
      " and ",
      {
        a: "Privacy Policy",
        href: privacyHref,
        style: { textDecoration: fixed("underline") },
        $: [link({ color: "primary" })],
      },
      ".",
    ],
    $: [small({ color: "neutral" })],
    // Upstream legal FieldDescription is `px-6 text-center` — the px-6 inset
    // keeps the copy narrower than the card above it.
    style: {
      display: "block",
      textAlign: "center",
      boxSizing: "border-box",
      width: "100%",
      maxWidth: themeSpacing(96),
      paddingInline: themeSpacing(6),
    },
  };
}

/** Props for {@link signup03}. */
export interface Signup03Props {
  companyName?: string;
  logoHref?: string;
  title?: string;
  subtitle?: string;
  fullNameLabel?: string;
  fullNamePlaceholder?: string;
  emailLabel?: string;
  emailPlaceholder?: string;
  passwordLabel?: string;
  confirmPasswordLabel?: string;
  passwordCaption?: string;
  submitLabel?: string;
  signInPrompt?: string;
  signInLinkText?: string;
  signInHref?: string;
  termsHref?: string;
  privacyHref?: string;
  onSubmit?: (event: SubmitEvent) => void;
}

/**
 * shadcn/ui "signup-03" — a single-column signup form centered on a
 * full-viewport muted page background, with a centered logo above a card
 * containing the form and a legal-links line beneath it.
 */
function signup03(props: Signup03Props = {}): DomphyElement<"div"> {
  const {
    companyName = "Acme Inc.",
    logoHref = "#",
    title = "Create your account",
    subtitle = "Enter your email below to create your account",
    fullNameLabel = "Full Name",
    fullNamePlaceholder = "John Doe",
    emailLabel = "Email",
    emailPlaceholder = "m@example.com",
    passwordLabel = "Password",
    confirmPasswordLabel = "Confirm Password",
    passwordCaption = "Must be at least 8 characters long.",
    submitLabel = "Create Account",
    signInPrompt = "Already have an account?",
    signInLinkText = "Sign in",
    signInHref = "#",
    termsHref = "#",
    privacyHref = "#",
    onSubmit,
  } = props;

  const submitButton: DomphyElement<"button"> = {
    button: submitLabel,
    type: "submit",
    dataTone: "shift-17",
    $: [button({ color: "neutral" })],
    style: {
      width: "100%",
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
    },
  };

  const footerLine: DomphyElement<"small"> = {
    small: [
      `${signInPrompt} `,
      {
        a: signInLinkText,
        href: signInHref,
        style: { textDecoration: fixed("underline") },
        $: [link({ color: "primary" })],
      },
    ],
    $: [small({ color: "neutral" })],
    // Upstream FieldDescription for the sign-in line is text-center.
    style: { textAlign: "center" },
  };

  const passwordGrid: DomphyElement<"div"> = {
    div: [
      {
        div: [
          field({
            id: "signup03-password",
            labelText: passwordLabel,
            type: "password",
          }),
        ],
      },
      {
        div: [
          field({
            id: "signup03-confirm-password",
            labelText: confirmPasswordLabel,
            type: "password",
          }),
        ],
      },
    ],
    style: {
      display: "grid",
      gridTemplateColumns: "1fr",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      // Stack the password fields on narrow screens; split into 2 columns
      // from the `sm` breakpoint up (same idiom as signup05's provider row).
      "@media (min-width: 40em)": {
        gridTemplateColumns: "1fr 1fr",
      },
    },
  };

  // Upstream nests the 2-col password grid and its "Must be at least 8
  // characters long." caption inside ONE <Field> (tight gap-3), so the caption
  // reads as attached to the grid rather than sitting at the full inter-field
  // gap. Mirror that with the same tight-group gap used below for submit+sign-in.
  const passwordGroup: DomphyElement<"div"> = {
    div: [
      passwordGrid,
      { small: passwordCaption, $: [small({ color: "neutral" })] },
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
    },
  };

  // Upstream nests the submit Button and the "Sign in" FieldDescription in
  // their own <Field> (gap-3) — tighter than the FieldGroup gap separating
  // this cluster from the fields above — and keeps both inside the <form>.
  const formElement: DomphyElement<"form"> = {
    form: [
      field({
        id: "signup03-name",
        labelText: fullNameLabel,
        placeholder: fullNamePlaceholder,
      }),
      field({
        id: "signup03-email",
        labelText: emailLabel,
        type: "email",
        placeholder: emailPlaceholder,
      }),
      passwordGroup,
      {
        div: [submitButton, footerLine],
        style: {
          display: "flex",
          flexDirection: "column",
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
        },
      },
    ],
    onSubmit: (event: Event) => {
      event.preventDefault();
      onSubmit?.(event as SubmitEvent);
    },
    style: {
      display: "flex",
      flexDirection: "column",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
    },
  };

  // Upstream signup-03 wraps its header + form in a visible Card (with a
  // centered header), sitting on the muted page between the logo above and
  // the legal line below — mirror that instead of rendering the form uncarded.
  const cardElement: DomphyElement<"div"> = {
    div: [
      // Upstream CardTitle is text-xl (fontSizes[3]); heading() adds increase-3
      // to the base size (index 2 → 5), so dataSize decrease-2 pulls it back to 3.
      {
        h2: title,
        $: [heading()],
        dataSize: "decrease-2",
        style: { textAlign: "center" },
      },
      // Upstream CardDescription is text-sm (fontSizes[1]); decrease-1 from base.
      {
        p: subtitle,
        $: [paragraph({ color: "neutral" })],
        dataSize: "decrease-1",
        style: { textAlign: "center" },
      },
      { div: [formElement] },
    ],
    $: [card({ color: "neutral" })],
    // White card surface (upstream bg-card) so the card separates from the
    // muted shift-1 page — card() paints an inherit-tone background; restate
    // bg+color so the dataTone surface contract is self-contained on this host.
    dataTone: "shift-0",
    style: {
      width: "100%",
      maxWidth: themeSpacing(96),
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
    },
  };

  return {
    div: [
      logoRow(companyName, logoHref),
      cardElement,
      legalLine(termsHref, privacyHref),
    ],
    dataTone: "shift-1",
    style: {
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      justifyContent: "center",
      minHeight: "100vh",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 5),
      paddingInline: themeFluidSpacing(4, 12),
      paddingBlock: themeFluidSpacing(4, 12),
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
    },
  };
}

export { signup03 };

← Back to shadcn/ui catalog