40+ landing page components for ReactBrowse now

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:

Input

Text input with label, placeholder, and error states

Textarea

Multi-line text input with auto-resize support

Select

Dropdown select with custom styling

Checkbox

Checkbox with label text

RadioGroup

Group of radio buttons for single selection

Switch

Toggle switch for boolean values

Button

Submit 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:

ContactForm.tsx
"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:

components/ContactForm.tsx
"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