Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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';

Last updated on July 24, 2026

Was this page helpful?