Skip to content
Domphy

signup04

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

Props

PropTypeDescription
titlestring
subtitlestring
emailLabelstring
emailPlaceholderstring
emailCaptionstring
passwordLabelstring
confirmPasswordLabelstring
passwordCaptionstring
submitLabelstring
providersSocialProvider[]
decorativeImageSrcstring
signInPromptstring
signInLinkTextstring
signInHrefstring
termsHrefstring
privacyHrefstring
onSubmit(event: SubmitEvent) => void
Implementation notes

Form + decorative-image combined into one contained panel (dataTone='shift-0' edge anchor, its own border/shadow-like outline, rounded corners) on a muted page background (dataTone='shift-2' on the page root). Panel is 1-col on mobile, 2-col at @media(min-width); panel max-width expands from themeSpacing(96)=24em to themeSpacing(224)=56em at the same breakpoint, both pure CSS, no JS. Email field, Password/Confirm as a 2-col grid with shared caption, solid submit, 'Or continue with' divider, and a 3-column row of ICON-ONLY outline buttons for Apple/Google/Meta (configurable via a providers prop, each item gets a stable _key). Legal line below the panel. No logo row (the spec's domSketch and summary don't include one for this variant, unlike 02/03/05). Provider glyphs are original brand-neutral letter-badge SVGs, not brand marks. Same authFieldInput() local patch as the others.

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,
  divider,
  heading,
  icon,
  label,
  link,
  paragraph,
  small,
} from "@domphy/ui";
import { fixed } from "../../shared/typography.js";

// Generic monochrome letter-badge glyphs — original, brand-neutral placeholders.
// Swap for official brand SVGs in production.
function letterBadgeIcon(letter: string): string {
  return (
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" ' +
    'fill="none" stroke="currentColor" stroke-width="1.5">' +
    '<circle cx="12" cy="12" r="9.25" />' +
    `<text x="12" y="16" text-anchor="middle" font-size="10" stroke="none" fill="currentColor">${letter}</text>` +
    "</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;
  caption?: string;
}

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

function legalLine(
  termsHref: string,
  privacyHref: string,
): DomphyElement<"small"> {
  return {
    small: [
      "By continuing, 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" })],
    style: { display: "block", textAlign: "center" },
  };
}

/** One entry in the social-provider row. */
export interface SocialProvider {
  id: string;
  label: string;
  /** Raw inline SVG string. Defaults to a generic letter-badge glyph. */
  iconSvg?: string;
  onClick?: () => void;
}

function providerButton(provider: SocialProvider): DomphyElement<"button"> {
  return {
    button: [
      {
        span: rawHtml(
          provider.iconSvg ?? letterBadgeIcon(provider.label.charAt(0)),
        ),
        $: [icon({ color: "inherit" })],
      },
    ],
    type: "button",
    ariaLabel: `Sign up with ${provider.label}`,
    // Only attach onClick when a handler is given — Domphy requires event
    // props, when present, to resolve to an actual function (even `undefined`
    // throws), so an unset handler must omit the key entirely.
    ...(provider.onClick ? { onClick: provider.onClick } : {}),
    $: [button({ color: "neutral" })],
    style: { width: "100%" },
    _key: provider.id,
  };
}

/** Props for {@link signup04}. */
export interface Signup04Props {
  title?: string;
  subtitle?: string;
  emailLabel?: string;
  emailPlaceholder?: string;
  emailCaption?: string;
  passwordLabel?: string;
  confirmPasswordLabel?: string;
  passwordCaption?: string;
  submitLabel?: string;
  providers?: SocialProvider[];
  decorativeImageSrc?: string;
  signInPrompt?: string;
  signInLinkText?: string;
  signInHref?: string;
  termsHref?: string;
  privacyHref?: string;
  onSubmit?: (event: SubmitEvent) => void;
}

const DEFAULT_PROVIDERS: SocialProvider[] = [
  { id: "apple", label: "Apple" },
  { id: "google", label: "Google" },
  { id: "meta", label: "Meta" },
];

/**
 * shadcn/ui "signup-04" — an email/password signup form combined with a
 * decorative image inside one contained panel, on a muted page background,
 * offering a row of social-provider icon buttons.
 */
function signup04(props: Signup04Props = {}): DomphyElement<"div"> {
  const {
    title = "Create your account",
    subtitle = "Enter your email below to create your account",
    emailLabel = "Email",
    emailPlaceholder = "m@example.com",
    emailCaption = "We'll never share your email with anyone else.",
    passwordLabel = "Password",
    confirmPasswordLabel = "Confirm Password",
    passwordCaption = "Must be at least 8 characters long.",
    submitLabel = "Create Account",
    providers = DEFAULT_PROVIDERS,
    decorativeImageSrc = "https://images.unsplash.com/photo-1522199755839-a2bacb67c546?w=1200&q=80",
    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 passwordGrid: DomphyElement<"div"> = {
    div: [
      {
        div: [
          field({
            id: "signup04-password",
            labelText: passwordLabel,
            type: "password",
          }),
        ],
      },
      {
        div: [
          field({
            id: "signup04-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",
      },
    },
  };

  const providerRow: DomphyElement<"div"> = {
    div: providers.map((provider) => providerButton(provider)),
    style: {
      display: "grid",
      gridTemplateColumns: "1fr",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      // Stack the provider buttons on mobile and only split into columns
      // from the `sm` breakpoint up (signup05 already does this).
      "@media (min-width: 40em)": {
        gridTemplateColumns: `repeat(${providers.length}, 1fr)`,
      },
    },
  };

  const formElement: DomphyElement<"form"> = {
    form: [
      field({
        id: "signup04-email",
        labelText: emailLabel,
        type: "email",
        placeholder: emailPlaceholder,
        caption: emailCaption,
      }),
      passwordGrid,
      { small: passwordCaption, $: [small({ color: "neutral" })] },
      submitButton,
      { div: "Or continue with", $: [divider({ color: "neutral" })] },
      providerRow,
    ],
    onSubmit: (event: Event) => {
      event.preventDefault();
      onSubmit?.(event as SubmitEvent);
    },
    style: {
      display: "flex",
      flexDirection: "column",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
    },
  };

  const footerLine: DomphyElement<"small"> = {
    small: [
      `${signInPrompt} `,
      {
        a: signInLinkText,
        href: signInHref,
        style: { textDecoration: fixed("underline") },
        $: [link({ color: "primary" })],
      },
    ],
    $: [small({ color: "neutral" })],
    style: { display: "block", textAlign: "center" },
  };

  const formSide: DomphyElement<"div"> = {
    div: [
      { h1: title, $: [heading()], style: { textAlign: "center" } },
      {
        p: subtitle,
        $: [paragraph({ color: "neutral" })],
        style: { textAlign: "center" },
      },
      formElement,
      footerLine,
    ],
    style: {
      padding: (listener: Listener) => themeSpacing(themeDensity(listener) * 6),
      display: "flex",
      flexDirection: "column",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
    },
  };

  const imageSide: DomphyElement<"div"> = {
    div: [
      {
        img: null,
        src: decorativeImageSrc,
        alt: "",
        style: {
          position: "absolute",
          inset: "0",
          width: "100%",
          height: "100%",
          objectFit: "cover",
          "@media (prefers-color-scheme: dark)": {
            filter: "brightness(0.2) grayscale(1)",
          },
        },
      },
    ],
    style: {
      position: "relative",
      display: "none",
      "@media(min-width:768px)": { display: "block" },
    },
  };

  const panel: DomphyElement<"div"> = {
    div: [formSide, imageSide],
    dataTone: "shift-0",
    style: {
      display: "grid",
      gridTemplateColumns: "1fr",
      overflow: "hidden",
      borderRadius: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 2),
      outline: (listener: Listener) =>
        `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
      "@media(min-width:768px)": { gridTemplateColumns: "1fr 1fr" },
    },
  };

  const panelWrap: DomphyElement<"div"> = {
    div: [panel],
    style: {
      width: "100%",
      maxWidth: themeSpacing(96),
      "@media(min-width:768px)": { maxWidth: themeSpacing(224) },
    },
  };

  return {
    div: [panelWrap, 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 { signup04 };

← Back to shadcn/ui catalog