Skip to content
Domphy

Login02

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

Props

PropTypeDescription
brandNamestring
headingstring
descriptionstring
emailLabelstring
emailPlaceholderstring
passwordLabelstring
forgotPasswordHrefstring
primaryButtonLabelstring
dividerTextstring
githubButtonLabelstring
onGithubClick() => void
signUpPromptstring
signUpLabelstring
signUpHrefstring
coverImageSrcstring
coverImageAltstring
dimCoverInDarkModeboolean
onSubmit(values: { email: string; password: string }) => void
Implementation notes

Full page-level 2-col grid (brand row + centered form column | full-bleed cover photo), image column hidden and grid collapses to 1 col at max-width 47.9375em via @media. Dark-mode image dimming via prefers-color-scheme media query (brightness+saturate filter), discrete not animated. GitHub glyph is a simplified hand-authored octocat-style silhouette, original geometry. Doctor diagnose(): 0 findings. Direct-source-diff fix (2026-07-05): Cover-image column collapsed at the wrong breakpoint (md/768px) — upstream login-02 collapses at lg/1024px. Added a dedicated WIDE_SPLIT_MEDIA_QUERY and switched this recipe to it.

Status: ported · Reference: shadcn/ui original

// shadcn/ui "login-02" block — clean-room reimplementation.
//
// A split-screen sign-in page: the form fills the left half of the
// viewport, a full-bleed cover photo fills the right half. The image
// column is hidden below a mid-size breakpoint, leaving only the form.
// See ./login01-05-shared.ts for the reusable field/button/divider pieces.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream shadcn/ui source was viewed or copied.

import type { DomphyElement } from "@domphy/core";
import { themeSpacing } from "@domphy/theme";
import { heading, paragraph } from "@domphy/ui";
import { fixed } from "../../shared/typography.js";
import {
  brandBadge,
  coverImage,
  coverPanel,
  dividerRow,
  emailField,
  oauthButton,
  passwordField,
  signUpLine,
  submitButton,
  WIDE_SPLIT_MEDIA_QUERY,
} from "./login01-05-shared.js";

/** Props for {@link Login02}. */
export interface Login02Props {
  brandName?: string;
  heading?: string;
  description?: string;
  emailLabel?: string;
  emailPlaceholder?: string;
  passwordLabel?: string;
  forgotPasswordHref?: string;
  primaryButtonLabel?: string;
  dividerText?: string;
  githubButtonLabel?: string;
  onGithubClick?: () => void;
  signUpPrompt?: string;
  signUpLabel?: string;
  signUpHref?: string;
  coverImageSrc?: string;
  coverImageAlt?: string;
  dimCoverInDarkMode?: boolean;
  onSubmit?: (values: { email: string; password: string }) => void;
}

/**
 * shadcn/ui "login-02" — split-screen sign-in with a full-bleed cover photo.
 * Call with no arguments for a fully working demo.
 */
function Login02(props: Login02Props = {}): DomphyElement<"div"> {
  const {
    brandName = "Acme Inc.",
    heading: headingText = "Login to your account",
    description = "Enter your email below to login to your account",
    emailLabel = "Email",
    emailPlaceholder = "m@example.com",
    passwordLabel = "Password",
    forgotPasswordHref = "#",
    primaryButtonLabel = "Login",
    dividerText = "Or continue with",
    githubButtonLabel = "Login with GitHub",
    onGithubClick,
    signUpPrompt = "Don't have an account?",
    signUpLabel = "Sign up",
    signUpHref = "#",
    coverImageSrc,
    coverImageAlt = "",
    dimCoverInDarkMode = true,
    onSubmit,
  } = props;

  // Upstream wraps the badge + wordmark in a clickable `<a href="#"
  // className="flex items-center gap-2 font-medium">` — a link, medium
  // weight (not a bold non-interactive div). `color`/`textDecoration` reset
  // the browser's default anchor styling so the wordmark reads as plain
  // foreground text, matching the source.
  const brandRow: DomphyElement<"a"> = {
    a: [brandBadge(), brandName],
    href: "#",
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      gap: themeSpacing(2),
      fontWeight: fixed("500"),
      color: "inherit",
      textDecoration: fixed("none"),
      "@media (min-width: 48em)": { justifyContent: "flex-start" },
    },
  };

  const formBlock: DomphyElement<"form"> = {
    form: [
      // Upstream groups the h1 + p in a
      // `<div className="flex flex-col items-center gap-1 text-center">`
      // so both are centered with a 4px (gap-1) gap.
      {
        div: [
          { h1: headingText, $: [heading()] },
          { p: description, $: [paragraph({ color: "neutral" })] },
        ],
        style: {
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          textAlign: "center",
          gap: themeSpacing(1),
        },
      },
      emailField({
        id: "login02-email",
        fieldLabel: emailLabel,
        placeholder: emailPlaceholder,
      }),
      passwordField({
        id: "login02-password",
        fieldLabel: passwordLabel,
        forgotPasswordHref,
      }),
      submitButton(primaryButtonLabel),
      dividerRow(dividerText),
      // Upstream keeps the GitHub button and the sign-up line inside a single
      // `<Field>` (flex-col gap-3 = 12px), so they sit closer together than
      // the 28px field-group rhythm around them.
      {
        div: [
          oauthButton({
            brand: "github",
            visibleLabel: githubButtonLabel,
            accessibleLabel: githubButtonLabel,
            onClick: onGithubClick,
          }),
          signUpLine({
            promptText: signUpPrompt,
            linkLabel: signUpLabel,
            href: signUpHref,
          }),
        ],
        style: {
          display: "flex",
          flexDirection: "column",
          gap: themeSpacing(3),
        },
      },
    ],
    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",
      // Upstream FieldGroup rhythm: gap-7 (28px) between the header, each
      // Field, and the separator — not the form's own gap-6.
      gap: themeSpacing(7),
      width: "100%",
      maxWidth: themeSpacing(80),
    },
  };

  return {
    div: [
      {
        div: [
          brandRow,
          {
            div: [formBlock],
            style: {
              flex: "1 1 auto",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
            },
          },
        ],
        style: {
          display: "flex",
          flexDirection: "column",
          gap: themeSpacing(4),
          minWidth: "0",
          // Upstream steps padding at the `md` breakpoint (p-6 -> md:p-10)
          // rather than scaling it continuously.
          padding: themeSpacing(6),
          "@media (min-width: 48em)": { padding: themeSpacing(10) },
        },
      },
      {
        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",
          [WIDE_SPLIT_MEDIA_QUERY]: { display: "none" },
        },
      } as DomphyElement<"div">,
    ],
    style: {
      display: "grid",
      gridTemplateColumns: "1fr 1fr",
      minHeight: "100svh",
      [WIDE_SPLIT_MEDIA_QUERY]: { gridTemplateColumns: "1fr" },
    },
  };
}

export { Login02 };

← Back to shadcn/ui catalog