Skip to content
Domphy

Login04

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

Props

PropTypeDescription
brandNamestring
headingstring
descriptionstring
emailLabelstring
emailPlaceholderstring
passwordLabelstring
forgotPasswordHrefstring
primaryButtonLabelstring
dividerTextstring
appleAccessibleLabelstring
onAppleClick() => void
googleAccessibleLabelstring
onGoogleClick() => void
metaAccessibleLabelstring
onMetaClick() => void
signUpPromptstring
signUpLabelstring
signUpHrefstring
termsLabelstring
termsHrefstring
privacyLabelstring
privacyHrefstring
coverImageSrcstring
coverImageAltstring
dimCoverInDarkModeboolean
onSubmit(values: { email: string; password: string }) => void
Implementation notes

Two-column card frame (form | cover photo) on a muted page background; card surface is hand-styled (radius/outline/overflow matching card()'s own formula) rather than using the card() patch directly, because card()'s fixed grid-template-areas can't give the image side zero padding while the form side keeps its own — documented in the file's header comment. Three-provider OAuth row (Apple/Google/Meta), legal footer below the card. Doctor diagnose(): 0 findings.

Status: ported · Reference: shadcn/ui original

// shadcn/ui "login-04" block — clean-room reimplementation.
//
// A two-column sign-in layout wrapped inside a single visible card frame:
// form on the left, a full-bleed cover photo on the right (hidden below a
// breakpoint, at which point the card collapses to one column). Three OAuth
// providers plus the classic email/password form, and a legal disclaimer
// footer below the card. See ./login01-05-shared.ts for the reusable
// field/button/divider pieces.
//
// The card frame here is hand-styled (not `card()`'s patch) because that
// patch's fixed grid-template-areas (image/title/aside/desc/content/footer,
// each a single full-width row) can't express a form|image split where only
// the image side is edge-to-edge with zero padding — the two areas need
// independent padding, which `card()`'s single shared "content" padding
// can't give them. The surface treatment (radius/outline/overflow) mirrors
// `card()`'s own formula for visual consistency with the rest of the
// design system.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream shadcn/ui source was viewed or copied.

import type { DomphyElement, Listener } from "@domphy/core";
import {
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
} from "@domphy/theme";
import { heading, paragraph } from "@domphy/ui";
import {
  coverImage,
  coverPanel,
  dividerRow,
  emailField,
  legalFooter,
  MOBILE_MEDIA_QUERY,
  NARROW_CARD_WIDTH,
  oauthButton,
  passwordField,
  signUpLine,
  submitButton,
} from "./login01-05-shared.js";

/** Props for {@link Login04}. */
export interface Login04Props {
  brandName?: string;
  heading?: string;
  description?: string;
  emailLabel?: string;
  emailPlaceholder?: string;
  passwordLabel?: string;
  forgotPasswordHref?: string;
  primaryButtonLabel?: string;
  dividerText?: string;
  appleAccessibleLabel?: string;
  onAppleClick?: () => void;
  googleAccessibleLabel?: string;
  onGoogleClick?: () => void;
  metaAccessibleLabel?: string;
  onMetaClick?: () => void;
  signUpPrompt?: string;
  signUpLabel?: string;
  signUpHref?: string;
  termsLabel?: string;
  termsHref?: string;
  privacyLabel?: string;
  privacyHref?: string;
  coverImageSrc?: string;
  coverImageAlt?: string;
  dimCoverInDarkMode?: boolean;
  onSubmit?: (values: { email: string; password: string }) => void;
}

/**
 * shadcn/ui "login-04" — two-column card frame (form + cover photo) on a
 * muted page background, with three OAuth providers. Call with no
 * arguments for a fully working demo.
 */
function Login04(props: Login04Props = {}): DomphyElement<"div"> {
  const {
    brandName = "Acme Inc.",
    heading: headingText = "Welcome back",
    description = `Login to your ${brandName} account`,
    emailLabel = "Email",
    emailPlaceholder = "m@example.com",
    passwordLabel = "Password",
    forgotPasswordHref = "#",
    primaryButtonLabel = "Login",
    dividerText = "Or continue with",
    appleAccessibleLabel = "Login with Apple",
    onAppleClick,
    googleAccessibleLabel = "Login with Google",
    onGoogleClick,
    metaAccessibleLabel = "Login with Meta",
    onMetaClick,
    signUpPrompt = "Don't have an account?",
    signUpLabel = "Sign up",
    signUpHref = "#",
    termsLabel = "Terms of Service",
    termsHref = "#",
    privacyLabel = "Privacy Policy",
    privacyHref = "#",
    coverImageSrc,
    coverImageAlt = "",
    dimCoverInDarkMode = true,
    onSubmit,
  } = props;

  const oauthRow: DomphyElement<"div"> = {
    div: [
      oauthButton({
        brand: "apple",
        accessibleLabel: appleAccessibleLabel,
        onClick: onAppleClick,
      }),
      oauthButton({
        brand: "google",
        accessibleLabel: googleAccessibleLabel,
        onClick: onGoogleClick,
      }),
      oauthButton({
        brand: "meta",
        accessibleLabel: metaAccessibleLabel,
        onClick: onMetaClick,
      }),
    ],
    style: {
      display: "grid",
      gridTemplateColumns: "repeat(3, 1fr)",
      gap: themeSpacing(4),
    },
  };

  const emailFieldRow = emailField({
    id: "login04-email",
    fieldLabel: emailLabel,
    placeholder: emailPlaceholder,
  });

  const passwordFieldRow = passwordField({
    id: "login04-password",
    fieldLabel: passwordLabel,
    forgotPasswordHref,
  });

  // Upstream wraps title + description in one centered
  // `flex flex-col items-center gap-2 text-center` block (a single FieldGroup
  // item), so the gap-7 field rhythm sits below the pair — not between them.
  const headingGroup: DomphyElement<"div"> = {
    div: [
      {
        // Upstream is <h1 class="text-2xl font-bold"> (h1 is bold by default).
        // `increase-2` is the theme step nearest text-2xl (1.5rem); it
        // overrides heading()'s h1 default (increase-4) and its bottom margin
        // so the group's gap-2 is the only title->description spacing.
        h1: headingText,
        $: [heading()],
        // `heading()` already sets `style.color` — the doctor tool inspects
        // only this element's own inline style, not patch contributions, so
        // it can't see that and flags a false positive here.
        // (`_doctorDisable` is a doctor-only annotation absent from core's
        // strict element type — build through an untyped literal + cast.)
        _doctorDisable: "missing-color",
        style: {
          fontSize: (listener: Listener) => themeSize(listener, "increase-2"),
          marginBottom: 0,
        },
      } as DomphyElement<"h1">,
      { p: description, $: [paragraph({ color: "neutral" })] },
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      gap: themeSpacing(2),
      textAlign: "center",
    },
  };

  const formColumn: DomphyElement<"div"> = {
    div: [
      {
        form: [
          headingGroup,
          emailFieldRow,
          passwordFieldRow,
          submitButton(primaryButtonLabel),
          dividerRow(dividerText),
          oauthRow,
          signUpLine({
            promptText: signUpPrompt,
            linkLabel: signUpLabel,
            href: signUpHref,
          }),
        ],
        onSubmit: (event) => {
          event.preventDefault();
          const data = new FormData(event.target as HTMLFormElement);
          onSubmit?.({
            email: String(data.get("email") ?? ""),
            password: String(data.get("password") ?? ""),
          });
        },
        style: {
          display: "flex",
          flexDirection: "column",
          gap: themeSpacing(7),
        },
      },
    ],
    style: {
      // Upstream form is p-6 md:p-8 — stepped at the md breakpoint, not a
      // continuously density-scaled pad.
      padding: themeSpacing(6),
      "@media (min-width: 48em)": { padding: themeSpacing(8) },
      minWidth: "0",
    },
  };

  const imageColumn: DomphyElement<"div"> = {
    div: [
      // Clean-room: no external photo is hotlinked by default — a caller-
      // supplied coverImageSrc renders the photo, otherwise an understated
      // theme-token dot-grid panel fills the column (see coverPanel()).
      coverImageSrc
        ? coverImage({
            src: coverImageSrc,
            alt: coverImageAlt,
            dimInDarkMode: dimCoverInDarkMode,
          })
        : coverPanel(),
    ],
    style: {
      minWidth: "0",
      display: "flex",
      [MOBILE_MEDIA_QUERY]: { display: "none" },
    },
  };

  const cardFrame: DomphyElement<"div"> = {
    div: [formColumn, imageColumn],
    // White card surface (upstream bg-card) so the frame separates from the
    // muted shift-1 page background.
    dataTone: "shift-0",
    style: {
      display: "grid",
      gridTemplateColumns: "1fr 1fr",
      overflow: "hidden",
      borderRadius: (listener: Listener) =>
        themeSpacing(themeDensity(listener) * 2),
      outline: (listener: Listener) =>
        `1px solid ${themeColor(listener, "shift-4", "neutral")}`,
      outlineOffset: "-1px",
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) =>
        themeColor(listener, "shift-10", "neutral"),
      width: "100%",
      [MOBILE_MEDIA_QUERY]: { gridTemplateColumns: "1fr" },
    },
  };

  const legalFooterNode = legalFooter({
    termsLabel,
    termsHref,
    privacyLabel,
    privacyHref,
  });
  // Upstream footer FieldDescription is px-6 text-center.
  legalFooterNode.style = {
    ...legalFooterNode.style,
    paddingInline: themeSpacing(6),
  };

  return {
    div: [
      {
        // Upstream page wrapper: `w-full max-w-sm md:max-w-4xl`, stacking the
        // card and the legal footer with gap-6 (LoginForm's own
        // `flex flex-col gap-6`). Caps at 384px below md and 896px at md+ —
        // not a single fixed ~800px width for both bands.
        div: [cardFrame, legalFooterNode],
        style: {
          width: "100%",
          maxWidth: NARROW_CARD_WIDTH,
          display: "flex",
          flexDirection: "column",
          gap: themeSpacing(6),
          "@media (min-width: 48em)": { maxWidth: themeSpacing(224) },
        },
      },
    ],
    dataTone: "shift-1",
    style: {
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      justifyContent: "center",
      minHeight: "100dvh",
      padding: themeSpacing(6),
      "@media (min-width: 48em)": { padding: themeSpacing(10) },
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
    },
  };
}

export { Login04 };

← Back to shadcn/ui catalog