A shadcn login page with a shy mascot

shadcn's free login-01 block on phones and the Shy Eyes mascot login on wide screens, with one server action behind both forms.

Shy Eyes Login, preview
One payment$3

By buying you agree to the Terms.

Ready forCursorv0Lovableshadcn CLI

Full product pageReact · Tailwind · TypeScript

Use it in Next.js

app/login/page.tsx

import { LoginForm } from "@/components/login-form";
import { ShyLogin } from "./shy-login";

export default function Page() {
  return (
    <div className="min-h-svh bg-muted">
      {/* The mascot scene is drawn at 1000 x 660, so it only shows where it fits. */}
      <div className="hidden min-h-svh place-items-center lg:grid">
        <ShyLogin />
      </div>

      {/* Phones and tablets: shadcn's login-01 form, same server code. */}
      <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10 lg:hidden">
        <div className="w-full max-w-sm">
          <LoginForm />
        </div>
      </div>
    </div>
  );
}

app/login/shy-login.tsx

"use client";
import { useRouter } from "next/navigation";
import ShyEyesLogin from "@/components/ShyEyesLogin";
import { signIn } from "./actions";

export function ShyLogin() {
  const router = useRouter();

  return (
    <ShyEyesLogin
      forgotHref="/forgot-password"
      resetAfter={null}
      signInError={(e) => (e instanceof Error ? e.message : "Sign-in failed. Try again.")}
      onSubmit={async (email, password) => {
        const res = await signIn(email, password);
        if (!res.ok) throw new Error(res.error); // head shake + message above the button
        setTimeout(() => router.push("/dashboard"), 1200); // let the wave play first
      }}
    />
  );
}

components/login-form.tsx

"use client";
import { useActionState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { loginAction, type LoginState } from "@/app/login/actions";

export function LoginForm({ className, ...props }: React.ComponentProps<"div">) {
  const [state, formAction, pending] = useActionState<LoginState, FormData>(loginAction, null);

  return (
    <div className={cn("flex flex-col gap-6", className)} {...props}>
      <Card>
        <CardHeader>
          <CardTitle>Login to your account</CardTitle>
          <CardDescription>Enter your email below to login to your account</CardDescription>
        </CardHeader>
        <CardContent>
          <form action={formAction}>
            <FieldGroup>
              <Field data-invalid={!!state?.error}>
                <FieldLabel htmlFor="email">Email</FieldLabel>
                <Input id="email" name="email" type="email" autoComplete="email" placeholder="[email protected]" defaultValue={state?.email} required aria-invalid={!!state?.error} />
              </Field>
              <Field data-invalid={!!state?.error}>
                <div className="flex items-center">
                  <FieldLabel htmlFor="password">Password</FieldLabel>
                  <a href="/forgot-password" className="ml-auto inline-block text-sm underline-offset-4 hover:underline">
                    Forgot your password?
                  </a>
                </div>
                <Input id="password" name="password" type="password" autoComplete="current-password" required aria-invalid={!!state?.error} />
                <FieldError>{state?.error}</FieldError>
              </Field>
              <Field>
                <Button type="submit" disabled={pending}>
                  {pending ? "Logging in..." : "Login"}
                </Button>
                <FieldDescription className="text-center">
                  Don&apos;t have an account? <a href="/sign-up">Sign up</a>
                </FieldDescription>
              </Field>
            </FieldGroup>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}

app/login/actions.ts

"use server";
import { redirect } from "next/navigation";
import { verifyCredentials } from "@/lib/auth";

export type SignInResult = { ok: true } | { ok: false; error: string };

export async function signIn(email: string, password: string): Promise<SignInResult> {
  if (!/.+@.+\..+/.test(email) || !password) return { ok: false, error: "Enter your email and password." };

  // Your auth layer: checks the password and sets the session cookie.
  const ok = await verifyCredentials(email, password);
  return ok ? { ok: true } : { ok: false, error: "That email and password don't match." };
}

// The same check for the login-01 form, shaped for useActionState.
// The email goes back so the field keeps it: React 19 resets a form after its action runs.
export type LoginState = { error: string; email: string } | null;

export async function loginAction(_prev: LoginState, formData: FormData): Promise<LoginState> {
  const email = String(formData.get("email") ?? "");
  const res = await signIn(email, String(formData.get("password") ?? ""));
  if (!res.ok) return { error: res.error, email };
  redirect("/dashboard");
}

app/globals.css

@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* ...the rest of the globals.css shadcn wrote for you stays as it is... */

/* Shy Eyes Login's two inputs carry font-sans. In a shadcn app font-sans is
   your app font, so pin the piece's font inside the scene only. */
.qse-root .font-sans {
  font-family: "Space Grotesk", system-ui, sans-serif;
}

Good to know

  • lib/auth.ts is your auth layer: one function that checks the password and sets the session cookie. The Better Auth, Supabase and Lucia guides on this site each show one.
  • Shy Eyes Login draws its own inputs and button, so shadcn's Input and Button can't go inside the card. shadcn shows up around it instead: the page background (bg-muted) and the whole phone form, which is login-01 with a server action wired in.
  • Both forms are in the HTML and CSS hides one (lg:grid and lg:hidden), so there is no screen-size check in JavaScript and no hydration mismatch. Only the visible form can be focused or submitted.
  • login-01 also ships a "Login with Google" button. We left it out because Shy Eyes has no slot for social login. If you need Google or passkeys, keep login-01 on every screen size.
  • The action returns the error text instead of throwing it, so the message reaches the browser as you wrote it. In the mascot, throwing inside onSubmit is what starts the head shake, and signInError turns the thrown Error into the line above the button.
  • The scene names Space Grotesk but doesn't load it. Load it with next/font or a stylesheet link, or the card falls back to system-ui.
  • What we ran on Oct 25 2026: this exact code in a scratch Next.js 16.4.3 app with React 19.3.1, Tailwind CSS 4.3.3, framer-motion 14.6.0, radix-ui 1.8.1, class-variance-authority 0.7.1, cn 0.5.0, tw-animate-css 1.4.0, shadcn 4.22.1 (for shadcn/tailwind.css) and TypeScript 7.0.2, plus the shipped ShyEyesLogin.tsx. We copied the button, card, input, label, field and separator files from shadcn's registry JSON by hand instead of running the CLI. tsc and next build passed, and an onSubmit with the wrong argument type failed tsc as it should. In headless Chromium at 1440 px an empty submit showed both field messages, a wrong password showed "That email and password don't match." with the head shake, and the right one went to /dashboard. At 390 px login-01 showed the same error under the password, kept the email, and the right password went to /dashboard. Neither width scrolled sideways. The auth function was a stand-in that compares two strings. We didn't test Safari, Firefox, a real phone, a screen reader, dark mode, or a real auth provider.

shadcn login page: start with the free login blocks

  • shadcn has five login blocks at ui.shadcn.com/blocks/login, and they are where most shadcn login pages should start. The page calls blocks "Clean, modern building blocks. Copy and paste into your apps. Works with all React frameworks. Open Source. Free forever." The five, with shadcn's own descriptions: login-01 "A simple login form", login-02 "A two column login page with a cover image", login-03 "A login page with a muted background color", login-04 "A login page with form and image" and login-05 "A simple email-only login page".
  • npx shadcn add login-01 writes app/login/page.tsx and components/login-form.tsx. Its registry entry pulls in the button, card, input, label and field components, and field brings label and separator with it. The phone form in the snippet above is login-01's login-form.tsx with name attributes, autocomplete and a server action added. Everything else, the Card, the FieldGroup and the class names, is as shadcn ships it.
  • In today's registry, lib/utils.ts is one line, export { cn } from "cn", using the cn package (0.5.0 on npm, described as a drop-in replacement for clsx and tailwind-merge). Older shadcn projects build cn from clsx and tailwind-merge instead. Both export a cn function from @/lib/utils, so the snippet works with either.
  • npm on Oct 25 2026: shadcn 4.22.1, published Oct 18 2026. npm's counter shows 8,003,630 downloads for Oct 16 to 22, but it reads zero for Oct 19, 20 and 21, so the real week was bigger. The repo shadcn-ui/ui is MIT licensed, has about 126,000 stars, is not archived, and was last pushed Oct 24 2026.

When a plain shadcn login block is the better choice

  • For most apps, use login-01 (or login-03 or login-04 if you want the muted background or an image) on every screen size and skip the mascot. It's free, it follows your shadcn theme and dark mode, and it has room for the things a login often grows: a Google button, a passkey button, a sign-up link, an email-only page like login-05.
  • Shy Eyes Login fits when the sign-in is a moment you want people to remember: a launch, a playful product, a studio site. The mascot watches the caret while you type the email, covers his eyes on the password field and lowers one hand if you show the password. It's a fixed 1000 x 660 scene with its own dark colours, an email and password form only, and no slot for social buttons. If any of that is wrong for your users, keep the shadcn block.

The shadcn login form on phones, and React 19's form reset

  • login-01 ships as a plain form with no submit handler. The snippet wires it to useActionState: the form's action is loginAction, the button shows "Logging in..." while pending is true, and a failed sign-in comes back as state.error, shown with shadcn's FieldError and data-invalid on the Field so the label and input turn red.
  • React 19 resets a form's fields after its action runs. Our first version lost the email after every wrong password, so a second try with the right password failed with "Enter your email and password." The fix is in the snippet: loginAction returns the email with the error, and the Input reads it back as defaultValue. With that, the email stayed filled in our 390 px test and the second try signed in.
  • The mascot calls the other export, signIn, directly from onSubmit. Both forms run the same check, so the error text matches on every screen size.

Shy Eyes Login in a shadcn app: Tailwind v4 and one font rule

  • shadcn CLI 4 projects run on Tailwind CSS v4. The Shy Eyes download is a Vite project on Tailwind CSS 3.4 with a tailwind.config.js. Our Tailwind v4 check of every paid piece (Oct 25 2026) rated Shy Eyes Login a one-line fix: "The two inputs (font-sans) lose Space Grotesk and fall back to a system sans." The check's fix is @config "../tailwind.config.js"; under the Tailwind import.
  • In a shadcn app that line is the wrong tool, and we tried it both ways. Our test app set --font-sans inside @theme inline, and with @config added the mascot's inputs still showed the app font, because the CSS theme value won. With --font-sans taken out, @config switched the body, shadcn's Button and shadcn's Input to Space Grotesk, because the piece's config redefines font-sans for the whole app. The globals.css above uses one rule scoped to .qse-root instead. It gave the two inputs Space Grotesk and left the body and the shadcn form on the app font.
  • shadcn's base layer (border-border and outline-ring on every element, bg-background on the body) didn't reach the card in our test: the card border stayed #262A36, the button stayed the accent colour and the heading stayed at weight 600. The card's colours are arbitrary values and the button is plain CSS, so they win over the base layer.
  • The download pins framer-motion ^11.11.0 and React 18. We ran it with framer-motion 14.6.0 and React 19.3.1, and npm lists framer-motion 14.6.0's React peer range as ^18.0.0 || ^19.0.0.

Install

  1. Copy from the zipPut react/src/components into src/, then npx shadcn add login-01 && npm i framer-motion.
  2. Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.

Props

onSubmit
called with a valid email and password; throw or return false to shake his head
signInError
the line above the button after a failed sign-in (a string, or a function of the thrown error)
error
a controlled error from your server, e.g. ?error=CredentialsSignin
accent / focusColor
button and focus colours (hex, not a shadcn CSS variable)
resetAfter
ms before the form clears after success; null keeps it
forgotHref
link under the form

How qodesign compares

All comparisons, with prices read on each library's own site.

Questions

Is there a free shadcn login page?

Yes. shadcn's login-01 to login-05 blocks are free and MIT licensed. Run npx shadcn add login-01 for the simple card, or pick login-02 to login-05 for a cover image, a muted background, a form with an image or an email-only page.

Can I put shadcn's Input and Button inside Shy Eyes Login?

No. The card renders its own inputs, validation and button. Use shadcn around it, the page and the phone form, and pass the same server action to both.

Does Shy Eyes Login follow my shadcn theme or dark mode?

No. The scene sets its own dark background and text colour. You change it with props: accent and focusColor take hex values, and bodyColors and handColors recolour the mascot. shadcn theme colours are oklch variables, so var(--primary) won't work as accent.

Should I add @config for the piece's tailwind.config.js?

Not in a shadcn app. Depending on your globals.css it either changes nothing or switches your whole app's sans font to Space Grotesk. The one rule scoped to .qse-root fixes the two inputs and nothing else.

Why show login-01 on phones?

The scene is drawn at 1000 x 660. Scaled down to a phone, the inputs get too small to type in, so the page shows shadcn's form below 1024 px.

Can Cursor or v0 add it for me?

Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.

Can I use it in client work?

Yes, in personal and client projects. You can't resell the code itself as a kit.