Firebase phone auth with an OTP screen
SMS sign-in with Firebase: the user types a phone number, an invisible reCAPTCHA runs, and the animated 6-digit card confirms the code and unlocks the app.

By buying you agree to the Terms.
- Download right after paying
- Use it in client projects
- Won't run on the listed stack? We fix it or refund you
Ready forCursorv0Lovableshadcn CLI
Use it in Next.js
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { RecaptchaVerifier, signInWithPhoneNumber, type ConfirmationResult } from "firebase/auth";
import { StepUpGate } from "@/components/StepUpGate";
import { auth } from "@/lib/firebase";
export default function PhoneLoginPage() {
const router = useRouter();
const box = useRef<HTMLDivElement>(null);
const verifier = useRef<RecaptchaVerifier | null>(null);
const [phone, setPhone] = useState("");
const [confirmation, setConfirmation] = useState<ConfirmationResult | null>(null);
const [error, setError] = useState("");
async function sendCode(number: string) {
setError("");
// a fresh invisible reCAPTCHA for every send, so a retry or a resend never reuses a spent one
verifier.current?.clear();
const el = document.createElement("div");
box.current!.replaceChildren(el);
verifier.current = new RecaptchaVerifier(auth, el, { size: "invisible" });
try {
setConfirmation(await signInWithPhoneNumber(auth, number, verifier.current));
} catch {
setError("We couldn't send a code. Use the full number with the country code, like +40 712 345 678.");
}
}
return (
<>
{confirmation ? (
<StepUpGate
title="Check your phone"
description={`Enter the 6-digit code we texted to ${phone}.`}
successTitle="You're signed in"
successDescription="Opening your dashboard."
onVerify={async (code) => {
try {
await confirmation.confirm(code);
return true;
} catch {
return false; // the cells shake and show "That code didn't match"
}
}}
onResend={() => void sendCode(phone)}
onCancel={() => setConfirmation(null)}
onVerified={() => router.push("/dashboard")}
>
{/* whatever should sit blurred behind the code card */}
<div className="grid min-h-dvh place-items-center bg-[#0b0c10] text-white/40">Your dashboard</div>
</StepUpGate>
) : (
<main className="grid min-h-dvh place-items-center bg-[#0b0c10] text-white">
<form
onSubmit={(e) => {
e.preventDefault();
void sendCode(phone);
}}
className="grid w-80 gap-3"
>
<label htmlFor="phone" className="text-sm text-white/70">Phone number</label>
<input id="phone" type="tel" autoComplete="tel" required placeholder="+40 712 345 678" value={phone}
onChange={(e) => setPhone(e.target.value)}
className="rounded-lg bg-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-[#C6F432]" />
<button className="rounded-lg bg-[#C6F432] px-3 py-2 font-medium text-black">Text me a code</button>
{error ? <p role="alert" className="text-sm text-red-400">{error}</p> : null}
</form>
</main>
)}
<div ref={box} />
</>
);
}import { getApp, getApps, initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
// Reuse the app on hot reload instead of initializing it twice.
export const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
export const auth = getAuth(app);Good to know
- We type-checked this against firebase 13.1 and next 15.5. In the modular SDK, RecaptchaVerifier takes auth first, then the element.
- Turn on Phone under Authentication > Sign-in method in the Firebase console. While you build, add a test number with a fixed code on the same screen, so you can sign in without real texts.
- Numbers have to include the country code (+40..., +1...). Firebase rejects a local number with auth/invalid-phone-number, and the form shows its own message.
- Each send makes a fresh invisible reCAPTCHA in a new element. A widget that already ran can't be used again, so a retry or a resend on the old one fails.
- onVerify returns true or false. A wrong code makes the cells shake, and the user can type again straight away.
- Copy StepUpGate.tsx, step-up-gate.css and timeline.ts from react/src/components. The gate frosts whatever you pass as children.
Install
- Copy from the zipPut
react/src/componentsintosrc/, thennpm i framer-motion firebase. - Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.
Props
- onVerify
- (code) → true when confirm() resolves
- onResend
- text a fresh code
- onCancel
- back to the phone number
- onVerified
- runs after the unlock animation
- title / description
- card text
- length
- number of cells (6 for Firebase)
How qodesign compares
- qodesign vs Magic UIMagic UI is best for: landing pages built with shadcn/ui.
- qodesign vs Aceternity UIAceternity UI is best for: teams building many marketing pages who want one big library.
All comparisons, with prices read on each library's own site.
Questions
Why do I get auth/invalid-app-credential or a reCAPTCHA error?
Usually the domain isn't on the Authorized domains list under Authentication > Settings, or a reCAPTCHA widget is being reused. localhost is on the list by default; add your live domain.
Can I use it to add a phone number as a second factor?
Yes. Firebase's multi-factor flow also ends with a 6-digit code. Put that check inside onVerify and the card works the same.
Does the session last after a reload?
Yes. Firebase keeps the signed-in user in the browser by default, and onAuthStateChanged tells you who it is on the next visit.
Does the code screen work in Next.js?
Yes, as a client component. Keep "use client" at the top of the file that renders it, as in the snippet above. App Router and Pages Router both work.
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.