Auth Form
Sign-in / sign-up card with a sliding mode switch, floating labels, a live strength meter and a morphing submit.
Create your account
Start your 14-day free trial.
or continue with
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/auth-form.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/auth-form/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { Component, HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import AuthField from './AuthField.vue'
import { passwordStrength } from './strength'
export type AuthFormMode = 'sign-in' | 'sign-up'
export interface AuthFormPayload {
mode: AuthFormMode
name?: string
email: string
password: string
}
export interface AuthFormProvider {
id: string
label: string
icon?: Component
}
const props = withDefaults(defineProps<{
/** Social sign-in buttons. `'google'` and `'github'` come with icons. */
providers?: Array<'google' | 'github' | AuthFormProvider>
/** Async submit handler. Pending → spinner, resolved → success, thrown Error → message. */
action?: (payload: AuthFormPayload) => Promise<unknown> | unknown
/** Minimum strength score (1–4) required to sign up. */
minStrength?: number
class?: HTMLAttributes['class']
}>(), {
providers: () => ['google', 'github'],
minStrength: 2,
})
const mode = defineModel<AuthFormMode>('mode', { default: 'sign-in' })
const emit = defineEmits<{
submit: [payload: AuthFormPayload]
social: [provider: string]
forgot: [email: string]
}>()
const SPRING = { type: 'spring', stiffness: 420, damping: 36 } as const
const HEIGHT = { type: 'spring', stiffness: 300, damping: 32 } as const
const reduced = useReducedMotion()
const uid = useId()
const ids = {
tabIn: `vx-auth-tab-in-${uid}`,
tabUp: `vx-auth-tab-up-${uid}`,
panel: `vx-auth-panel-${uid}`,
meter: `vx-auth-meter-${uid}`,
pill: `vx-auth-pill-${uid}`,
}
const form = reactive({ name: '', email: '', password: '' })
const touched = reactive({ name: false, email: false, password: false })
const showPassword = ref(false)
const status = ref<'idle' | 'loading' | 'success'>('idle')
const formError = ref('')
const isSignUp = computed(() => mode.value === 'sign-up')
const strength = computed(() => passwordStrength(form.password))
const errors = computed(() => ({
name: isSignUp.value && !form.name.trim() ? 'Tell us what to call you' : '',
email: !form.email.trim()
? 'Email is required'
: !/^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(form.email.trim()) ? 'That doesn\'t look like an email address' : '',
password: !form.password
? 'Password is required'
: isSignUp.value && strength.value.score < props.minStrength ? 'Pick a stronger password' : '',
}))
const visibleError = (field: keyof typeof touched) => (touched[field] ? errors.value[field] : '')
watch(mode, () => {
touched.name = touched.email = touched.password = false
status.value = 'idle'
formError.value = ''
})
watch(form, () => {
if (status.value === 'success')
status.value = 'idle'
})
async function submit() {
touched.email = touched.password = true
touched.name = isSignUp.value
if (errors.value.name || errors.value.email || errors.value.password || status.value === 'loading')
return
const payload: AuthFormPayload = {
mode: mode.value,
email: form.email.trim(),
password: form.password,
...(isSignUp.value ? { name: form.name.trim() } : {}),
}
formError.value = ''
emit('submit', payload)
if (props.action) {
status.value = 'loading'
try {
await props.action(payload)
}
catch (e) {
status.value = 'idle'
formError.value = e instanceof Error ? e.message : 'Something went wrong'
return
}
}
status.value = 'success'
}
function onTabKey(e: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key))
return
e.preventDefault()
const next: AuthFormMode = e.key === 'Home' ? 'sign-in' : e.key === 'End' ? 'sign-up' : isSignUp.value ? 'sign-in' : 'sign-up'
mode.value = next
;(document.getElementById(next === 'sign-in' ? ids.tabIn : ids.tabUp) as HTMLElement | null)?.focus()
}
// Animate the panel height as fields come and go.
const inner = ref<HTMLElement>()
const height = ref<number | 'auto'>('auto')
let observer: ResizeObserver | undefined
onMounted(() => {
if (!inner.value || typeof ResizeObserver === 'undefined')
return
observer = new ResizeObserver(([entry]) => (height.value = entry!.borderBoxSize?.[0]?.blockSize ?? entry!.contentRect.height))
observer.observe(inner.value)
})
onBeforeUnmount(() => observer?.disconnect())
const providerList = computed(() => props.providers.map(p =>
typeof p === 'string' ? { id: p, label: p === 'google' ? 'Google' : 'GitHub', icon: undefined } : p,
))
const meterColor = computed(() => ['bg-muted', 'bg-destructive', 'bg-chart-4', 'bg-chart-2', 'bg-brand'][strength.value.score])
const meterText = computed(() => ['text-muted-foreground', 'text-destructive', 'text-chart-4', 'text-chart-2', 'text-brand'][strength.value.score])
const requirements = computed(() => [
{ key: 'length', label: '8+ characters', ok: strength.value.checks.length },
{ key: 'mixedCase', label: 'Upper & lower case', ok: strength.value.checks.mixedCase },
{ key: 'number', label: 'A number', ok: strength.value.checks.number },
{ key: 'symbol', label: 'A symbol', ok: strength.value.checks.symbol },
])
const direction = computed(() => (isSignUp.value ? 1 : -1))
</script>
<template>
<div :class="cn('w-full max-w-sm rounded-2xl border bg-card p-6 text-card-foreground shadow-sm', props.class)">
<div class="relative h-14 overflow-hidden text-center">
<AnimatePresence :initial="false">
<motion.div
:key="mode"
class="absolute inset-x-0 top-0"
:initial="{ opacity: 0, y: 12 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -12 }"
:transition="SPRING"
>
<p class="m-0 text-lg font-semibold tracking-tight">
{{ isSignUp ? 'Create your account' : 'Welcome back' }}
</p>
<p class="m-0 mt-0.5 text-sm text-muted-foreground">
{{ isSignUp ? 'Start your 14-day free trial.' : 'Sign in to continue to your workspace.' }}
</p>
</motion.div>
</AnimatePresence>
</div>
<div role="tablist" aria-label="Authentication mode" class="mt-4 grid grid-cols-2 gap-1 rounded-xl bg-muted p-1" @keydown="onTabKey">
<button
v-for="tab in ([['sign-in', 'Sign in', ids.tabIn], ['sign-up', 'Create account', ids.tabUp]] as const)"
:id="tab[2]"
:key="tab[0]"
type="button"
role="tab"
:aria-selected="mode === tab[0]"
:aria-controls="ids.panel"
:tabindex="mode === tab[0] ? 0 : -1"
:class="cn(
'relative rounded-lg py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
mode === tab[0] ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)"
@click="mode = tab[0]"
>
<motion.span
v-if="mode === tab[0]"
:layout-id="ids.pill"
aria-hidden="true"
class="absolute inset-0 rounded-lg bg-background shadow-sm"
:transition="SPRING"
/>
<span class="relative">{{ tab[1] }}</span>
</button>
</div>
<motion.div
:id="ids.panel"
role="tabpanel"
:aria-labelledby="isSignUp ? ids.tabUp : ids.tabIn"
class="relative -mx-1 overflow-hidden px-1"
:animate="{ height }"
:transition="reduced ? { duration: 0 } : HEIGHT"
>
<div ref="inner" class="pb-1 pt-5">
<form novalidate class="space-y-3" @submit.prevent="submit">
<AnimatePresence :initial="false">
<motion.div
v-if="isSignUp"
key="name"
class="origin-top"
:initial="{ opacity: 0, x: 24 * direction }"
:animate="{ opacity: 1, x: 0 }"
:exit="{ opacity: 0, x: -24, transition: { duration: 0.12 } }"
:transition="SPRING"
>
<AuthField
v-model="form.name"
label="Full name"
name="name"
autocomplete="name"
:error="visibleError('name')"
@blur="touched.name = true"
/>
</motion.div>
</AnimatePresence>
<AuthField
v-model="form.email"
label="Email"
type="email"
name="email"
autocomplete="email"
:error="visibleError('email')"
@blur="touched.email = true"
/>
<AuthField
v-model="form.password"
label="Password"
:type="showPassword ? 'text' : 'password'"
name="password"
:autocomplete="isSignUp ? 'new-password' : 'current-password'"
:error="visibleError('password')"
:describedby="isSignUp && form.password ? ids.meter : undefined"
@blur="touched.password = true"
>
<template #trailing>
<button
type="button"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:aria-pressed="showPassword"
class="mr-2 inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="showPassword = !showPassword"
>
<AnimatePresence mode="popLayout" :initial="false">
<motion.svg
v-if="showPassword"
key="hide"
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="{ opacity: 0, scale: 0.5, rotate: -45 }"
:animate="{ opacity: 1, scale: 1, rotate: 0 }"
:exit="{ opacity: 0, scale: 0.5, rotate: 45 }"
:transition="SPRING"
>
<path d="M10.7 5.1A10.7 10.7 0 0 1 22 12a13.2 13.2 0 0 1-1.7 2.7M14.1 14.2a3 3 0 0 1-4.2-4.2M17.5 17.5A10.7 10.7 0 0 1 2 12a13.2 13.2 0 0 1 4.5-5.5M2 2l20 20" />
</motion.svg>
<motion.svg
v-else
key="show"
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="{ opacity: 0, scale: 0.5, rotate: 45 }"
:animate="{ opacity: 1, scale: 1, rotate: 0 }"
:exit="{ opacity: 0, scale: 0.5, rotate: -45 }"
:transition="SPRING"
>
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</motion.svg>
</AnimatePresence>
</button>
</template>
</AuthField>
<!-- Strength meter (sign-up only) -->
<AnimatePresence :initial="false">
<motion.div
v-if="isSignUp && form.password"
:id="ids.meter"
key="meter"
class="overflow-hidden"
:initial="{ opacity: 0, height: 0 }"
:animate="{ opacity: 1, height: 'auto' }"
:exit="{ opacity: 0, height: 0 }"
:transition="SPRING"
>
<div class="flex items-center gap-3 px-1 pt-0.5">
<div class="grid flex-1 grid-cols-4 gap-1.5" aria-hidden="true">
<span v-for="n in 4" :key="n" class="h-1.5 overflow-hidden rounded-full bg-muted">
<motion.span
:class="cn('block h-full origin-left rounded-full transition-colors duration-300', meterColor)"
:initial="false"
:animate="{ scaleX: strength.score >= n ? 1 : 0 }"
:transition="{ ...SPRING, delay: strength.score >= n ? (n - 1) * 0.04 : (4 - n) * 0.03 }"
/>
</span>
</div>
<span :class="cn('w-12 text-right text-xs font-medium transition-colors', meterText)">
{{ strength.label }}
</span>
</div>
<ul class="m-0 mt-2 grid list-none grid-cols-2 gap-x-3 gap-y-1 p-0 px-1">
<li
v-for="req in requirements"
:key="req.key"
:class="cn('flex items-center gap-1.5 text-xs transition-colors', req.ok ? 'text-foreground' : 'text-muted-foreground')"
>
<span :class="cn('relative flex size-3.5 items-center justify-center rounded-full border transition-colors', req.ok ? 'border-transparent bg-brand text-background' : 'border-border')">
<AnimatePresence>
<motion.svg
v-if="req.ok"
class="size-2.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="{ scale: 0 }"
:animate="{ scale: 1 }"
:exit="{ scale: 0 }"
:transition="{ type: 'spring', stiffness: 600, damping: 18 }"
>
<path d="M20 6 9 17l-5-5" />
</motion.svg>
</AnimatePresence>
</span>
{{ req.label }}
<span class="sr-only">{{ req.ok ? '(met)' : '(not met)' }}</span>
</li>
</ul>
</motion.div>
</AnimatePresence>
<AnimatePresence :initial="false">
<motion.div
v-if="!isSignUp"
key="forgot"
class="flex justify-end"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0, transition: { duration: 0.1 } }"
>
<button
type="button"
class="rounded text-xs font-medium text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="emit('forgot', form.email)"
>
Forgot password?
</button>
</motion.div>
</AnimatePresence>
<AnimatePresence :initial="false">
<motion.p
v-if="formError"
key="form-error"
role="alert"
class="m-0 overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"
:initial="{ opacity: 0, height: 0, paddingTop: 0, paddingBottom: 0 }"
:animate="{ opacity: 1, height: 'auto', paddingTop: 8, paddingBottom: 8 }"
:exit="{ opacity: 0, height: 0, paddingTop: 0, paddingBottom: 0 }"
>
{{ formError }}
</motion.p>
</AnimatePresence>
<motion.button
type="submit"
:aria-busy="status === 'loading'"
:class="cn(
'relative inline-flex h-11 w-full items-center justify-center overflow-hidden rounded-xl text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
status === 'success' ? 'bg-brand text-background' : 'bg-primary text-primary-foreground hover:bg-primary/90',
)"
:while-press="{ scale: 0.98 }"
:transition="SPRING"
>
<AnimatePresence mode="popLayout" :initial="false">
<motion.span
:key="status === 'idle' ? mode : status"
class="inline-flex items-center gap-2"
:initial="{ opacity: 0, y: 14, filter: 'blur(4px)' }"
:animate="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
:exit="{ opacity: 0, y: -14, filter: 'blur(4px)' }"
:transition="SPRING"
>
<template v-if="status === 'loading'">
<svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".25" stroke-width="3" /><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" /></svg>
{{ isSignUp ? 'Creating account…' : 'Signing in…' }}
</template>
<template v-else-if="status === 'success'">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<motion.path d="M5 12.5 10 17 19 7.5" :initial="{ pathLength: 0 }" :animate="{ pathLength: 1 }" :transition="{ duration: 0.3, delay: 0.1 }" />
</svg>
{{ isSignUp ? 'Account created' : 'Signed in' }}
</template>
<template v-else>
{{ isSignUp ? 'Create account' : 'Sign in' }}
</template>
</motion.span>
</AnimatePresence>
</motion.button>
<p class="sr-only" aria-live="polite">
{{ status === 'success' ? (isSignUp ? 'Account created' : 'Signed in') : '' }}
</p>
</form>
</div>
</motion.div>
<template v-if="providerList.length">
<div class="my-5 flex items-center gap-3 text-xs text-muted-foreground">
<span class="h-px flex-1 bg-border" />
or continue with
<span class="h-px flex-1 bg-border" />
</div>
<div :class="cn('grid gap-2', providerList.length > 1 ? 'grid-cols-2' : 'grid-cols-1')">
<motion.button
v-for="p in providerList"
:key="p.id"
type="button"
class="inline-flex h-10 items-center justify-center gap-2 rounded-xl border bg-background text-sm font-medium outline-none transition-colors hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
:while-press="{ scale: 0.97 }"
:transition="SPRING"
@click="emit('social', p.id)"
>
<component :is="p.icon" v-if="p.icon" class="size-4" />
<svg v-else-if="p.id === 'github'" class="size-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" /></svg>
<svg v-else-if="p.id === 'google'" class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.85 3.19-1.79 4.13-1.15 1.15-2.93 2.4-6.05 2.4-4.83 0-8.6-3.89-8.6-8.72s3.77-8.72 8.6-8.72c2.6 0 4.51 1.03 5.91 2.35l2.31-2.31C18.75 1.44 16.13 0 12.48 0 5.87 0 .31 5.39.31 12s5.56 12 12.17 12c3.57 0 6.27-1.17 8.37-3.36 2.16-2.16 2.84-5.21 2.84-7.67 0-.76-.05-1.47-.17-2.05H12.48Z" /></svg>
{{ p.label }}
</motion.button>
</div>
</template>
</div>
</template>vue
<script setup lang="ts">
import type { InputTypeHTMLAttribute } from 'vue'
import { AnimatePresence, motion } from 'motion-v'
import { computed, ref, useId } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
label: string
type?: InputTypeHTMLAttribute
name?: string
autocomplete?: string
/** Inline error; slides in under the field. */
error?: string
/** Extra id(s) for aria-describedby (e.g. a strength meter). */
describedby?: string
}>()
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{ blur: [] }>()
const id = `vx-auth-${useId()}`
const focused = ref(false)
const floated = computed(() => focused.value || model.value.length > 0)
const SPRING = { type: 'spring', stiffness: 500, damping: 34 } as const
</script>
<template>
<div>
<div
:class="cn(
'relative flex items-center rounded-xl border bg-background transition-[border-color,box-shadow]',
focused && !props.error && 'border-ring ring-[3px] ring-ring/30',
props.error && 'border-destructive',
props.error && focused && 'ring-[3px] ring-destructive/20',
)"
>
<motion.label
:for="id"
class="pointer-events-none absolute left-3 top-[17px] origin-top-left text-sm leading-5"
:class="floated ? (props.error ? 'text-destructive' : 'text-muted-foreground') : 'text-muted-foreground'"
:initial="false"
:animate="floated ? { y: -10, scale: 0.78 } : { y: 0, scale: 1 }"
:transition="SPRING"
>
{{ props.label }}
</motion.label>
<input
:id="id"
v-model="model"
:type="props.type ?? 'text'"
:name="props.name"
:autocomplete="props.autocomplete"
:aria-invalid="props.error ? true : undefined"
:aria-describedby="[props.error ? `${id}-error` : '', props.describedby ?? ''].join(' ').trim() || undefined"
class="h-14 w-full min-w-0 flex-1 rounded-xl bg-transparent px-3 pb-1.5 pt-5 text-sm text-foreground outline-none"
@focus="focused = true"
@blur="focused = false; emit('blur')"
>
<slot name="trailing" />
</div>
<AnimatePresence :initial="false">
<motion.p
v-if="props.error"
:id="`${id}-error`"
key="error"
class="m-0 flex items-center gap-1.5 overflow-hidden pl-1 text-xs text-destructive"
:initial="{ opacity: 0, height: 0, y: -4 }"
:animate="{ opacity: 1, height: 'auto', y: 0, marginTop: 6 }"
:exit="{ opacity: 0, height: 0, y: -4, marginTop: 0 }"
:transition="SPRING"
>
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" /></svg>
{{ props.error }}
</motion.p>
</AnimatePresence>
</div>
</template>ts
export interface PasswordStrength {
/** 0 (empty) → 4 (strong) */
score: 0 | 1 | 2 | 3 | 4
label: '' | 'Weak' | 'Fair' | 'Good' | 'Strong'
checks: { length: boolean, mixedCase: boolean, number: boolean, symbol: boolean }
}
const labels = ['', 'Weak', 'Fair', 'Good', 'Strong'] as const
/** A small, dependency-free heuristic. Swap in zxcvbn or your own rules if you need more. */
export function passwordStrength(password: string): PasswordStrength {
const checks = {
length: password.length >= 8,
mixedCase: /[a-z]/.test(password) && /[A-Z]/.test(password),
number: /\d/.test(password),
symbol: /[^A-Z0-9]/i.test(password),
}
let score = Object.values(checks).filter(Boolean).length
if (password.length >= 14)
score++
if (!checks.length)
score = Math.min(score, 1)
if (password && !score)
score = 1
const clamped = Math.min(4, score) as PasswordStrength['score']
return { score: clamped, label: labels[clamped], checks }
}ts
export { default as AuthForm } from './AuthForm.vue'
export type { AuthFormMode, AuthFormPayload, AuthFormProvider } from './AuthForm.vue'
export { passwordStrength } from './strength'
export type { PasswordStrength } from './strength'Usage
ts
import { AuthForm, type AuthFormPayload } from '@/components/ui/auth-form'
async function action({ mode, name, email, password }: AuthFormPayload) {
const res = await fetch(`/api/auth/${mode}`, { method: 'POST', body: JSON.stringify({ name, email, password }) })
if (!res.ok)
throw new Error((await res.json()).message) // shown above the submit button
}vue
<AuthForm
v-model:mode="mode"
:action="action"
:providers="['google', 'github']"
@social="signInWith"
@forgot="openReset"
/>Switching modes slides the tab pill, swaps the heading and animates the card height as the name field and strength meter come and go. Errors appear once a field is blurred (or on submit) and are wired up with aria-invalid / aria-describedby. The strength heuristic is exported as passwordStrength() if you want it elsewhere — or replace it with your own rules.
API
| Prop | Type | Default | Description |
|---|---|---|---|
v-model:mode | 'sign-in' | 'sign-up' | 'sign-in' | Which form is shown. |
action | (payload) => Promise<unknown> | — | Async submit. Pending shows a spinner, resolving shows success, a thrown Error shows its message. |
providers | Array<'google' | 'github' | { id, label, icon? }> | ['google', 'github'] | Social buttons. Pass [] to hide them. |
minStrength | number | 2 | Minimum strength score (1–4) needed to sign up. |
| Event | Payload | Description |
|---|---|---|
submit | { mode, name?, email, password } | The form passed validation. |
social | (provider: string) | A social button was pressed. |
forgot | (email: string) | "Forgot password?" was pressed. |