For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Auth Card 01

Sign-in, sign-up, and verification cards with optional social providers.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/auth-card-01

Pass AuthMediaCarousel to the media prop to show an automatically advancing set of images. Each slide pulls back into a rounded card, holds, travels away in depth while the next one swings in, and grows back out to fill the panel. Replace the gradient placeholders with your own image URLs. The artwork is hidden on narrow screens and stays still when reduced motion is requested.

The card's rows rise in 40ms apart on mount, and the entrance is skipped entirely when reduced motion is requested. Input, Checkbox, Button and InputOTP keep their own 150ms state transitions.

Connect onSubmit, onProvider, and onResend to your authentication service. The example does not authenticate users.

Source

"use client";

import { RiMailCheckLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Field,
  FieldDescription,
  FieldLabel,
  FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
  SocialButton,
  SOCIAL_PROVIDERS,
} from "@/components/ui/social-button";
import type { SocialProvider } from "@/components/ui/social-button";

export type AuthLayout = "stacked" | "inline" | "grid";
export type AuthMode = "signin" | "signup" | "verify";
export interface AuthCardProps {
  mode?: AuthMode;
  email?: string;
  codeLength?: number;
  onComplete?: (code: string) => void;
  onResend?: () => void;
  layout?: AuthLayout;
  providers?: SocialProvider[];
  title?: React.ReactNode;
  description?: React.ReactNode;
  media?: React.ReactNode;
  logo?: React.ReactNode;
  centered?: boolean;
  confirmPassword?: boolean;
  footnote?: React.ReactNode;
  onSubmit?: (data: FormData) => void;
  onProvider?: (provider: SocialProvider) => void;
  switchHref?: string;
  forgotHref?: string;
  headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
  className?: string;
}

const copy = {
  signin: {
    title: "Welcome back",
    description: "Sign in to pick up where you left off.",
    cta: "Sign in",
    lead: "New here?",
    action: "Create an account",
  },
  signup: {
    title: "Create your account",
    description: "Enter your email below to create your account",
    cta: "Create account",
    lead: "Already have an account?",
    action: "Sign in",
  },
  verify: {
    title: "Check your email",
    description: "Enter the code to continue.",
    cta: "Verify and continue",
    lead: "Didn't get a code?",
    action: "Send another",
  },
};

// Rows arrive one after another: 12px rise, 3px blur clearing, 500ms,
// 40ms between rows. The delay arrives as --delay because it depends on order.
const rise = "auth-card-01-rise";

// A link that sits inside a sentence rather than on its own line, so the
// size height and horizontal padding would break the text flow.
const inlineLink = "-mx-2.75 h-auto";

function Row({
  className,
  delay,
  ...props
}: React.ComponentProps<"div"> & { delay: string }) {
  return (
    <div
      className={cn(rise, className)}
      style={{ "--delay": delay } as React.CSSProperties}
      {...props}
    />
  );
}

export function AuthCard({
  mode = "signin",
  email,
  codeLength = 6,
  onComplete,
  onResend,
  layout = "stacked",
  providers = ["google", "apple", "github"],
  title,
  description,
  media,
  logo,
  centered = false,
  confirmPassword = false,
  footnote,
  onSubmit,
  onProvider,
  switchHref = "/signup",
  forgotHref = "/forgot-password",
  headingLevel = 1,
  className,
}: AuthCardProps) {
  const id = React.useId();
  const [remember, setRemember] = React.useState(true);
  const [code, setCode] = React.useState("");
  const Heading = `h${headingLevel}` as const;
  const signup = mode === "signup";
  const verify = mode === "verify";
  const labels = copy[mode];
  let step = 0;
  const nextRow = () => {
    const delay = 40 * step;
    step += 1;
    return `${delay}ms`;
  };
  const field = (
    name: string,
    label: string,
    props: React.ComponentProps<typeof Input>,
    hint?: string
  ) => (
    <Row key={name} delay={nextRow()}>
      <Field className="gap-1.5">
        <FieldLabel htmlFor={`${id}-${name}`}>
          <span>
            {label}
            <span
              aria-hidden
              className="ml-0.5 text-(color:--label-required,var(--destructive))"
            >
              *
            </span>
          </span>
        </FieldLabel>
        <Input id={`${id}-${name}`} name={name} required {...props} />
        {hint && <FieldDescription>{hint}</FieldDescription>}
      </Field>
    </Row>
  );
  const body = (
    <>
      {(logo || verify) && (
        <div
          className={cn("mb-5 flex", rise, centered && "justify-center")}
          style={{ "--delay": nextRow() } as React.CSSProperties}
        >
          {logo ?? (
            <RiMailCheckLine className="size-8 text-foreground" aria-hidden />
          )}
        </div>
      )}
      <Row delay={nextRow()}>
        <CardHeader className={cn("gap-1.5", centered && "text-center")}>
          <Heading className="text-xl leading-6.5 font-medium tracking-normal text-foreground">
            {title ?? labels.title}
          </Heading>
          <CardDescription>
            {description ??
              (verify && email ? (
                <>
                  Enter the code sent to{" "}
                  <span className="font-medium text-foreground">{email}</span>{" "}
                  to continue.
                </>
              ) : (
                labels.description
              ))}
          </CardDescription>
        </CardHeader>
      </Row>
      <form
        className="mt-6 flex flex-col gap-4"
        onSubmit={(event) => {
          event.preventDefault();
          onSubmit?.(new FormData(event.currentTarget));
        }}
      >
        {verify ? (
          <Row delay={nextRow()}>
            <Field className="gap-1.5">
              <FieldTitle>Verification code</FieldTitle>
              <InputOTP
                maxLength={codeLength}
                value={code}
                onChange={setCode}
                onComplete={onComplete}
                aria-label="Verification code"
              >
                <InputOTPGroup className="flex-wrap">
                  {Array.from({ length: codeLength }, (_, index) => (
                    <InputOTPSlot
                      key={index}
                      index={index}
                      className={cn(
                        codeLength % 2 === 0 &&
                          index === codeLength / 2 &&
                          "ml-2"
                      )}
                    />
                  ))}
                </InputOTPGroup>
              </InputOTP>
            </Field>
          </Row>
        ) : (
          <>
            {signup &&
              !confirmPassword &&
              field("name", "Full name", {
                placeholder: "Your name",
                autoComplete: "name",
              })}
            {field(
              "email",
              "Email",
              {
                type: "email",
                placeholder: "you@company.com",
                autoComplete: "email",
              },
              signup
                ? "We use this to contact you, and never share it."
                : undefined
            )}
            {field("password", "Password", {
              type: "password",
              placeholder: signup
                ? "At least 8 characters"
                : "Enter your password",
              autoComplete: signup ? "new-password" : "current-password",
            })}
            {signup &&
              confirmPassword &&
              field("confirmPassword", "Confirm password", {
                type: "password",
                placeholder: "Repeat your password",
                autoComplete: "new-password",
              })}
          </>
        )}
        {!signup && !verify && (
          <div
            className={cn(
              "flex flex-wrap items-center justify-between gap-x-3 gap-y-1",
              rise
            )}
            style={{ "--delay": nextRow() } as React.CSSProperties}
          >
            <Label
              htmlFor={`${id}-remember`}
              variant="inline"
              className="whitespace-nowrap"
            >
              <Checkbox
                id={`${id}-remember`}
                size="sm"
                checked={remember}
                onCheckedChange={setRemember}
              />
              Remember me
            </Label>
            <a
              href={forgotHref}
              className="text-sm font-medium whitespace-nowrap text-primary hover:underline"
            >
              Forgot password?
            </a>
          </div>
        )}
        <Row delay={nextRow()}>
          <Button type="submit" className="w-full">
            {labels.cta}
          </Button>
        </Row>
        {signup && !footnote && (
          <p
            className={cn("text-xs leading-4 text-muted-foreground", rise)}
            style={{ "--delay": nextRow() } as React.CSSProperties}
          >
            By continuing, you agree to the terms and privacy policy.
          </p>
        )}
      </form>
      {!verify && providers.length > 0 && (
        <>
          <div
            className={cn("my-5", rise)}
            style={{ "--delay": nextRow() } as React.CSSProperties}
          >
            <Separator>or continue with</Separator>
          </div>
          <div
            className={cn(
              layout === "stacked" && "flex flex-col gap-2.5",
              layout === "inline" && "flex flex-wrap justify-center gap-2",
              layout === "grid" && "grid auto-cols-fr grid-flow-col gap-2.5",
              rise
            )}
            style={{ "--delay": nextRow() } as React.CSSProperties}
          >
            {providers.map((provider) => (
              <SocialButton
                key={provider}
                brand={provider}
                variant="white"
                iconOnly={layout !== "stacked"}
                fullWidth={layout === "stacked"}
                className={layout === "grid" ? "w-full" : undefined}
                aria-label={
                  layout !== "stacked"
                    ? `Continue with ${SOCIAL_PROVIDERS[provider].label}`
                    : undefined
                }
                onClick={() => onProvider?.(provider)}
              >
                Continue with {SOCIAL_PROVIDERS[provider].label}
              </SocialButton>
            ))}
          </div>
        </>
      )}
      <p
        className={cn(
          "mt-6 text-center text-sm leading-5 text-muted-foreground",
          rise
        )}
        style={{ "--delay": nextRow() } as React.CSSProperties}
      >
        {labels.lead}{" "}
        {verify ? (
          <Button
            type="button"
            variant="link"
            onClick={onResend}
            className={inlineLink}
          >
            {labels.action}
          </Button>
        ) : (
          <a
            href={switchHref}
            className="font-medium text-primary hover:underline"
          >
            {labels.action}
          </a>
        )}
      </p>
    </>
  );
  const card = (
    <Card
      radius={24}
      size="flush"
      variant="bordered"
      className={cn(
        "w-full",
        media ? "max-w-[880px] md:grid md:grid-cols-2" : "max-w-[400px]",
        className
      )}
    >
      <div className="flex flex-col p-6 sm:p-8">{body}</div>
      {media && (
        <div className="relative hidden bg-secondary md:block">{media}</div>
      )}
    </Card>
  );
  return footnote ? (
    <div className="flex w-full flex-col items-center gap-4">
      {card}
      <p className="max-w-[520px] text-center text-xs leading-4 text-muted-foreground">
        {footnote}
      </p>
    </div>
  ) : (
    card
  );
}

export { AuthMediaCarousel, type AuthMediaSlide } from "./auth-media-carousel";
"use client";

import { cn } from "cn";
import * as React from "react";

import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { useDirection } from "@/components/ui/direction";

export interface AuthMediaSlide {
  src?: string;
  alt?: string;
  /** Background artwork for a slide without an image. */
  className?: string;
}

/**
 * Autoplaying artwork for the auth card's `media` slot.
 *
 * The transition is a four-beat cycle: the slide in play pulls back into a
 * rounded card and holds there, travels away in depth while the next one
 * swings in from the other side, then grows back out to fill the panel.
 * Shrinking before moving is what makes it read as one object being replaced
 * by another rather than as a cut under a hard edge. The panel holds the
 * perspective and the cards turn away from the middle, so the movement has a
 * front and a back instead of a left and a right.
 *
 * Every slide is placed by where it sits relative to the current one, so the
 * set travels one direction forever and the wrap back to the first slide is
 * just another step. The slide leaving the frame jumps from one side to the
 * back of the queue, so transitions are enabled only for the slides actually
 * moving in a given beat.
 */

/** Card state: shrunk to a rounded tile, and the size it rides in at. */
const CARD_SCALE = 0.82;
const ENTER_SCALE = 0.8;
const CARD_RADIUS = 24;

/** How deep the drum is, and how far the cards turn on it. */
const PERSPECTIVE = 850;
const DEPTH = 220;
const TILT = 48;

const SHRINK_MS = 450;
/** The deliberate pause once it has become a card, before it moves. */
const HOLD_MS = 200;
const SLIDE_MS = 700;
const GROW_MS = 500;

/**
 * `idle`   at rest, full bleed
 * `shrink` pulling back into a card, then holding
 * `slide`  travelling away while the next one rides in
 * `enter`  the frame where the new slide takes over, before it grows
 */
type Phase = "idle" | "shrink" | "slide" | "enter";

const NEXT_PHASE: Record<Phase, Phase> = {
  enter: "idle",
  idle: "shrink",
  shrink: "slide",
  slide: "enter",
};

export function AuthMediaCarousel({
  slides,
  interval = 3200,
  className,
}: {
  slides: AuthMediaSlide[];
  /** Milliseconds a slide rests at full bleed, before the transition begins. */
  interval?: number;
  className?: string;
}) {
  const [index, setIndex] = React.useState(0);
  const [phase, setPhase] = React.useState<Phase>("idle");
  const reduced = useReducedMotion();
  const sign = useDirection() === "rtl" ? -1 : 1;
  const count = slides.length;

  React.useEffect(() => {
    // Reduced motion holds the artwork still on the first slide.
    if (count < 2 || reduced) {
      return;
    }
    const delay = {
      enter: 20,
      idle: interval,
      shrink: SHRINK_MS + HOLD_MS,
      slide: SLIDE_MS,
    }[phase];
    const timer = window.setTimeout(() => {
      if (phase === "slide") {
        setIndex((current) => (current + 1) % count);
      }
      setPhase(NEXT_PHASE[phase]);
    }, delay);
    return () => window.clearTimeout(timer);
  }, [count, interval, phase, reduced]);

  // The preference can flip mid-cycle; the resting beat is the readable one.
  const beat: Phase = reduced ? "idle" : phase;

  return (
    <div
      role="group"
      aria-roledescription="carousel"
      aria-label="Artwork"
      style={{ "--perspective": `${PERSPECTIVE}px` } as React.CSSProperties}
      className={cn(
        "absolute inset-0 overflow-hidden [perspective:var(--perspective)]",
        className
      )}
    >
      {slides.map((slide, slideIndex) => {
        // 0 is the slide in play, 1 is next in the queue, and everything else
        // waits off to the side until its turn comes round.
        const position = (slideIndex - index + count) % count;
        const current = position === 0;
        const upNext = position === 1;
        const transform = current
          ? beat === "slide"
            ? `translateX(${-100 * sign}%) translateZ(${-DEPTH}px) rotateY(${-TILT * sign}deg) scale(${CARD_SCALE})`
            : beat === "shrink"
              ? `scale(${CARD_SCALE})`
              : beat === "enter"
                ? `scale(${ENTER_SCALE})`
                : "scale(1)"
          : upNext && beat === "slide"
            ? `scale(${ENTER_SCALE})`
            : `translateX(${100 * sign}%) translateZ(${-DEPTH}px) rotateY(${TILT * sign}deg) scale(${ENTER_SCALE})`;
        // Only the slides in motion this beat may transition; the rest are
        // being repositioned, and a transition would streak them across.
        const moving = !reduced && (current || (upNext && beat === "slide"));
        return (
          <div
            key={slideIndex}
            aria-hidden={slide.alt ? undefined : true}
            style={
              {
                "--radius": `${current && beat === "idle" ? 0 : CARD_RADIUS}px`,
                "--transform": transform,
                "--duration":
                  beat === "slide"
                    ? `${SLIDE_MS}ms`
                    : beat === "shrink"
                      ? `${SHRINK_MS}ms`
                      : `${GROW_MS}ms`,
              } as React.CSSProperties
            }
            className={cn(
              "absolute inset-0 size-full [transform:var(--transform)] overflow-hidden rounded-[var(--radius)] duration-(--duration)",
              slide.className,
              moving && beat !== "enter"
                ? "transition-all ease-in-out"
                : "transition-none"
            )}
          >
            {slide.src && (
              // Artwork is decorative unless an alternative is supplied. This
              // file installs into other projects, so it stays a plain <img>.
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={slide.src}
                alt={slide.alt ?? ""}
                loading={slideIndex === 0 ? "eager" : "lazy"}
                className="size-full object-cover"
              />
            )}
          </div>
        );
      })}
      {slides.length > 1 && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center gap-1.5"
        >
          {slides.map((_, dot) => (
            <span
              key={dot}
              className={cn(
                "h-1.5 rounded-full bg-primary-foreground transition-all duration-500 ease-out motion-reduce:transition-none",
                dot === index ? "w-5 opacity-90" : "w-1.5 opacity-45"
              )}
            />
          ))}
        </div>
      )}
    </div>
  );
}