Sign Up
A registration screen composed from touchcn components.
A purely presentational sign-up screen: name, email and password fields, a terms checkbox with linked-text styling, a submit button with a loading state, an “or” divider, Apple and Google social sign-up placeholders and a sign-in affordance. The submit button stays disabled until the terms are accepted. It owns only the field values and emits them — wire real account creation from the parent.
Installation
npx touchcn add signup
This also adds the components the block composes:
button · checkbox · divider · input · navbar · page · spinner
Usage
<tcn-signup-block [loading]="loading()" (submitted)="signUp($event)" />import { TcnSignupBlock } from '@/components/blocks/signup';
<TcnSignupBlock loading={loading} onSubmit={(values) => signUp(values)} /><script setup lang="ts">
import { TcnSignupBlock } from '@/components/blocks/signup';
</script>
<template>
<TcnSignupBlock :loading="loading" @submit="(values) => signUp(values)" />
</template>The submit callback receives { name, email, password, acceptedTerms }. Set
loading to show the button spinner and disable the form while your request is
in flight.
Set showBack to render a back control in the navbar’s leading slot; it emits
back (Angular) / onBack (React) when tapped — wire it to your router.
Source · Angular
export * from './tcn-signup-block';import { booleanAttribute, Component, input, output, signal } from '@angular/core';
import { TcnButton } from '@/components/ui/button';
import { TcnCheckbox } from '@/components/ui/checkbox';
import { TcnDivider } from '@/components/ui/divider';
import { TcnInput } from '@/components/ui/input';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnSpinner } from '@/components/ui/spinner';
export interface SignupValues {
name: string;
email: string;
password: string;
acceptedTerms: boolean;
}
/**
* Sign-up screen — a purely presentational registration form. It owns only the
* field values and emits them through `submitted`; wire real account creation
* (and the `loading` flag) from the parent. The submit button stays disabled
* until the terms are accepted.
*/
@Component({
selector: 'tcn-signup-block',
imports: [TcnPage, TcnNavbar, TcnInput, TcnCheckbox, TcnButton, TcnDivider, TcnSpinner],
template: `
<tcn-page>
<tcn-navbar title="Sign up">
@if (showBack()) {
<button navbar-leading type="button" class="tcn-navbar-back" aria-label="Back" (click)="back.emit()">
<svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
<path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
}
</tcn-navbar>
<div class="mx-auto flex min-h-full max-w-[430px] flex-col justify-center px-5 pt-20 pb-12">
<div class="mb-8 text-center">
<h1 class="text-2xl font-semibold">Create your account</h1>
<p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">Join in a few seconds — it's free.</p>
</div>
<form class="flex flex-col gap-4" (submit)="$event.preventDefault(); submit()">
<tcn-input label="Name" type="text" [(value)]="name" />
<tcn-input label="Email" type="email" [(value)]="email" />
<tcn-input label="Password" type="password" [(value)]="password" />
<!-- Real links must live OUTSIDE the checkbox: its label slot renders
inside a button, where nested links are invalid and every click
toggles. The bare box centers against the text row instead. -->
<div class="mt-1 flex items-center gap-2.5">
<tcn-checkbox class="[&_.tcn-checkbox-box]:mt-0" aria-label="I agree to the Terms of Service and Privacy Policy" [(checked)]="acceptedTerms" />
<span class="text-sm leading-snug text-[var(--color-on-surface-variant)]">
I agree to the
<a class="text-[var(--color-primary)] underline" [href]="termsUrl()" target="_blank" rel="noopener noreferrer">Terms of Service</a>
and
<a class="text-[var(--color-primary)] underline" [href]="privacyUrl()" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
</span>
</div>
<button tcnButton type="submit" class="mt-2 w-full" [disabled]="loading() || !acceptedTerms()">
@if (loading()) {
<tcn-spinner size="sm" />
}
Sign up
</button>
</form>
<div class="my-6 flex items-center gap-3">
<tcn-divider class="flex-1" />
<span class="text-xs uppercase tracking-wide text-[var(--color-on-surface-variant)]">or</span>
<tcn-divider class="flex-1" />
</div>
<div class="flex flex-col gap-3">
<button tcnButton variant="secondary" type="button" class="w-full" (click)="continueWithApple.emit()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.05 12.04c-.03-2.6 2.12-3.85 2.22-3.91-1.21-1.77-3.09-2.02-3.76-2.04-1.6-.16-3.12.94-3.93.94-.81 0-2.06-.92-3.39-.9-1.74.03-3.35 1.01-4.25 2.57-1.81 3.14-.46 7.79 1.3 10.34.86 1.25 1.88 2.65 3.22 2.6 1.29-.05 1.78-.83 3.34-.83 1.56 0 2 .83 3.37.81 1.39-.03 2.27-1.27 3.12-2.53.98-1.45 1.39-2.85 1.41-2.93-.03-.01-2.7-1.04-2.73-4.13zM14.6 4.59c.71-.86 1.19-2.06 1.06-3.25-1.02.04-2.26.68-2.99 1.54-.66.76-1.23 1.98-1.08 3.15 1.14.09 2.3-.58 3.01-1.44z" />
</svg>
Continue with Apple
</button>
<button tcnButton variant="secondary" type="button" class="w-full" (click)="continueWithGoogle.emit()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.479 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.085-1.39-.189-1.989H12.24z" />
</svg>
Continue with Google
</button>
</div>
<div class="mt-6 flex items-center justify-center gap-1 text-sm text-[var(--color-on-surface-variant)]">
<span>Already have an account?</span>
<button tcnButton variant="ghost" size="sm" type="button" (click)="signIn.emit()">Sign in</button>
</div>
</div>
</tcn-page>
`,
})
export class TcnSignupBlock {
/** Shows the submit button's spinner and disables the form while true. */
readonly loading = input(false, { transform: booleanAttribute });
/** Renders a back control in the navbar's leading slot. */
readonly showBack = input(false, { transform: booleanAttribute });
/** Target of the "Terms of Service" link. */
readonly termsUrl = input('#');
/** Target of the "Privacy Policy" link. */
readonly privacyUrl = input('#');
readonly submitted = output<SignupValues>();
readonly back = output<void>();
readonly signIn = output<void>();
readonly continueWithApple = output<void>();
readonly continueWithGoogle = output<void>();
protected readonly name = signal('');
protected readonly email = signal('');
protected readonly password = signal('');
protected readonly acceptedTerms = signal(false);
protected submit(): void {
this.submitted.emit({
name: this.name(),
email: this.email(),
password: this.password(),
acceptedTerms: this.acceptedTerms(),
});
}
}Source · React
export * from './tcn-signup-block';import { useState } from 'react';
import type { FormEvent } from 'react';
import { TcnButton } from '@/components/ui/button';
import { TcnCheckbox } from '@/components/ui/checkbox';
import { TcnDivider } from '@/components/ui/divider';
import { TcnInput } from '@/components/ui/input';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnSpinner } from '@/components/ui/spinner';
export interface SignupValues {
name: string;
email: string;
password: string;
acceptedTerms: boolean;
}
export interface TcnSignupBlockProps {
/** Shows the submit button's spinner and disables the form while true. */
loading?: boolean;
/** Renders a back control in the navbar's leading slot. */
showBack?: boolean;
/** Target of the "Terms of Service" link. */
termsUrl?: string;
/** Target of the "Privacy Policy" link. */
privacyUrl?: string;
/** Fires with the entered details when the form is submitted. */
onSubmit?(values: SignupValues): void;
/** Fires when the navbar back control is activated. */
onBack?(): void;
/** Fires when the sign-in affordance is activated. */
onSignIn?(): void;
/** Placeholder social sign-up affordance. */
onContinueWithApple?(): void;
/** Placeholder social sign-up affordance. */
onContinueWithGoogle?(): void;
}
/**
* Sign-up screen — a purely presentational registration form. It owns only the
* field values and emits them through `onSubmit`; wire real account creation
* (and the `loading` flag) from the parent. The submit button stays disabled
* until the terms are accepted.
*/
export function TcnSignupBlock({
loading = false,
showBack = false,
termsUrl = '#',
privacyUrl = '#',
onSubmit,
onBack,
onSignIn,
onContinueWithApple,
onContinueWithGoogle,
}: TcnSignupBlockProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [acceptedTerms, setAcceptedTerms] = useState(false);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
onSubmit?.({ name, email, password, acceptedTerms });
};
return (
<TcnPage>
<TcnNavbar
title="Sign up"
leading={
showBack ? (
<button type="button" className="tcn-navbar-back" aria-label="Back" onClick={() => onBack?.()}>
<svg className="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
<path d="M10 2 2 10l8 8" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg className="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
) : undefined
}
/>
<div className="mx-auto flex min-h-full max-w-[430px] flex-col justify-center px-5 pt-20 pb-12">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold">Create your account</h1>
<p className="mt-1 text-sm text-[var(--color-on-surface-variant)]">Join in a few seconds — it's free.</p>
</div>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<TcnInput label="Name" type="text" autoComplete="name" value={name} onValueChange={setName} />
<TcnInput label="Email" type="email" autoComplete="email" value={email} onValueChange={setEmail} />
<TcnInput
label="Password"
type="password"
autoComplete="new-password"
value={password}
onValueChange={setPassword}
/>
{/* Real links must live OUTSIDE the checkbox: its label slot renders
inside a button, where nested links are invalid and every click
toggles. The bare box centers against the text row instead. */}
<div className="mt-1 flex items-center gap-2.5">
<TcnCheckbox
className="[&_.tcn-checkbox-box]:mt-0"
aria-label="I agree to the Terms of Service and Privacy Policy"
checked={acceptedTerms}
onCheckedChange={setAcceptedTerms}
/>
<span className="text-sm leading-snug text-[var(--color-on-surface-variant)]">
I agree to the{' '}
<a className="text-[var(--color-primary)] underline" href={termsUrl} target="_blank" rel="noopener noreferrer">
Terms of Service
</a>{' '}
and{' '}
<a className="text-[var(--color-primary)] underline" href={privacyUrl} target="_blank" rel="noopener noreferrer">
Privacy Policy
</a>
.
</span>
</div>
<TcnButton type="submit" className="mt-2 w-full" disabled={loading || !acceptedTerms}>
{loading && <TcnSpinner size="sm" />}
Sign up
</TcnButton>
</form>
<div className="my-6 flex items-center gap-3">
<TcnDivider className="flex-1" />
<span className="text-xs uppercase tracking-wide text-[var(--color-on-surface-variant)]">or</span>
<TcnDivider className="flex-1" />
</div>
<div className="flex flex-col gap-3">
<TcnButton type="button" variant="secondary" className="w-full" onClick={() => onContinueWithApple?.()}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.05 12.04c-.03-2.6 2.12-3.85 2.22-3.91-1.21-1.77-3.09-2.02-3.76-2.04-1.6-.16-3.12.94-3.93.94-.81 0-2.06-.92-3.39-.9-1.74.03-3.35 1.01-4.25 2.57-1.81 3.14-.46 7.79 1.3 10.34.86 1.25 1.88 2.65 3.22 2.6 1.29-.05 1.78-.83 3.34-.83 1.56 0 2 .83 3.37.81 1.39-.03 2.27-1.27 3.12-2.53.98-1.45 1.39-2.85 1.41-2.93-.03-.01-2.7-1.04-2.73-4.13zM14.6 4.59c.71-.86 1.19-2.06 1.06-3.25-1.02.04-2.26.68-2.99 1.54-.66.76-1.23 1.98-1.08 3.15 1.14.09 2.3-.58 3.01-1.44z" />
</svg>
Continue with Apple
</TcnButton>
<TcnButton type="button" variant="secondary" className="w-full" onClick={() => onContinueWithGoogle?.()}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.479 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.085-1.39-.189-1.989H12.24z" />
</svg>
Continue with Google
</TcnButton>
</div>
<div className="mt-6 flex items-center justify-center gap-1 text-sm text-[var(--color-on-surface-variant)]">
<span>Already have an account?</span>
<TcnButton type="button" variant="ghost" size="sm" onClick={() => onSignIn?.()}>
Sign in
</TcnButton>
</div>
</div>
</TcnPage>
);
}Source · Vue
<script lang="ts">
export interface SignupValues {
name: string;
email: string;
password: string;
acceptedTerms: boolean;
}
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { TcnButton } from '@/components/ui/button';
import { TcnCheckbox } from '@/components/ui/checkbox';
import { TcnDivider } from '@/components/ui/divider';
import { TcnInput } from '@/components/ui/input';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnSpinner } from '@/components/ui/spinner';
/**
* Sign-up screen — a purely presentational registration form. It owns only the
* field values and emits them through `submit`; wire real account creation (and
* the `loading` flag) from the parent. The submit button stays disabled until
* the terms are accepted.
*/
withDefaults(
defineProps<{
/** Shows the submit button's spinner and disables the form while true. */
loading?: boolean;
/** Renders a back control in the navbar's leading slot. */
showBack?: boolean;
/** Target of the "Terms of Service" link. */
termsUrl?: string;
/** Target of the "Privacy Policy" link. */
privacyUrl?: string;
}>(),
{ loading: false, showBack: false, termsUrl: '#', privacyUrl: '#' },
);
const emit = defineEmits<{
/** Fires with the entered details when the form is submitted. */
submit: [values: SignupValues];
/** Fires when the navbar back control is activated. */
back: [];
/** Fires when the sign-in affordance is activated. */
signIn: [];
/** Placeholder social sign-up affordance. */
continueWithApple: [];
/** Placeholder social sign-up affordance. */
continueWithGoogle: [];
}>();
const name = ref('');
const email = ref('');
const password = ref('');
const acceptedTerms = ref(false);
const handleSubmit = (): void => {
emit('submit', {
name: name.value,
email: email.value,
password: password.value,
acceptedTerms: acceptedTerms.value,
});
};
</script>
<template>
<TcnPage>
<TcnNavbar title="Sign up">
<template v-if="showBack" #leading>
<button type="button" class="tcn-navbar-back" aria-label="Back" @click="emit('back')">
<svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
<path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>
</TcnNavbar>
<div class="mx-auto flex min-h-full max-w-[430px] flex-col justify-center px-5 pt-20 pb-12">
<div class="mb-8 text-center">
<h1 class="text-2xl font-semibold">Create your account</h1>
<p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">Join in a few seconds — it's free.</p>
</div>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<TcnInput v-model="name" label="Name" type="text" autocomplete="name" />
<TcnInput v-model="email" label="Email" type="email" autocomplete="email" />
<TcnInput v-model="password" label="Password" type="password" autocomplete="new-password" />
<!-- Real links must live OUTSIDE the checkbox: its label slot renders
inside a button, where nested links are invalid and every click
toggles. The bare box centers against the text row instead. -->
<div class="mt-1 flex items-center gap-2.5">
<TcnCheckbox
v-model="acceptedTerms"
class="[&_.tcn-checkbox-box]:mt-0"
aria-label="I agree to the Terms of Service and Privacy Policy"
/>
<span class="text-sm leading-snug text-[var(--color-on-surface-variant)]">
I agree to the
<a class="text-[var(--color-primary)] underline" :href="termsUrl" target="_blank" rel="noopener noreferrer">Terms of Service</a>
and
<a class="text-[var(--color-primary)] underline" :href="privacyUrl" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
</span>
</div>
<TcnButton type="submit" class="mt-2 w-full" :disabled="loading || !acceptedTerms">
<TcnSpinner v-if="loading" size="sm" />
Sign up
</TcnButton>
</form>
<div class="my-6 flex items-center gap-3">
<TcnDivider class="flex-1" />
<span class="text-xs uppercase tracking-wide text-[var(--color-on-surface-variant)]">or</span>
<TcnDivider class="flex-1" />
</div>
<div class="flex flex-col gap-3">
<TcnButton type="button" variant="secondary" class="w-full" @click="emit('continueWithApple')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.05 12.04c-.03-2.6 2.12-3.85 2.22-3.91-1.21-1.77-3.09-2.02-3.76-2.04-1.6-.16-3.12.94-3.93.94-.81 0-2.06-.92-3.39-.9-1.74.03-3.35 1.01-4.25 2.57-1.81 3.14-.46 7.79 1.3 10.34.86 1.25 1.88 2.65 3.22 2.6 1.29-.05 1.78-.83 3.34-.83 1.56 0 2 .83 3.37.81 1.39-.03 2.27-1.27 3.12-2.53.98-1.45 1.39-2.85 1.41-2.93-.03-.01-2.7-1.04-2.73-4.13zM14.6 4.59c.71-.86 1.19-2.06 1.06-3.25-1.02.04-2.26.68-2.99 1.54-.66.76-1.23 1.98-1.08 3.15 1.14.09 2.3-.58 3.01-1.44z" />
</svg>
Continue with Apple
</TcnButton>
<TcnButton type="button" variant="secondary" class="w-full" @click="emit('continueWithGoogle')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.479 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.085-1.39-.189-1.989H12.24z" />
</svg>
Continue with Google
</TcnButton>
</div>
<div class="mt-6 flex items-center justify-center gap-1 text-sm text-[var(--color-on-surface-variant)]">
<span>Already have an account?</span>
<TcnButton type="button" variant="ghost" size="sm" @click="emit('signIn')">Sign in</TcnButton>
</div>
</div>
</TcnPage>
</template>export { default as TcnSignupBlock } from './TcnSignupBlock.vue';
export type { SignupValues } from './TcnSignupBlock.vue';