Skip to content
Product block

Email-First SSO Sign-In

Email first, then whatever that domain uses. The password field is absent rather than disabled when SSO is enforced, the button names the company it redirects to, and a personal address is a normal answer instead of an error.

257 lineslucide-reactAdded 27 Aug 2026Updated 10 Sept 2026
  • sso
  • saml
  • enterprise
  • login
  • okta

What's included

  • components/auth-sso-domain.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add auth-sso-domain

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Sign in

Start with your work email — how you sign in depends on what your company has set up.

Meridian Foods manages accounts on meridianfoods.com. They require single sign-on, so there is no password to enter here.

Takes you to Okta

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/auth-sso-domain.tsx
'use client'

/**
 * <AuthSsoDomain> — email first, then whatever that email's company uses.
 *
 * Authentication had login, signup, OTP, forgot, reset and two-factor:
 * six screens that all assume the account belongs to the person typing.
 * The moment a customer has an IT department that assumption breaks, and
 * the sign-in screen has to ask one question before it can know what to
 * show. Nothing in the catalog asked it.
 *
 * THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
 *
 * The password field is not disabled — it is *absent* until the domain is
 * known, and when SSO is required it never appears at all, with the
 * reason in its place. A greyed-out password box beside "your organisation
 * requires SSO" invites ten minutes of trying to type into it, and every
 * support ticket that follows starts with "the password field wasn't
 * working". Removing an input is honest; disabling one is a riddle.
 *
 * THE DOMAIN IS ECHOED BACK
 *
 * "Continue with Meridian Foods SSO" rather than "Continue with SSO".
 * Someone who mistypes their address at a company with two tenants finds
 * out here, at the button, rather than after a redirect to an identity
 * provider that shows them a login page for the wrong company.
 *
 * PERSONAL ADDRESSES ARE A NORMAL ANSWER
 *
 * A gmail.com address matching no tenant is the common case, not an
 * error. It falls through to the password path with no warning colour and
 * no scolding — the only difference is which second step appears.
 *
 * ACCESSIBILITY: one `<form>` with a real submit, so Enter works at every
 * stage; the step change is announced through `aria-live`; the back
 * control is a button that restores focus to the email field, because a
 * step that cannot be undone from the keyboard is a trap.
 */

import * as React from 'react'
import { ArrowLeft, ArrowRight, Building2, KeyRound, Loader2, ShieldCheck } from 'lucide-react'

export interface SsoTenant {
  /** Email domain, lower-case, without the @. */
  domain: string
  name: string
  /** What the redirect actually goes to — worth naming before it happens. */
  provider: string
  /** When true, the password path is not offered at all. */
  enforced?: boolean
}

export interface AuthSsoDomainProps {
  tenants?: SsoTenant[]
  className?: string
}

const DEFAULT_TENANTS: SsoTenant[] = [
  { domain: 'meridianfoods.com', name: 'Meridian Foods', provider: 'Okta', enforced: true },
  { domain: 'halden.co', name: 'Halden Group', provider: 'Microsoft Entra ID' },
]

type Step = 'email' | 'sso' | 'password'

export function AuthSsoDomain({ tenants = DEFAULT_TENANTS, className = '' }: AuthSsoDomainProps) {
  /*
    Per-instance prefix for every id this block emits.

    The literals these replaced were a latent duplicate the moment the
    block appeared twice on one document, and `aria-labelledby` on a
    duplicated id resolves to the first match -- so the second copy was
    labelled by the first copy's heading. Client component, so `useId` is
    the right tool.
  */
  const uid = React.useId()

  /*
   * Opens on the second step, with a domain already recognised.
   *
   * The first step is an email field, which is what every sign-in screen
   * looks like and says nothing about what this one does. The answer —
   * "Meridian Foods manages accounts on this domain, and there is no
   * password field because they require SSO" — is the component. "Use a
   * different email" walks back to the empty state.
   */
  const [email, setEmail] = React.useState('you@meridianfoods.com')
  const [step, setStep] = React.useState<Step>('sso')
  const [tenant, setTenant] = React.useState<SsoTenant | null>(tenants[0] ?? null)
  const [checking, setChecking] = React.useState(false)
  const emailRef = React.useRef<HTMLInputElement>(null)

  const domain = email.split('@')[1]?.trim().toLowerCase() ?? ''

  function lookUp(event: React.FormEvent) {
    event.preventDefault()
    if (step !== 'email') return
    setChecking(true)
    /*
      A real deployment asks the server here; the delay is kept because
      the interesting design question is what the screen does while it
      does not yet know, and a demo that answers instantly hides it.
    */
    window.setTimeout(() => {
      const match = tenants.find((t) => t.domain === domain) ?? null
      setTenant(match)
      setStep(match ? 'sso' : 'password')
      setChecking(false)
    }, 550)
  }

  function back() {
    setStep('email')
    setTenant(null)
    /* Focus follows, or the back button is decorative for keyboard users. */
    window.setTimeout(() => emailRef.current?.focus(), 0)
  }

  return (
    <section className={`mx-auto w-full max-w-md px-4 py-16 sm:px-6 ${className}`}>
      <div className="rounded-2xl border border-border bg-card p-6 sm:p-7">
        <h1 className="text-xl font-semibold tracking-tight text-foreground">Sign in</h1>
        <p className="mt-1 text-sm text-muted-foreground">
          Start with your work email — how you sign in depends on what your company
          has set up.
        </p>

        <form onSubmit={lookUp} className="mt-6 space-y-4">
          <div>
            <label htmlFor={`${uid}-sso-email`} className="block text-sm font-medium text-foreground">
              Email
            </label>
            <input
              id={`${uid}-sso-email`}
              ref={emailRef}
              type="email"
              required
              autoComplete="username"
              value={email}
              readOnly={step !== 'email'}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="you@meridianfoods.com"
              className={`mt-1.5 h-10 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring ${
                step !== 'email' ? 'text-muted-foreground' : ''
              }`}
            />
          </div>

          {/* The step change is announced; the visual change alone is not
              available to a screen-reader user who just pressed Enter. */}
          <div aria-live="polite">
            {step === 'email' ? (
              <button
                type="submit"
                disabled={checking || !email.includes('@')}
                className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                {checking ? (
                  <>
                    <Loader2 aria-hidden className="h-4 w-4 motion-safe:animate-spin" />
                    Checking your organisation
                  </>
                ) : (
                  <>
                    Continue
                    <ArrowRight aria-hidden className="h-4 w-4 rtl:rotate-180" />
                  </>
                )}
              </button>
            ) : null}

            {step === 'sso' && tenant ? (
              <div className="space-y-3">
                <div className="flex items-start gap-2.5 rounded-xl border border-border bg-muted/40 p-3">
                  <Building2 aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
                  <p className="text-xs leading-relaxed text-muted-foreground">
                    <strong className="font-semibold text-foreground">{tenant.name}</strong>{' '}
                    manages accounts on {tenant.domain}.{' '}
                    {tenant.enforced
                      ? 'They require single sign-on, so there is no password to enter here.'
                      : 'You can use single sign-on or your password.'}
                  </p>
                </div>

                {/* Named, so a mistyped domain is caught before the redirect. */}
                <button
                  type="button"
                  className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  <ShieldCheck aria-hidden className="h-4 w-4" />
                  Continue with {tenant.name} SSO
                </button>
                <p className="text-center text-xs text-muted-foreground">
                  Takes you to {tenant.provider}
                </p>

                {/* Absent, not disabled, when SSO is enforced. */}
                {!tenant.enforced ? (
                  <button
                    type="button"
                    onClick={() => setStep('password')}
                    className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-lg border border-border bg-background px-4 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                  >
                    <KeyRound aria-hidden className="h-4 w-4" />
                    Use a password instead
                  </button>
                ) : null}
              </div>
            ) : null}

            {step === 'password' ? (
              <div className="space-y-3">
                <div>
                  <label
                    htmlFor={`${uid}-sso-password`}
                    className="block text-sm font-medium text-foreground"
                  >
                    Password
                  </label>
                  <input
                    id={`${uid}-sso-password`}
                    type="password"
                    autoComplete="current-password"
                    className="mt-1.5 h-10 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
                  />
                </div>
                <button
                  type="button"
                  className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  Sign in
                </button>
                {/* A personal address is a normal answer, not a warning. */}
                <p className="text-xs text-muted-foreground">
                  {tenant
                    ? `${tenant.name} allows passwords as well as SSO.`
                    : `No organisation is set up for ${domain || 'that domain'} — this is an individual account.`}
                </p>
              </div>
            ) : null}
          </div>

          {step !== 'email' ? (
            <button
              type="button"
              onClick={back}
              className="inline-flex items-center gap-1.5 rounded text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <ArrowLeft aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
              Use a different email
            </button>
          ) : null}
        </form>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/auth-sso-domain.tsx and import it where you need the section:

import { AuthSsoDomain } from '@/components/auth-sso-domain'

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
tenantsSsoTenant[]DEFAULT_TENANTS, className = ''
classNamestring—

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

auth-sso-domain.html
<!--
  Email-First SSO Sign-In — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-md px-4 py-16 sm:px-6 ">
  <div class="rounded-2xl border border-border bg-card p-6 sm:p-7">
    <h1 class="text-xl font-semibold tracking-tight text-foreground">Sign in</h1>
    <p class="mt-1 text-sm text-muted-foreground">Start with your work email — how you sign in depends on what your company has set up.</p>
    <form class="mt-6 space-y-4">
      <div>
        <label for="_R_0_-sso-email" class="block text-sm font-medium text-foreground">Email</label>
        <input id="_R_0_-sso-email" type="email" required="" autoComplete="username" readOnly="" placeholder="you@meridianfoods.com" class="mt-1.5 h-10 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring text-muted-foreground" value="you@meridianfoods.com" />
      </div>
      <div aria-live="polite">
        <div class="space-y-3">
          <div class="flex items-start gap-2.5 rounded-xl border border-border bg-muted/40 p-3">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-building2 lucide-building-2 mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true">
              <path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"></path>
              <path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path>
              <path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"></path>
              <path d="M10 6h4"></path>
              <path d="M10 10h4"></path>
              <path d="M10 14h4"></path>
              <path d="M10 18h4"></path>
            </svg>
            <p class="text-xs leading-relaxed text-muted-foreground"><strong class="font-semibold text-foreground">Meridian Foods</strong> manages accounts on meridianfoods.com. They require single sign-on, so there is no password to enter here.</p>
          </div>
          <button type="button" class="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-check h-4 w-4" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="m9 12 2 2 4-4"></path></svg>Continue with Meridian Foods SSO</button>
          <p class="text-center text-xs text-muted-foreground">Takes you to Okta</p>
        </div>
      </div>
      <button type="button" class="inline-flex items-center gap-1.5 rounded text-xs font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg>Use a different email</button>
    </form>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

8 more blocks in Authentication

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Authentication

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Authentication blocks

View category
Open the full page for this block

Split Signup With Proof

Registration form beside a testimonial panel, with live password rules and a proof column that drops rather than stacks on mobile.

Authentication205 lines1 dep
Open the full page for this block

One-Time Code Input

Six-box OTP entry that handles paste, backspace, arrow keys and iOS SMS autofill — the parts hand-rolled versions always miss.

Authentication184 lines1 dep
Open the full page for this block

New Password With Strength Meter

Set-a-new-password form with a four-step strength meter, confirm matching and errors announced rather than only coloured.

Authentication179 lines1 dep
Open the full page for this block

Two-Factor Challenge

Authenticator-code prompt with a backup-code escape hatch and an opt-in trusted-device checkbox that is off by default.

Authentication148 lines1 dep
Open the full page for this block

Magic Link Sign In

Passwordless sign-in that says what will land in the inbox and how long it lasts, before the address is typed.

Authentication176 linesNo deps
Open the full page for this block

Enterprise SSO Split

What SSO actually covers, written for the person who has to configure it rather than the person who asked for it.

Authentication155 linesNo deps