Guides
Forms & Validation
Build accessible, validated forms using trink-ui primitives. This guide covers Input, Select, Textarea, and Button components for common landing page forms like contact forms and newsletter signups.
Available form primitives
trink-ui provides the following form-related primitives, all with consistent styling and full keyboard accessibility:
InputText input with label, placeholder, and error states
TextareaMulti-line text input with auto-resize support
SelectDropdown select with custom styling
CheckboxCheckbox with label text
RadioGroupGroup of radio buttons for single selection
SwitchToggle switch for boolean values
ButtonSubmit and action buttons with loading states
Basic input usage
The Input component supports labels, placeholders, helper text, and error messages:
import { Input } from "@trinkui/react";
<Input
label="Email address"
type="email"
placeholder="you@example.com"
required
/>
<Input
label="Full name"
placeholder="John Doe"
helperText="Enter your name as it appears on your ID."
/>
<Input
label="Username"
placeholder="johndoe"
error="This username is already taken."
/>HTML5 native validation
trink-ui inputs pass through all standard HTML attributes, so you can use native browser validation with required, pattern, minLength, maxLength, and type:
<Input
label="Phone number"
type="tel"
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
placeholder="123-456-7890"
required
/>
<Input
label="Password"
type="password"
minLength={8}
required
/>Custom validation with onChange
For more control, use React state to validate on change and display errors dynamically:
"use client";
import { useState } from "react";
import { Input } from "@trinkui/react";
export function EmailInput() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setEmail(value);
if (value && !value.includes("@")) {
setError("Please enter a valid email address.");
} else {
setError("");
}
}
return (
<Input
label="Email"
type="email"
value={email}
onChange={handleChange}
error={error}
placeholder="you@company.com"
required
/>
);
}Error state display
When you pass an error string to any form primitive, it automatically applies a red border, displays the error message below the input, and sets aria-invalid="true" for screen readers. The error is associated with the input via aria-describedby.
// Error state — red border + error message shown
<Input
label="Website"
type="url"
error="Please enter a valid URL starting with https://"
/>
// Valid state — no error
<Input
label="Website"
type="url"
value="https://example.com"
/>Complete contact form example
Here is a full contact form with name, email, budget select, message textarea, and a submit button with loading state:
"use client";
import { useState } from "react";
import { Input, Textarea, Button } from "@trinkui/react";
interface FormData {
name: string;
email: string;
budget: string;
message: string;
}
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
export function ContactForm() {
const [data, setData] = useState<FormData>({
name: "",
email: "",
budget: "",
message: "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
function validate(): boolean {
const newErrors: FormErrors = {};
if (!data.name.trim()) {
newErrors.name = "Name is required.";
}
if (!data.email.trim()) {
newErrors.email = "Email is required.";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
newErrors.email = "Please enter a valid email address.";
}
if (!data.message.trim()) {
newErrors.message = "Message is required.";
} else if (data.message.trim().length < 10) {
newErrors.message = "Message must be at least 10 characters.";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setIsSuccess(true);
} finally {
setIsSubmitting(false);
}
}
if (isSuccess) {
return (
<div className="rounded-lg border border-green-500/30 bg-green-500/5 p-6 text-center">
<p className="font-medium text-green-600">Message sent!</p>
<p className="mt-1 text-sm text-[rgb(var(--trinkui-muted))]">
We will get back to you within 24 hours.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<Input
label="Name"
placeholder="John Doe"
value={data.name}
onChange={(e) => setData({ ...data, name: e.target.value })}
error={errors.name}
required
/>
<Input
label="Email"
type="email"
placeholder="john@example.com"
value={data.email}
onChange={(e) => setData({ ...data, email: e.target.value })}
error={errors.email}
required
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[rgb(var(--trinkui-fg))]">
Budget
</label>
<select
value={data.budget}
onChange={(e) => setData({ ...data, budget: e.target.value })}
className="w-full rounded-lg border border-[rgb(var(--trinkui-border))] bg-[rgb(var(--trinkui-bg))] px-3 py-2 text-sm text-[rgb(var(--trinkui-fg))]"
>
<option value="">Select a budget range</option>
<option value="5k">Under $5,000</option>
<option value="10k">$5,000 - $10,000</option>
<option value="25k">$10,000 - $25,000</option>
<option value="50k">$25,000+</option>
</select>
</div>
<Textarea
label="Message"
placeholder="Tell us about your project..."
rows={5}
value={data.message}
onChange={(e) => setData({ ...data, message: e.target.value })}
error={errors.message}
required
/>
<Button type="submit" variant="primary" size="lg" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send Message"}
</Button>
</form>
);
}Inline newsletter signup
For a simpler form like a newsletter signup, combine an Input with a Button in a flex row:
<form className="flex gap-3">
<Input
type="email"
placeholder="Enter your email"
className="flex-1"
required
/>
<Button type="submit" variant="primary">
Subscribe
</Button>
</form>You can also use the NewsletterBanner or NewsletterSplit section components which include this pattern out of the box.
Next step
Learn how to build responsive layouts that look great on all screen sizes.
Responsive Design Guide