Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Login

A sign-in screen composed from touchcn components.

A purely presentational sign-in screen: an email and password field, a remember-me checkbox, a submit button with a loading state, a forgot-password action, an “or” divider, Apple and Google social sign-in placeholders and a sign-up affordance. It owns only the field values and emits them — wire real authentication from the parent.

Installation

npx touchcn add login

This also adds the components the block composes:

button · checkbox · divider · input · navbar · page · spinner

Usage

<tcn-login-block [loading]="loading()" (submitted)="signIn($event)" />
import { TcnLoginBlock } from '@/components/blocks/login';

<TcnLoginBlock loading={loading} onSubmit={(values) => signIn(values)} />
<script setup lang="ts">
import { TcnLoginBlock } from '@/components/blocks/login';
</script>

<template>
  <TcnLoginBlock :loading="loading" @submit="(values) => signIn(values)" />
</template>

The submit callback receives { email, password, remember }. 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-login-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 LoginValues {
  email: string;
  password: string;
  remember: boolean;
}

/**
 * Sign-in screen — a purely presentational auth form. It owns only the field
 * values and emits them through `submitted`; wire real authentication (and the
 * `loading` flag) from the parent.
 */
@Component({
  selector: 'tcn-login-block',
  imports: [TcnPage, TcnNavbar, TcnInput, TcnCheckbox, TcnButton, TcnDivider, TcnSpinner],
  template: `
    <tcn-page>
      <tcn-navbar title="Sign in">
        @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">Welcome back</h1>
          <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">Sign in to continue to your account.</p>
        </div>

        <form class="flex flex-col gap-4" (submit)="$event.preventDefault(); submit()">
          <tcn-input label="Email" type="email" [(value)]="email" />
          <tcn-input label="Password" type="password" [(value)]="password" />

          <div class="flex items-center justify-between">
            <tcn-checkbox [(checked)]="remember">Remember me</tcn-checkbox>
            <button tcnButton variant="ghost" size="sm" type="button" (click)="forgotPassword.emit()">
              Forgot password?
            </button>
          </div>

          <button tcnButton type="submit" class="mt-2 w-full" [disabled]="loading()">
            @if (loading()) {
              <tcn-spinner size="sm" />
            }
            Sign in
          </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>Don't have an account?</span>
          <button tcnButton variant="ghost" size="sm" type="button" (click)="signUp.emit()">Sign up</button>
        </div>
      </div>
    </tcn-page>
  `,
})
export class TcnLoginBlock {
  /** 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 });
  readonly submitted = output<LoginValues>();
  readonly back = output<void>();
  readonly forgotPassword = output<void>();
  readonly continueWithApple = output<void>();
  readonly continueWithGoogle = output<void>();
  readonly signUp = output<void>();

  protected readonly email = signal('');
  protected readonly password = signal('');
  protected readonly remember = signal(false);

  protected submit(): void {
    this.submitted.emit({
      email: this.email(),
      password: this.password(),
      remember: this.remember(),
    });
  }
}
Source · React
export * from './tcn-login-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 LoginValues {
  email: string;
  password: string;
  remember: boolean;
}

export interface TcnLoginBlockProps {
  /** 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;
  /** Fires with the entered credentials when the form is submitted. */
  onSubmit?(values: LoginValues): void;
  /** Fires when the navbar back control is activated. */
  onBack?(): void;
  onForgotPassword?(): void;
  /** Placeholder social sign-in affordance. */
  onContinueWithApple?(): void;
  /** Placeholder social sign-in affordance. */
  onContinueWithGoogle?(): void;
  /** Fires when the sign-up affordance is activated. */
  onSignUp?(): void;
}

/**
 * Sign-in screen — a purely presentational auth form. It owns only the field
 * values and emits them through `onSubmit`; wire real authentication (and the
 * `loading` flag) from the parent.
 */
export function TcnLoginBlock({
  loading = false,
  showBack = false,
  onSubmit,
  onBack,
  onForgotPassword,
  onContinueWithApple,
  onContinueWithGoogle,
  onSignUp,
}: TcnLoginBlockProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [remember, setRemember] = useState(false);

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault();
    onSubmit?.({ email, password, remember });
  };

  return (
    <TcnPage>
      <TcnNavbar
        title="Sign in"
        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">Welcome back</h1>
          <p className="mt-1 text-sm text-[var(--color-on-surface-variant)]">
            Sign in to continue to your account.
          </p>
        </div>

        <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
          <TcnInput label="Email" type="email" autoComplete="email" value={email} onValueChange={setEmail} />
          <TcnInput
            label="Password"
            type="password"
            autoComplete="current-password"
            value={password}
            onValueChange={setPassword}
          />

          <div className="flex items-center justify-between">
            <TcnCheckbox checked={remember} onCheckedChange={setRemember}>
              Remember me
            </TcnCheckbox>
            <TcnButton type="button" variant="ghost" size="sm" onClick={() => onForgotPassword?.()}>
              Forgot password?
            </TcnButton>
          </div>

          <TcnButton type="submit" className="mt-2 w-full" disabled={loading}>
            {loading && <TcnSpinner size="sm" />}
            Sign in
          </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>Don&apos;t have an account?</span>
          <TcnButton type="button" variant="ghost" size="sm" onClick={() => onSignUp?.()}>
            Sign up
          </TcnButton>
        </div>
      </div>
    </TcnPage>
  );
}
Source · Vue
<script lang="ts">
export interface LoginValues {
  email: string;
  password: string;
  remember: 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-in screen — a purely presentational auth form. It owns only the field
 * values and emits them through `submit`; wire real authentication (and the
 * `loading` flag) from the parent.
 */
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;
  }>(),
  { loading: false, showBack: false },
);

const emit = defineEmits<{
  /** Fires with the entered credentials when the form is submitted. */
  submit: [values: LoginValues];
  /** Fires when the navbar back control is activated. */
  back: [];
  forgotPassword: [];
  /** Placeholder social sign-in affordance. */
  continueWithApple: [];
  /** Placeholder social sign-in affordance. */
  continueWithGoogle: [];
  /** Fires when the sign-up affordance is activated. */
  signUp: [];
}>();

const email = ref('');
const password = ref('');
const remember = ref(false);

const handleSubmit = (): void => {
  emit('submit', { email: email.value, password: password.value, remember: remember.value });
};
</script>

<template>
  <TcnPage>
    <TcnNavbar title="Sign in">
      <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">Welcome back</h1>
        <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">Sign in to continue to your account.</p>
      </div>

      <form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
        <TcnInput v-model="email" label="Email" type="email" autocomplete="email" />
        <TcnInput v-model="password" label="Password" type="password" autocomplete="current-password" />

        <div class="flex items-center justify-between">
          <TcnCheckbox v-model="remember">Remember me</TcnCheckbox>
          <TcnButton type="button" variant="ghost" size="sm" @click="emit('forgotPassword')">
            Forgot password?
          </TcnButton>
        </div>

        <TcnButton type="submit" class="mt-2 w-full" :disabled="loading">
          <TcnSpinner v-if="loading" size="sm" />
          Sign in
        </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>Don't have an account?</span>
        <TcnButton type="button" variant="ghost" size="sm" @click="emit('signUp')">Sign up</TcnButton>
      </div>
    </div>
  </TcnPage>
</template>
export { default as TcnLoginBlock } from './TcnLoginBlock.vue';
export type { LoginValues } from './TcnLoginBlock.vue';

Last updated on July 24, 2026

Was this page helpful?