App & Dashboardappdashboardsaasauthloginsign-inform
Auth Card
Split-screen sign-in / sign-up with SSO buttons, show-password toggle, validation and a brand panel.
Live Preview
Usage
Copy and paste this code into your React component:
tsx
import { AuthCard } from 'react-component-kit-ui/app';
<AuthCard mode="signin" brand="Lobby" successHref="/app/" />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/AuthCard.tsx + AuthCard.css:
AuthCard.tsx
'use client';
import { useEffect, useRef } from 'react';
import './AuthCard.css';
/** class:list โ className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/**
* AuthCard โ Sign-in / sign-up split screen: form card (email, password with show/hide, remember
* me, SSO buttons) next to a brand panel with a quote and key figures.
*
* @param {string} mode - 'signin' or 'signup'. Default 'signin'.
* @param {string} brand - Product name. Default 'Lobby'.
* @param {string} title - Heading. Defaults by mode.
* @param {string} subtitle - Supporting text.
* @param {string} action - Form action. With '#' it validates and redirects to `successHref`. Default '#'.
* @param {string} successHref - Where to go after a demo sign-in. Default '#'.
* @param {string[]} providers - SSO buttons. Default ['Google', 'Microsoft'].
* @param {string} switchHref - Link to the other mode. Default '#'.
* @param {{quote: string, author: string, role?: string}} testimonial - Brand panel quote (optional).
* @param {Array<{value: string, label: string}>} stats - Brand panel figures.
* @param {string} variant - Layout (split|centered). Default 'split'.
* @param {string} className - Extra class.
*
* Accessibility: one h1; labelled fields with autocomplete (email, current-password /
* new-password); errors are linked with aria-describedby and announced; the show-password
* button is a toggle with aria-pressed.
*/
export interface AuthCardProps {
mode?: 'signin' | 'signup';
brand?: string;
title?: string;
subtitle?: string;
action?: string;
successHref?: string;
providers?: string[];
switchHref?: string;
testimonial?: { quote: string; author: string; role?: string };
stats?: Array<{ value: string; label: string }>;
variant?: 'split' | 'centered';
className?: string;
}
export function AuthCard({
mode = 'signin',
brand = 'Lobby',
title = mode === 'signin' ? 'Welcome back' : 'Create your workspace',
subtitle = mode === 'signin' ? 'Sign in to manage your property.' : 'Start your 14-day free trial โ no card required.',
action = '#',
successHref = '#',
providers = ['Google', 'Microsoft'],
switchHref = '#',
testimonial = { quote: 'We replaced three tools with Lobby. Check-in takes half the time and our occupancy is up 9% this season.', author: 'Lucรญa Ortega', role: 'GM, Hotel Mirador' },
stats = [{ value: '2,400+', label: 'properties' }, { value: '38%', label: 'less admin time' }, { value: '4.9/5', label: 'support rating' }],
variant = 'split',
className = '',
}: AuthCardProps) {
const isIn = mode === 'signin';
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
document.querySelectorAll<HTMLFormElement>('form[data-auth]:not([data-bound])').forEach((form) => {
form.dataset.bound = '';
const eye = form.querySelector<HTMLButtonElement>('[data-eye]');
const pass = form.querySelector<HTMLInputElement>('#auth-pass');
const error = form.querySelector<HTMLElement>('[data-error]')!;
eye?.addEventListener('click', () => {
const show = eye.getAttribute('aria-pressed') !== 'true';
eye.setAttribute('aria-pressed', String(show));
eye.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
if (pass) pass.type = show ? 'text' : 'password';
});
form.addEventListener('submit', (e) => {
form.querySelectorAll('[aria-invalid]').forEach((n) => n.removeAttribute('aria-invalid'));
const bad = Array.from(form.querySelectorAll<HTMLInputElement>('input[required]')).filter((i) => !i.checkValidity());
if (bad.length) {
e.preventDefault();
bad.forEach((i) => i.setAttribute('aria-invalid', 'true'));
error.textContent = bad[0].type === 'password' ? 'Your password needs at least 8 characters.' : 'Please check your email address.';
bad[0].focus();
return;
}
if (form.getAttribute('action') === '#') {
e.preventDefault();
const btn = form.querySelector<HTMLButtonElement>('[data-submit]')!;
btn.disabled = true;
btn.textContent = 'Signing inโฆ';
window.setTimeout(() => { window.location.href = form.dataset.success || '#'; }, 700);
}
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="auth-cq" data-rk-149787="">
<section className={cx(['auth', `auth--${variant}`, className])} data-rk-149787="">
<div className="auth__form-side" data-rk-149787="">
<form className="auth__card" action={action} method="post" noValidate data-auth data-success={successHref} data-rk-149787="">
<p className="auth__brand" data-rk-149787=""><span aria-hidden="true" data-rk-149787="">{brand.slice(0, 1)}</span>{brand}</p>
<h1 className="auth__title" data-rk-149787="">{title}</h1>
{subtitle && <p className="auth__sub" data-rk-149787="">{subtitle}</p>}
{providers.length > 0 && (
<>
<div className="auth__sso" data-rk-149787="">
{providers.map((p, __i: number) => <button key={__i} type="button" className="auth__sso-btn" data-rk-149787="">Continue with {p}</button>)}
</div>
<p className="auth__or" data-rk-149787=""><span data-rk-149787="">or with email</span></p>
</>
)}
{!isIn && (
<div className="auth__field" data-rk-149787="">
<label htmlFor="auth-name" data-rk-149787="">Full name</label>
<input id="auth-name" name="name" type="text" autoComplete="name" required data-rk-149787="" />
</div>
)}
<div className="auth__field" data-rk-149787="">
<label htmlFor="auth-email" data-rk-149787="">Work email</label>
<input id="auth-email" name="email" type="email" autoComplete="email" required aria-describedby="auth-error" data-rk-149787="" />
</div>
<div className="auth__field" data-rk-149787="">
<div className="auth__label-row" data-rk-149787="">
<label htmlFor="auth-pass" data-rk-149787="">Password</label>
{isIn && <a href="#" data-rk-149787="">Forgot password?</a>}
</div>
<div className="auth__pass" data-rk-149787="">
<input id="auth-pass" name="password" type="password" autoComplete={isIn ? 'current-password' : 'new-password'} required minLength={8} aria-describedby="auth-error" data-rk-149787="" />
<button type="button" className="auth__eye" aria-pressed="false" aria-label="Show password" data-eye data-rk-149787="">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-149787=""><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" data-rk-149787="" /><circle cx="12" cy="12" r="3" data-rk-149787="" /></svg>
</button>
</div>
</div>
{isIn && (
<label className="auth__remember" data-rk-149787=""><input type="checkbox" name="remember" defaultChecked data-rk-149787="" /> Keep me signed in</label>
)}
<p className="auth__error" id="auth-error" role="alert" data-error data-rk-149787=""></p>
<button type="submit" className="auth__submit" data-submit data-rk-149787="">{isIn ? 'Sign in' : 'Create account'}</button>
<p className="auth__switch" data-rk-149787="">{isIn ? 'New to ' + brand + '?' : 'Already have an account?'} <a href={switchHref} data-rk-149787="">{isIn ? 'Start a free trial' : 'Sign in'}</a></p>
</form>
</div>
{variant === 'split' && (
<aside className="auth__brand-side" aria-label={`About ${brand}`} data-rk-149787="">
{testimonial && (
<figure className="auth__quote" data-rk-149787="">
<blockquote data-rk-149787=""><p data-rk-149787="">โ{testimonial.quote}โ</p></blockquote>
<figcaption data-rk-149787=""><strong data-rk-149787="">{testimonial.author}</strong>{testimonial.role && <span data-rk-149787=""> ยท {testimonial.role}</span>}</figcaption>
</figure>
)}
{stats.length > 0 && (
<dl className="auth__stats" data-rk-149787="">{stats.map((s, __i: number) => <div key={__i} data-rk-149787=""><dt data-rk-149787="">{s.label}</dt><dd data-rk-149787="">{s.value}</dd></div>)}</dl>
)}
</aside>
)}
</section>
</div>
</>
);
}
export default AuthCard;AuthCard.css
/* AuthCard โ ported from astro-component-kit (app/AuthCard.astro) */
.auth-cq:where([data-rk-149787]) { container-type: inline-size; }
.auth:where([data-rk-149787]) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: min(100dvh, 860px); color: var(--c-text-2, #a3a3a3); }
.auth--centered:where([data-rk-149787]) { grid-template-columns: 1fr; }
.auth__form-side:where([data-rk-149787]) { display: grid; place-items: center; padding: clamp(1.5rem, 5vw, 4rem) var(--sp-5, 1.25rem); }
.auth__card:where([data-rk-149787]) { display: grid; gap: var(--sp-4, 1rem); width: 100%; max-width: 400px; }
.auth__brand:where([data-rk-149787]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0 0 var(--sp-4, 1rem); font-weight: 800; font-size: 1.05rem; color: var(--c-text-1, #fff); }
.auth__brand:where([data-rk-149787]) span:where([data-rk-149787]) { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.auth__title:where([data-rk-149787]) { margin: 0; font-size: 1.8rem; font-weight: 800; letter-spacing: -0.03em; color: var(--c-text-1, #fff); }
.auth__sub:where([data-rk-149787]) { margin: -8px 0 0; font-size: 0.92rem; }
.auth__sso:where([data-rk-149787]) { display: grid; gap: var(--sp-2, 0.5rem); }
.auth__sso-btn:where([data-rk-149787]) { min-height: 44px; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); font: inherit; font-size: 0.88rem; font-weight: 600; cursor: pointer; }
.auth__sso-btn:hover:where([data-rk-149787]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); }
.auth__or:where([data-rk-149787]) { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); margin: 0; font-size: 0.75rem; color: var(--c-text-3, #737373); }
.auth__or:where([data-rk-149787])::before, .auth__or:where([data-rk-149787])::after { content: ''; flex: 1; height: 1px; background: var(--c-border, rgba(255, 255, 255, 0.1)); }
.auth__field:where([data-rk-149787]) { display: grid; gap: 6px; }
.auth__field:where([data-rk-149787]) label:where([data-rk-149787]), .auth__label-row:where([data-rk-149787]) label:where([data-rk-149787]) { font-size: 0.82rem; font-weight: 600; color: var(--c-text-1, #fff); }
.auth__label-row:where([data-rk-149787]) { display: flex; justify-content: space-between; align-items: baseline; }
.auth__label-row:where([data-rk-149787]) a:where([data-rk-149787]), .auth__switch:where([data-rk-149787]) a:where([data-rk-149787]) { font-size: 0.8rem; font-weight: 600; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.auth__field:where([data-rk-149787]) input:where([data-rk-149787]) { width: 100%; min-height: 46px; padding: 0 0.85rem; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.14)); background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); font: inherit; font-size: 0.95rem; }
.auth__field:where([data-rk-149787]) input:focus:where([data-rk-149787]) { outline: none; border-color: var(--app-accent, var(--c-primary-light, #d4a017)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 20%, transparent); }
.auth__field:where([data-rk-149787]) input[aria-invalid='true']:where([data-rk-149787]) { border-color: var(--c-danger, #f87171); }
.auth__pass:where([data-rk-149787]) { position: relative; }
.auth__pass:where([data-rk-149787]) input:where([data-rk-149787]) { padding-right: 44px; }
.auth__eye:where([data-rk-149787]) { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: grid; place-items: center; width: 38px; height: 38px; border: 0; border-radius: 8px; background: transparent; color: var(--c-text-3, #737373); cursor: pointer; }
.auth__eye[aria-pressed='true']:where([data-rk-149787]) { color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.auth__remember:where([data-rk-149787]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); font-size: 0.85rem; cursor: pointer; }
.auth__remember:where([data-rk-149787]) input:where([data-rk-149787]) { width: 16px; height: 16px; accent-color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.auth__error:where([data-rk-149787]) { min-height: 1.2em; margin: -6px 0 0; font-size: 0.82rem; color: var(--c-danger, #f87171); }
.auth__error:empty:where([data-rk-149787]) { display: none; }
.auth__submit:where([data-rk-149787]) { min-height: 48px; border: 0; border-radius: 10px; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); font: inherit; font-size: 0.95rem; font-weight: 700; cursor: pointer; }
.auth__submit:disabled:where([data-rk-149787]) { opacity: 0.7; cursor: progress; }
.auth__switch:where([data-rk-149787]) { margin: 0; font-size: 0.85rem; text-align: center; }
.auth:where([data-rk-149787]) :is(a, button, input):focus-visible:where([data-rk-149787]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.auth__brand-side:where([data-rk-149787]) {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--sp-8, 2rem);
padding: clamp(2rem, 5vw, 4rem);
background:
radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 45%, transparent), transparent 55%),
radial-gradient(circle at 90% 80%, color-mix(in srgb, var(--chart-3, #199e70) 35%, transparent), transparent 50%),
#0b1020;
color: #fff;
}
.auth__quote:where([data-rk-149787]) { margin: 0; }
.auth__quote:where([data-rk-149787]) p:where([data-rk-149787]) { margin: 0; font-size: clamp(1.3rem, 2.4vw, 1.9rem); font-weight: 600; line-height: 1.35; letter-spacing: -0.02em; }
.auth__quote:where([data-rk-149787]) figcaption:where([data-rk-149787]) { margin-top: var(--sp-4, 1rem); font-size: 0.9rem; color: rgba(255, 255, 255, 0.75); }
.auth__quote:where([data-rk-149787]) strong:where([data-rk-149787]) { color: #fff; }
.auth__stats:where([data-rk-149787]) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-4, 1rem); margin: 0; padding-top: var(--sp-6, 1.5rem); border-top: 1px solid rgba(255, 255, 255, 0.2); }
.auth__stats:where([data-rk-149787]) div:where([data-rk-149787]) { display: flex; flex-direction: column-reverse; }
.auth__stats:where([data-rk-149787]) dt:where([data-rk-149787]) { font-size: 0.8rem; color: rgba(255, 255, 255, 0.75); }
.auth__stats:where([data-rk-149787]) dd:where([data-rk-149787]) { margin: 0; font-size: 1.5rem; font-weight: 800; }
@container (max-width: 860px) {
.auth:where([data-rk-149787]) { grid-template-columns: 1fr; }
.auth__brand-side:where([data-rk-149787]) { display: none; }
}Quick Info
- Category
- App & Dashboard
- Filename
AuthCard.tsx- Dependencies
- None โ pure React + CSS
- Tags
- appdashboardsaasauthloginsign-inform