Form

Building forms with proper structure, validation, and accessibility using composable form components.

PreviousNext
This is your display name.
We'll never share your email with anyone else.
Optional: Brief description about yourself.
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
 
import { generateId } from '../../../shared/utils/utils';
import { ZardButtonComponent } from '../../button/button.component';
import { ZardInputDirective } from '../../input/input.directive';
import { ZardFormModule } from '../form.module';
 
@Component({
  selector: 'zard-demo-form-default',
  imports: [FormsModule, ZardButtonComponent, ZardInputDirective, ZardFormModule],
  standalone: true,
  template: `
    <form class="max-w-sm space-y-6">
      <z-form-field>
        <label z-form-label zRequired [for]="idFullName">Full Name</label>
        <z-form-control>
          <input
            z-input
            type="text"
            [id]="idFullName"
            placeholder="Enter your full name"
            [(ngModel)]="fullName"
            name="fullName"
          />
        </z-form-control>
        <z-form-message>This is your display name.</z-form-message>
      </z-form-field>
 
      <z-form-field>
        <label z-form-label zRequired [for]="idEmail">Email</label>
        <z-form-control>
          <input z-input type="email" [id]="idEmail" placeholder="Enter your email" [(ngModel)]="email" name="email" />
        </z-form-control>
        <z-form-message>We'll never share your email with anyone else.</z-form-message>
      </z-form-field>
 
      <z-form-field>
        <label z-form-label [for]="idBio">Bio</label>
        <z-form-control>
          <textarea
            z-input
            [id]="idBio"
            placeholder="Tell us about yourself"
            rows="3"
            [(ngModel)]="bio"
            name="bio"
          ></textarea>
        </z-form-control>
        <z-form-message>Optional: Brief description about yourself.</z-form-message>
      </z-form-field>
 
      <button z-button zType="default" type="submit">Submit</button>
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
})
export class ZardDemoFormDefaultComponent {
  protected readonly idFullName = generateId('fullName');
  protected readonly idEmail = generateId('email');
  protected readonly idBio = generateId('bio');
 
  fullName = '';
  email = '';
  bio = '';
}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui@latest add form
1

Add the component files

Create the component directory structure and add the following files to your project.

form.component.ts
form.component.ts
import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core';
 
import type { ClassValue } from 'clsx';
 
import {
  formFieldVariants,
  formControlVariants,
  formLabelVariants,
  formMessageVariants,
  type ZardFormMessageVariants,
} from './form.variants';
import { mergeClasses, transform } from '../../shared/utils/utils';
 
@Component({
  selector: 'z-form-field, [z-form-field]',
  standalone: true,
  template: '<ng-content />',
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
  },
  exportAs: 'zFormField',
})
export class ZardFormFieldComponent {
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(formFieldVariants(), this.class()));
}
 
@Component({
  selector: 'z-form-control, [z-form-control]',
  imports: [],
  standalone: true,
  template: `
    <div class="relative">
      <ng-content />
    </div>
    @if (errorMessage() || helpText()) {
      <div class="mt-1.5 min-h-[1.25rem]">
        @if (errorMessage()) {
          <p class="text-sm text-red-500">{{ errorMessage() }}</p>
        } @else if (helpText()) {
          <p class="text-muted-foreground text-sm">{{ helpText() }}</p>
        }
      </div>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
  },
  exportAs: 'zFormControl',
})
export class ZardFormControlComponent {
  readonly class = input<ClassValue>('');
  readonly errorMessage = input<string>('');
  readonly helpText = input<string>('');
 
  protected readonly classes = computed(() => mergeClasses(formControlVariants(), this.class()));
}
 
@Component({
  selector: 'z-form-label, label[z-form-label]',
  standalone: true,
  template: '<ng-content />',
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
  },
  exportAs: 'zFormLabel',
})
export class ZardFormLabelComponent {
  readonly class = input<ClassValue>('');
  readonly zRequired = input(false, { transform });
 
  protected readonly classes = computed(() =>
    mergeClasses(formLabelVariants({ zRequired: this.zRequired() }), this.class()),
  );
}
 
@Component({
  selector: 'z-form-message, [z-form-message]',
  standalone: true,
  template: '<ng-content />',
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  host: {
    '[class]': 'classes()',
  },
  exportAs: 'zFormMessage',
})
export class ZardFormMessageComponent {
  readonly class = input<ClassValue>('');
  readonly zType = input<ZardFormMessageVariants['zType']>('default');
 
  protected readonly classes = computed(() => mergeClasses(formMessageVariants({ zType: this.zType() }), this.class()));
}
 
form.variants.ts
form.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
export const formFieldVariants = cva('grid gap-2');
 
export const formLabelVariants = cva(
  'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
  {
    variants: {
      zRequired: {
        true: "after:content-['*'] after:ml-0.5 after:text-red-500",
      },
    },
  },
);
 
export const formControlVariants = cva('');
 
export const formMessageVariants = cva('text-sm', {
  variants: {
    zType: {
      default: 'text-muted-foreground',
      error: 'text-red-500',
      success: 'text-green-500',
      warning: 'text-yellow-500',
    },
  },
  defaultVariants: {
    zType: 'default',
  },
});
 
export type ZardFormFieldVariants = VariantProps<typeof formFieldVariants>;
export type ZardFormLabelVariants = VariantProps<typeof formLabelVariants>;
export type ZardFormControlVariants = VariantProps<typeof formControlVariants>;
export type ZardFormMessageVariants = VariantProps<typeof formMessageVariants>;
 
form.module.ts
form.module.ts
import { NgModule } from '@angular/core';
 
import {
  ZardFormControlComponent,
  ZardFormFieldComponent,
  ZardFormLabelComponent,
  ZardFormMessageComponent,
} from './form.component';
 
const FORM_COMPONENTS = [
  ZardFormFieldComponent,
  ZardFormLabelComponent,
  ZardFormControlComponent,
  ZardFormMessageComponent,
];
 
@NgModule({
  imports: [...FORM_COMPONENTS],
  exports: [...FORM_COMPONENTS],
})
export class ZardFormModule {}
 

Examples

default

This is your display name.
We'll never share your email with anyone else.
Optional: Brief description about yourself.
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
 
import { generateId } from '../../../shared/utils/utils';
import { ZardButtonComponent } from '../../button/button.component';
import { ZardInputDirective } from '../../input/input.directive';
import { ZardFormModule } from '../form.module';
 
@Component({
  selector: 'zard-demo-form-default',
  imports: [FormsModule, ZardButtonComponent, ZardInputDirective, ZardFormModule],
  standalone: true,
  template: `
    <form class="max-w-sm space-y-6">
      <z-form-field>
        <label z-form-label zRequired [for]="idFullName">Full Name</label>
        <z-form-control>
          <input
            z-input
            type="text"
            [id]="idFullName"
            placeholder="Enter your full name"
            [(ngModel)]="fullName"
            name="fullName"
          />
        </z-form-control>
        <z-form-message>This is your display name.</z-form-message>
      </z-form-field>
 
      <z-form-field>
        <label z-form-label zRequired [for]="idEmail">Email</label>
        <z-form-control>
          <input z-input type="email" [id]="idEmail" placeholder="Enter your email" [(ngModel)]="email" name="email" />
        </z-form-control>
        <z-form-message>We'll never share your email with anyone else.</z-form-message>
      </z-form-field>
 
      <z-form-field>
        <label z-form-label [for]="idBio">Bio</label>
        <z-form-control>
          <textarea
            z-input
            [id]="idBio"
            placeholder="Tell us about yourself"
            rows="3"
            [(ngModel)]="bio"
            name="bio"
          ></textarea>
        </z-form-control>
        <z-form-message>Optional: Brief description about yourself.</z-form-message>
      </z-form-field>
 
      <button z-button zType="default" type="submit">Submit</button>
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
})
export class ZardDemoFormDefaultComponent {
  protected readonly idFullName = generateId('fullName');
  protected readonly idEmail = generateId('email');
  protected readonly idBio = generateId('bio');
 
  fullName = '';
  email = '';
  bio = '';
}
 

reactive

Username must be 3-20 characters long.
We'll use this for account notifications.
Password must be at least 6 characters.
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardInputDirective } from '../../input/input.directive';
import { ZardFormModule } from '../form.module';
 
@Component({
  selector: 'zard-demo-form-reactive',
  imports: [ReactiveFormsModule, ZardButtonComponent, ZardInputDirective, ZardFormModule],
  standalone: true,
  template: `
    <form [formGroup]="profileForm" (ngSubmit)="onSubmit()" class="max-w-sm space-y-6">
      <z-form-field>
        <label z-form-label zRequired>Username</label>
        <z-form-control>
          <input z-input type="text" placeholder="Choose a username" formControlName="username" />
        </z-form-control>
        <z-form-message zType="default">Username must be 3-20 characters long.</z-form-message>
      </z-form-field>
 
      <z-form-field>
        <label z-form-label zRequired>Email</label>
        <z-form-control>
          <input z-input type="email" placeholder="Enter your email" formControlName="email" />
        </z-form-control>
        <z-form-message zType="default">We'll use this for account notifications.</z-form-message>
      </z-form-field>
 
      <z-form-field>
        <label z-form-label zRequired>Password</label>
        <z-form-control>
          <input z-input type="password" placeholder="Create a password" formControlName="password" />
        </z-form-control>
        <z-form-message zType="default">Password must be at least 6 characters.</z-form-message>
      </z-form-field>
 
      <button z-button zType="default" type="submit" [disabled]="profileForm.invalid">Create Account</button>
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
})
export class ZardDemoFormReactiveComponent {
  profileForm = new FormGroup({
    username: new FormControl('', [Validators.required, Validators.minLength(3), Validators.maxLength(20)]),
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required, Validators.minLength(6)]),
  });
 
  onSubmit() {
    if (this.profileForm.valid) {
      console.log('Form submitted:', this.profileForm.value);
    }
  }
}
 

validation

Enter your full name.
We'll never share your email.
Optional: Your website or portfolio URL.
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardInputDirective } from '../../input/input.directive';
import { ZardFormModule } from '../form.module';
 
@Component({
  selector: 'zard-demo-form-validation',
  imports: [ReactiveFormsModule, ZardButtonComponent, ZardInputDirective, ZardFormModule],
  standalone: true,
  template: `
    <form [formGroup]="validationForm" (ngSubmit)="onSubmit()" class="max-w-sm space-y-6">
      <z-form-field>
        <label z-form-label zRequired>Name</label>
        <z-form-control>
          <input
            z-input
            type="text"
            placeholder="Your full name"
            formControlName="name"
            [zStatus]="nameControl.invalid && nameControl.touched ? 'error' : undefined"
          />
        </z-form-control>
        @if (nameControl.hasError('required') && nameControl.touched) {
          <z-form-message zType="error">Name is required.</z-form-message>
        } @else if (nameControl.hasError('minlength') && nameControl.touched) {
          <z-form-message zType="error">Name must be at least 2 characters long.</z-form-message>
        } @else {
          <z-form-message>Enter your full name.</z-form-message>
        }
      </z-form-field>
 
      <z-form-field>
        <label z-form-label zRequired>Email</label>
        <z-form-control>
          <input
            z-input
            type="email"
            placeholder="your.email@example.com"
            formControlName="email"
            [zStatus]="emailControl.invalid && emailControl.touched ? 'error' : undefined"
          />
        </z-form-control>
        @if (emailControl.hasError('required') && emailControl.touched) {
          <z-form-message zType="error">Email is required.</z-form-message>
        } @else if (emailControl.hasError('email') && emailControl.touched) {
          <z-form-message zType="error">Please enter a valid email address.</z-form-message>
        } @else {
          <z-form-message>We'll never share your email.</z-form-message>
        }
      </z-form-field>
 
      <z-form-field>
        <label z-form-label>Website</label>
        <z-form-control>
          <input
            z-input
            type="url"
            placeholder="https://example.com"
            formControlName="website"
            [zStatus]="
              websiteControl.invalid && websiteControl.touched
                ? 'error'
                : websiteControl.valid && websiteControl.touched
                  ? 'success'
                  : undefined
            "
          />
        </z-form-control>
        @if (websiteControl.hasError('pattern') && websiteControl.touched) {
          <z-form-message zType="error">Please enter a valid URL starting with http:// or https://</z-form-message>
        } @else if (websiteControl.valid && websiteControl.touched && websiteControl.value) {
          <z-form-message zType="success">Valid website URL!</z-form-message>
        } @else {
          <z-form-message>Optional: Your website or portfolio URL.</z-form-message>
        }
      </z-form-field>
 
      <div class="flex gap-2">
        <button z-button zType="default" type="submit" [disabled]="validationForm.invalid">Submit</button>
        <button z-button zType="outline" type="button" (click)="reset()">Reset</button>
      </div>
 
      @if (submitted) {
        <z-form-message zType="success" class="block">Form submitted successfully! ✓</z-form-message>
      }
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
})
export class ZardDemoFormValidationComponent {
  submitted = false;
 
  validationForm = new FormGroup({
    name: new FormControl('', [Validators.required, Validators.minLength(2)]),
    email: new FormControl('', [Validators.required, Validators.email]),
    website: new FormControl('', [Validators.pattern(/^https?:\/\/.+/)]),
  });
 
  get nameControl() {
    return this.validationForm.get('name')!;
  }
 
  get emailControl() {
    return this.validationForm.get('email')!;
  }
 
  get websiteControl() {
    return this.validationForm.get('website')!;
  }
 
  onSubmit() {
    if (this.validationForm.valid) {
      this.submitted = true;
      console.log('Form submitted:', this.validationForm.value);
 
      // Hide success message after 3 seconds
      setTimeout(() => {
        this.submitted = false;
      }, 3000);
    }
  }
 
  reset() {
    this.validationForm.reset();
    this.submitted = false;
  }
}
 

complex

Include country code if outside US

Optional: Where do you work?

0/500 characters

Get updates about new features and releases

import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardCheckboxComponent } from '../../checkbox/checkbox.component';
import { ZardInputDirective } from '../../input/input.directive';
import { ZardSelectItemComponent } from '../../select/select-item.component';
import { ZardSelectComponent } from '../../select/select.component';
import { ZardFormModule } from '../form.module';
 
interface FormData {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  country: string;
  company: string;
  message: string;
  newsletter: boolean;
  terms: boolean;
}
 
@Component({
  selector: 'zard-demo-form-complex',
  imports: [
    ReactiveFormsModule,
    ZardButtonComponent,
    ZardInputDirective,
    ZardCheckboxComponent,
    ZardSelectComponent,
    ZardSelectItemComponent,
    ZardFormModule,
  ],
  standalone: true,
  template: `
    <form [formGroup]="form" (ngSubmit)="handleSubmit()" class="max-w-lg space-y-6">
      <!-- Name Fields Row -->
      <div class="flex items-start gap-4">
        <z-form-field>
          <label z-form-label zRequired for="firstName">First Name</label>
          <z-form-control [errorMessage]="isFieldInvalid('firstName') ? 'First name is required' : ''">
            <input z-input id="firstName" type="text" placeholder="John" formControlName="firstName" />
          </z-form-control>
        </z-form-field>
 
        <z-form-field>
          <label z-form-label zRequired for="lastName">Last Name</label>
          <z-form-control [errorMessage]="isFieldInvalid('lastName') ? 'Last name is required' : ''">
            <input z-input id="lastName" type="text" placeholder="Doe" formControlName="lastName" />
          </z-form-control>
        </z-form-field>
      </div>
 
      <!-- Email Field -->
      <z-form-field>
        <label z-form-label zRequired for="email">Email</label>
        <z-form-control [errorMessage]="isFieldInvalid('email') ? getEmailError() : ''">
          <input z-input id="email" type="email" placeholder="john.doe@example.com" formControlName="email" />
        </z-form-control>
      </z-form-field>
 
      <!-- Phone Field -->
      <z-form-field>
        <label z-form-label for="phone">Phone Number</label>
        <z-form-control helpText="Include country code if outside US">
          <input z-input id="phone" type="tel" placeholder="+1 (555) 123-4567" formControlName="phone" />
        </z-form-control>
      </z-form-field>
 
      <!-- Country Selector -->
      <z-form-field>
        <label z-form-label zRequired for="country">Country</label>
        <z-form-control [errorMessage]="isFieldInvalid('country') ? 'Please select a country' : ''">
          <z-select id="country" formControlName="country" placeholder="Select your country">
            @for (country of countries; track country.value) {
              <z-select-item [zValue]="country.value">{{ country.label }}</z-select-item>
            }
          </z-select>
        </z-form-control>
      </z-form-field>
 
      <!-- Company Field -->
      <z-form-field>
        <label z-form-label for="company">Company</label>
        <z-form-control helpText="Optional: Where do you work?">
          <input z-input id="company" type="text" placeholder="Your company name" formControlName="company" />
        </z-form-control>
      </z-form-field>
 
      <!-- Message Field -->
      <z-form-field>
        <label z-form-label for="message">Message</label>
        <z-form-control
          [errorMessage]="isFieldInvalid('message') ? 'Message is too long (max 500 characters)' : ''"
          [helpText]="!isFieldInvalid('message') ? messageLength() + '/500 characters' : ''"
        >
          <textarea
            z-input
            id="message"
            rows="4"
            placeholder="Tell us about your project or inquiry..."
            formControlName="message"
          ></textarea>
        </z-form-control>
      </z-form-field>
 
      <!-- Newsletter Checkbox -->
      <z-form-field>
        <z-form-control helpText="Get updates about new features and releases" class="flex flex-col">
          <div class="flex items-center space-x-2">
            <z-checkbox id="newsletter" formControlName="newsletter" />
            <label z-form-label class="!mb-0" for="newsletter">Subscribe to newsletter</label>
          </div>
        </z-form-control>
      </z-form-field>
 
      <!-- Terms Checkbox -->
      <z-form-field>
        <z-form-control
          [errorMessage]="isFieldInvalid('terms') ? 'You must accept the terms and conditions' : ''"
          class="flex flex-col"
        >
          <div class="flex items-center space-x-2">
            <z-checkbox id="terms" formControlName="terms" />
            <label z-form-label class="!mb-0" zRequired for="terms">I agree to the terms and conditions</label>
          </div>
        </z-form-control>
      </z-form-field>
 
      <!-- Action Buttons -->
      <div class="flex gap-2 pt-4">
        <button z-button zType="default" type="submit" [disabled]="isSubmitting()">
          {{ isSubmitting() ? 'Submitting...' : 'Submit Form' }}
        </button>
        <button z-button zType="outline" type="button" (click)="resetForm()">Reset</button>
      </div>
 
      <!-- Success Message -->
      @if (showSuccess()) {
        <div class="rounded-md border border-green-200 bg-green-50 p-4">
          <z-form-message zType="success">✓ Form submitted successfully! We'll get back to you soon.</z-form-message>
        </div>
      }
    </form>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
})
export class ZardDemoFormComplexComponent {
  private readonly fb = inject(FormBuilder);
  private readonly destroyRef = inject(DestroyRef);
 
  readonly showSuccess = signal(false);
  readonly isSubmitting = signal(false);
 
  readonly countries = [
    { value: 'us', label: 'United States' },
    { value: 'ca', label: 'Canada' },
    { value: 'uk', label: 'United Kingdom' },
    { value: 'au', label: 'Australia' },
    { value: 'de', label: 'Germany' },
    { value: 'fr', label: 'France' },
    { value: 'jp', label: 'Japan' },
    { value: 'br', label: 'Brazil' },
  ] as const;
 
  readonly form = this.fb.nonNullable.group({
    firstName: ['', [Validators.required, Validators.minLength(2)]],
    lastName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    phone: [''],
    country: ['', Validators.required],
    company: [''],
    message: ['', Validators.maxLength(500)],
    newsletter: [false],
    terms: [false, Validators.requiredTrue],
  });
 
  readonly messageLength = signal(0);
 
  constructor() {
    // Track message length
    this.form.controls.message.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
      this.messageLength.set(value?.length ?? 0);
    });
  }
 
  isFieldInvalid(fieldName: keyof FormData): boolean {
    const field = this.form.get(fieldName);
    return !!(field?.invalid && (field?.dirty || field?.touched));
  }
 
  getEmailError(): string {
    const email = this.form.get('email');
    if (email?.hasError('required')) {
      return 'Email is required';
    }
    if (email?.hasError('email')) {
      return 'Please enter a valid email address';
    }
    return '';
  }
 
  async handleSubmit(): Promise<void> {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
 
    this.isSubmitting.set(true);
 
    await this.simulateApiCall();
 
    this.isSubmitting.set(false);
    this.showSuccess.set(true);
 
    console.log('Form submitted:', this.form.getRawValue());
 
    setTimeout(() => {
      this.showSuccess.set(false);
    }, 5000);
  }
 
  resetForm(): void {
    this.form.reset();
    this.showSuccess.set(false);
    this.messageLength.set(0);
  }
 
  private simulateApiCall(): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, 1000));
  }
}
 

Form API Reference

ZardFormFieldComponent

Container component that provides proper spacing and structure for form elements.

Props

Name Type Default Description
class ClassValue '' Additional CSS classes

ZardFormLabelComponent

Accessible label component with optional required indicator.

Props

Name Type Default Description
class ClassValue '' Additional CSS classes
zRequired boolean false Shows required indicator (*)

ZardFormControlComponent

Wrapper component for form controls that provides proper positioning and styling context.

Props

Name Type Default Description
class ClassValue '' Additional CSS classes

ZardFormMessageComponent

Component for displaying helper text, validation messages, and other form-related information.

Props

Name Type Default Description
class ClassValue '' Additional CSS classes
zType 'default' | 'error' | 'success' | 'warning' 'default' Message type affecting color