Forms Quick Start
Build a working form in 5 minutes. By the end you will have a validated form that shows field errors and a loading state on submit.
What you will build
Step 1 — Install
npm install primus-react-ui @primus/ui-core # React
npm install primus-angular-ui @primus/ui-core # Angular
Step 2 — Import styles
- React
- Angular
// main.tsx
import '@primus/ui-core/dist/primus-ui.min.css';
import 'primus-react-ui/styles.css';
// angular.json
"styles": [
"node_modules/@primus/ui-core/dist/primus-ui.min.css",
"src/styles.scss"
]
Step 3 — Build the form
- React
- Angular
- Plain HTML
import { useState } from 'react';
import { PrimusInput, PrimusTextarea, PrimusButton } from 'primus-react-ui';
export function ContactForm() {
const [form, setForm] = useState({ name: '', email: '', message: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const set = (key: string, val: string) =>
setForm(f => ({ ...f, [key]: val }));
const validate = () => {
const e: Record<string, string> = {};
if (!form.email) e.email = 'Email is required';
else if (!/\S+@\S+/.test(form.email)) e.email = 'Enter a valid email';
if (!form.message) e.message = 'Message is required';
return e;
};
const handleSubmit = async () => {
const e = validate();
if (Object.keys(e).length) { setErrors(e); return; }
setLoading(true);
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(form),
});
setLoading(false);
};
return (
<div className="card" style={{ maxWidth: 440, padding: '1.75rem' }}>
<h2>Get in touch</h2>
<p>We will get back to you within 24 hours.</p>
<PrimusInput
label="Full name"
value={form.name}
onChange={(v) => set('name', v)}
/>
<PrimusInput
label="Work email"
type="email"
required
value={form.email}
onChange={(v) => set('email', v)}
error={errors.email}
/>
<PrimusTextarea
label="Message"
rows={3}
required
value={form.message}
onChange={(v) => set('message', v)}
error={errors.message}
/>
<PrimusButton
style={{ width: '100%' }}
loading={loading}
onClick={handleSubmit}
>
Send message
</PrimusButton>
</div>
);
}
// contact-form.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-contact-form',
templateUrl: './contact-form.component.html',
})
export class ContactFormComponent {
form: FormGroup;
isLoading = false;
constructor(private fb: FormBuilder, private http: HttpClient) {
this.form = this.fb.group({
name: [''],
email: ['', [Validators.required, Validators.email]],
message: ['', Validators.required],
});
}
fieldError(field: string): string {
const c = this.form.get(field);
if (!c?.touched || !c?.errors) return '';
if (c.errors['required']) return `${field} is required`;
if (c.errors['email']) return 'Enter a valid email';
return '';
}
async onSubmit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.isLoading = true;
await this.http.post('/api/contact', this.form.value).toPromise();
this.isLoading = false;
}
}
<!-- contact-form.component.html -->
<div class="card" style="max-width:440px;padding:1.75rem">
<h2>Get in touch</h2>
<p>We will get back to you within 24 hours.</p>
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="vstack" style="gap:1rem">
<primus-input
label="Full name"
formControlName="name">
</primus-input>
<primus-input
label="Work email"
type="email"
[required]="true"
formControlName="email"
[error]="fieldError('email')">
</primus-input>
<primus-textarea
label="Message"
[rows]="3"
[required]="true"
formControlName="message"
[error]="fieldError('message')">
</primus-textarea>
<primus-button type="submit" style="width:100%" [loading]="isLoading">
Send message
</primus-button>
</form>
</div>
<!-- Zero dependencies — just @primus/ui-core CSS -->
<div class="card" style="max-width:440px;padding:1.75rem">
<h2>Get in touch</h2>
<p>We will get back to you within 24 hours.</p>
<form class="vstack" style="gap:1rem" action="/api/contact" method="POST">
<label data-field>
Full name
<input type="text" name="name" placeholder="Jane Doe" />
</label>
<div data-field="error">
<label>
Work email <span style="color:var(--danger)">*</span>
<input type="email" name="email" aria-invalid="true" required />
</label>
<span class="error">Please enter a valid email address.</span>
</div>
<label data-field>
Message <span style="color:var(--danger)">*</span>
<textarea name="message" rows="3" placeholder="How can we help?" required></textarea>
</label>
<button type="submit" style="width:100%">Send message</button>
</form>
</div>
What just happened
| Feature | How it works |
|---|---|
| Styled inputs | @primus/ui-core styles <input>, <textarea>, <select> automatically — no classes needed |
| Error display | data-field="error" + aria-invalid="true" → red border + error text below the field |
| Loading state | loading={true} on PrimusButton → spinner + disabled, prevents double-submit |
| Field label | <label> wrapping <input> (HTML) or label prop (React/Angular) |
| Required marker | <span style="color:var(--danger)">*</span> in label, or required prop |
Next steps
- Form Field — labelled wrapper for complex layouts
- Errors and Validation — form-level errors, async validation
- Primus Form — compound form manager with built-in state
- Create / Edit Form — full-page enterprise form recipe
Version history
See the Changelog for version history and breaking changes.