Building Forms with carbon-components-svelte: The Complete Guide to Validation, Accessibility & State Management
Forms are where most web applications either earn trust or lose users forever.
A blinking red border with no explanation, an inaccessible label that a screen reader skips,
or a submit button that does nothing on the first click — any of these will quietly kill your
conversion rate while you’re busy wondering why. The good news is that
carbon-components-svelte
gives you a solid foundation: IBM’s battle-tested
Carbon Design System
translated into idiomatic Svelte components. The less obvious news is that you still need to
wire the pieces together correctly — and that’s exactly what this guide covers.
We’ll go from a blank .svelte file to a fully validated, screen-reader-friendly,
state-managed form, explaining every decision along the way. By the end you’ll understand not
just how to use these components, but why they behave the way they do —
which is the only knowledge that actually transfers to your next project.
Why carbon-components-svelte for Forms?
Before reaching for any component library, it’s fair to ask whether it actually solves your
problem or just adds a dependency. For enterprise-grade or accessibility-critical applications,
carbon-components-svelte
passes the test decisively. Every form component — TextInput, Select,
Checkbox, RadioButton, DatePicker and their siblings —
ships with correct ARIA roles, keyboard navigation, focus management, and high-contrast
theme support baked in. You’re not bolting accessibility on afterward; it’s the default.
The library maps almost 1:1 to IBM Carbon’s React implementation, so if your team already
knows Carbon from another project, onboarding is trivial. Props like invalid,
invalidText, labelText, and helperText work identically
across components, meaning your mental model stays consistent whether you’re styling a
simple text field or a complex date range picker. Consistency at this level isn’t a luxury —
it’s the difference between a codebase your team can maintain and one they quietly dread.
Svelte’s reactivity model is also a surprisingly good match for form logic.
Reactive declarations ($:), two-way binding with bind:value,
and the absence of a virtual DOM mean that validation state updates are cheap, synchronous,
and easy to reason about. You don’t need a form library to get a great result here —
though you can add one if your requirements demand it.
Setting Up: Installation and Basic Form Structure
If you haven’t already installed carbon-components-svelte, the setup is straightforward.
You’ll need the package itself and, optionally, a Carbon CSS bundle for global styles:
npm install carbon-components-svelte
Import the stylesheet once in your root layout or App.svelte:
<!-- global stylesheet — pick the theme that fits your project -->
<link
rel="stylesheet"
href="https://unpkg.com/carbon-components-svelte/css/white.min.css"
/>
With the foundation in place, a minimal
Carbon form
looks like this. The Form component is essentially a semantic wrapper — it renders
a native <form> element and passes through all standard HTML attributes,
including on:submit. Don’t skip it: using a bare <div> instead
breaks keyboard submission and assistive-technology navigation in one move.
<script>
import { Form, FormGroup, TextInput, Button } from 'carbon-components-svelte';
let name = '';
function handleSubmit(e) {
e.preventDefault();
console.log('Submitted:', name);
}
</script>
<Form on:submit={handleSubmit}>
<FormGroup legendText="Personal Information">
<TextInput
labelText="Full name"
placeholder="Jane Doe"
bind:value={name}
/>
</FormGroup>
<Button type="submit">Submit</Button>
</Form>
Notice FormGroup with a legendText prop. This renders a
<fieldset>/<legend> pair — the semantically correct
way to group related fields and the thing that makes screen readers announce group context
before individual input labels. It costs you one line. Skip it and you’re silently failing
WCAG 1.3.1.
TextInput Deep Dive: Every Prop That Actually Matters
TextInput
is the workhorse of any form, and understanding its prop surface in detail will save you
hours of digging through source code. The most important props for validation workflows are
invalid (boolean) and invalidText (string). When invalid
is true, the component renders a red border, a warning icon, and surfaces
invalidText beneath the field — all with the correct
aria-describedby wiring so screen readers pick it up automatically.
The helperText prop serves a different purpose: it’s persistent, instructional
copy that appears below the input regardless of validation state. Use it for format hints
(“MM/DD/YYYY”) or character limits. Mixing up helperText and
invalidText is a common mistake — the former informs, the latter corrects.
They can coexist, though Carbon hides helperText when invalid is
true to reduce visual noise.
Two other props worth knowing: warn and warnText follow the same
pattern as invalid/invalidText but render an amber warning state
rather than a red error state. This is perfect for soft validation — alerting users to
something unusual without blocking submission. And readonly versus
disabled are not the same thing: disabled fields are excluded from
form submission and cannot be focused; readonly fields are submitted and can be
focused (and therefore read by assistive technology). Choose deliberately.
<TextInput
labelText="Email address"
placeholder="you@example.com"
bind:value={email}
invalid={emailInvalid}
invalidText={emailError}
helperText="We'll never share your email."
on:input={validateEmail}
/>
Form Validation Patterns: From Simple to Production-Ready
There are three moments you can validate: on input (every keystroke), on blur (when the
user leaves a field), and on submit. Validating on every keystroke feels responsive but
can be aggressive — nobody wants to be told their email is invalid before they’ve even
finished typing the @ symbol. Validating only on submit is safe but delays
feedback. The sweet spot for most forms is on blur for initial validation, on input
after the first error. Here’s how that looks in Svelte without any external
library:
<script>
import { Form, FormGroup, TextInput, Button } from 'carbon-components-svelte';
let email = '';
let emailTouched = false;
$: emailInvalid = emailTouched && !isValidEmail(email);
$: emailError = emailInvalid ? 'Please enter a valid email address.' : '';
function isValidEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function handleBlur() {
emailTouched = true;
}
function handleSubmit(e) {
e.preventDefault();
emailTouched = true; // force validation display on submit attempt
if (emailInvalid) return;
// proceed with submission
}
</script>
<Form on:submit={handleSubmit}>
<FormGroup legendText="Account Details">
<TextInput
labelText="Email address"
bind:value={email}
invalid={emailInvalid}
invalidText={emailError}
on:blur={handleBlur}
/>
</FormGroup>
<Button type="submit">Create account</Button>
</Form>
The emailTouched flag is key. It gates the reactive declaration so that
emailInvalid is false until the user has actually interacted
with the field — or until they attempt to submit. Setting all touched flags to
true inside handleSubmit before the validation guard is a clean
pattern: it forces the UI to show all errors simultaneously, which is far better UX than
letting users play whack-a-mole with errors one at a time.
For forms with many fields, this pattern scales cleanly into a validation object. Replace
individual touched booleans with a touched object keyed by field
name, and replace individual reactive declarations with a single computed errors
object. The structure becomes more predictable, and adding a new field is a matter of adding
one key to three objects — not hunting for where the next variable should live.
<script>
import { Form, FormGroup, TextInput, PasswordInput, Button }
from 'carbon-components-svelte';
let fields = { name: '', email: '', password: '' };
let touched = { name: false, email: false, password: false };
$: errors = {
name: !fields.name.trim() ? 'Name is required.' : '',
email: !isValidEmail(fields.email) ? 'Enter a valid email.' : '',
password: fields.password.length < 8 ? 'Password must be 8+ characters.' : '',
};
$: formInvalid = Object.values(errors).some(Boolean);
function isValidEmail(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
}
function touch(field) { touched[field] = true; }
function handleSubmit(e) {
e.preventDefault();
touched = { name: true, email: true, password: true };
if (formInvalid) return;
// submit logic
}
</script>
<Form on:submit={handleSubmit}>
<FormGroup legendText="Register">
<TextInput
labelText="Full name"
bind:value={fields.name}
invalid={touched.name && !!errors.name}
invalidText={errors.name}
on:blur={() => touch('name')}
/>
<TextInput
labelText="Email"
bind:value={fields.email}
invalid={touched.email && !!errors.email}
invalidText={errors.email}
on:blur={() => touch('email')}
/>
<PasswordInput
labelText="Password"
bind:value={fields.password}
invalid={touched.password && !!errors.password}
invalidText={errors.password}
on:blur={() => touch('password')}
/>
</FormGroup>
<Button type="submit" disabled={false}>Register</Button>
</Form>
Notice that the submit button is not disabled by default even when the form has errors.
Disabling it prematurely is a common anti-pattern: users can’t figure out why the button
won’t respond, and screen reader users have no way to trigger the validation messages that
would explain what’s missing. Always prefer allowing submission and then surfacing errors,
rather than silently blocking the action.
Accessible Forms: What Carbon Does and What You Still Need to Do
Carbon components do a lot of the accessibility heavy lifting automatically. Every
TextInput generates a unique ID, wires its <label> to that ID,
and links error messages via aria-describedby. When you set invalid
to true, the component adds aria-invalid="true" to the underlying
<input>. This is the attribute that causes screen readers like NVDA and
VoiceOver to announce that a field is in an error state — a critical signal that many
hand-rolled form implementations miss entirely.
What Carbon cannot do for you is provide meaningful content. labelText is not
optional from an accessibility perspective, even if it looks optional from a TypeScript
perspective. An input without a label is invisible to screen readers — visually impaired
users will encounter a nameless, purpose-unknown field and have no choice but to skip it.
If a design requires a label to be visually hidden (common in search bars), use the
hideLabel prop on Carbon components rather than CSS display:none
or visibility:hidden — the former hides the label visually while keeping it in
the accessibility tree; the latter removes it from both.
Focus management matters especially on form submission errors. If a user submits an
incomplete form, the browser doesn’t automatically move focus to the first error. You need
to do that programmatically. In Svelte this is a tick() + focus()
call away:
<script>
import { tick } from 'svelte';
let firstErrorRef;
async function handleSubmit(e) {
e.preventDefault();
touched = { name: true, email: true, password: true };
if (formInvalid) {
await tick(); // wait for DOM to update with error states
firstErrorRef?.focus(); // move focus to the first invalid input
return;
}
// submit
}
</script>
<!-- bind the input element reference -->
<TextInput
bind:ref={firstErrorRef}
labelText="Full name"
...
/>
This single addition dramatically improves the experience for keyboard-only users and screen
reader users. It also makes your form compliant with WCAG 2.1 Success Criterion 3.3.1
(Error Identification) and 3.3.3 (Error Suggestion). Compliance isn’t just a legal
checkbox — in this case it’s also genuinely better UX.
State Management: Keeping Your Form Logic Maintainable
For small forms (two to four fields), keeping state directly in the component is perfectly
reasonable. Svelte’s local reactivity handles it without ceremony. The moment your form
grows beyond that — or when the same form state needs to be accessible from multiple
components, persisted across navigation, or shared with a server action — you need a
proper state management strategy.
A Svelte writable store is the most idiomatic solution. Extract your fields
object, touched flags, errors computation, and submit logic into
a formStore.js module. Components subscribe to the store and dispatch updates
through exported functions. This keeps component files thin — they’re just rendering the
state, not managing it — and makes the validation logic trivially unit-testable without
mounting a component at all.
// formStore.js
import { writable, derived } from 'svelte/store';
const initialFields = { name: '', email: '' };
const initialTouched = { name: false, email: false };
export const fields = writable({ ...initialFields });
export const touched = writable({ ...initialTouched });
export const errors = derived(fields, ($f) => ({
name: !$f.name.trim() ? 'Name is required.' : '',
email: !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test($f.email)
? 'Enter a valid email.' : '',
}));
export const isValid = derived(errors, ($e) =>
Object.values($e).every((msg) => !msg)
);
export function touchAll() {
touched.set({ name: true, email: true });
}
export function resetForm() {
fields.set({ ...initialFields });
touched.set({ ...initialTouched });
}
Inside your Svelte component, import the stores and bind them directly. The derived
errors and isValid stores update automatically whenever
fields changes — no manual wiring needed. This architecture also makes it
trivial to implement features like “reset form after successful submission” (call
resetForm()) or “pre-populate fields from an API response” (call
fields.set(apiData)). Both are one-liners when your state lives in a store.
Error Handling Beyond the Input: Server Errors and Async Validation
Client-side validation catches format errors and empty fields, but it can’t catch
“this email is already registered” or “this username is taken” — those require a round
trip to the server. The pattern for surfacing server errors through Carbon components is
straightforward: after an API call fails, map the server’s error payload back onto your
errors object and set the relevant touched flags. The reactive
declarations handle the rest.
async function handleSubmit(e) {
e.preventDefault();
touchAll();
if (!get(isValid)) return;
try {
await registerUser(get(fields));
resetForm();
} catch (err) {
// Server returned { field: 'email', message: 'Already registered.' }
if (err.field === 'email') {
serverErrors.email = err.message;
}
}
}
Async validation (checking availability as the user types) is another common requirement.
Debounce the API call with a simple timeout, set a loading state on the input using the
disabled or a custom loading indicator, and resolve the invalid
state once the response arrives. Be cautious about race conditions: if the user types
quickly, an earlier API call might resolve after a later one. Cancel stale requests using
an AbortController or a simple sequence counter.
One Carbon-specific tip: the InlineNotification or ToastNotification
components are excellent for surfacing form-level errors that don’t belong to a specific
field — things like “Network error, please try again” or “You don’t have permission to
perform this action.” Don’t try to squeeze these into a field-level invalidText;
they belong at the form level, above the submit button, where they’re immediately visible
after a failed submission.
Other Carbon Form Components Worth Knowing
Beyond TextInput, the Carbon Svelte library offers a full palette of form
controls that follow the same invalid/invalidText pattern.
Understanding the available inventory saves you from reimplementing things that already exist.
-
Select & MultiSelect — dropdown and multi-select with built-in filtering; use
invalid/invalidTextidentically to TextInput. -
Checkbox & CheckboxGroup — individual checkboxes or a grouped set; validate the group at the
FormGrouplevel rather than per-item. -
RadioButton & RadioButtonGroup — mutually exclusive options with full keyboard navigation; validation state lives on the
RadioButtonGroup. - DatePicker & DatePickerInput — single date or range; supports the same invalid state; underlying logic is Flatpickr.
- FileUploader — handles single or multiple file selection with drag-and-drop; reports upload state via a status prop.
- Toggle — boolean on/off; rarely needs validation but supports disabled and labelText properly.
The pattern consistency across this list is the real value. Once you understand how
invalid and invalidText work on TextInput, you
understand how they work on every other component in the library. Your validation logic
becomes genuinely component-agnostic — the same errors object, the same
touched flags, regardless of input type.
Best Practices Summary
After covering the full surface of
carbon-components-svelte
form development, a few principles stand out as consistently high-impact:
- Always use
FormandFormGroup— semantic structure is not optional. - Always provide
labelText— even for visually hidden labels, usehideLabelrather than CSS tricks. - Validate on blur, re-validate on input after first error, force all errors on submit.
- Set all touched flags to
truebefore the submit guard to surface all errors at once. - Move focus to the first invalid field programmatically after a failed submission.
- Use Svelte stores for any form that spans multiple components or has complex state.
- Use
InlineNotificationfor form-level errors,invalidTextfor field-level errors. - Never disable the submit button to prevent submission — surface errors instead.
These aren’t arbitrary rules — each one addresses a specific, documented failure mode in
form UX. Follow them consistently and you’ll ship forms that work correctly for every user,
on every device, with every assistive technology. That’s a remarkably rare outcome in
production applications, and it’s achievable without any form management library beyond
what Svelte provides natively.
Frequently Asked Questions
How do I validate a form in carbon-components-svelte?
Use the invalid and invalidText props on components like
TextInput, Select, or RadioButtonGroup.
Drive them with Svelte reactive declarations ($:) or a dedicated
validation function triggered on blur and submit events.
Check each field’s value against your rules, set invalid to
true when a rule fails, and bind the error message to
invalidText. Use a touched flag per field to avoid
showing errors before the user has interacted with the input.
How do I make Carbon Design System forms accessible in Svelte?
Carbon components ship with built-in ARIA wiring, but you must provide
labelText to every input — never omit it. Use FormGroup
with legendText to semantically group related fields. Ensure error
messages are bound to invalidText so that aria-invalid
and aria-describedby are set correctly and screen readers announce
them. After a failed submission, programmatically move focus to the first invalid
field using tick() and ref.focus(). Use
hideLabel instead of CSS to visually hide labels when design requires it.
What is the best way to handle form state in Svelte with carbon-components-svelte?
For small forms, keep state in local reactive variables bound via
bind:value. For larger or multi-component forms, use a Svelte writable
store for fields and touched, a derived store for
errors, and exported functions for actions like touchAll()
and resetForm(). This keeps components thin and makes validation logic
unit-testable without mounting a component. Avoid coupling UI state (touched, dirty
flags) with business logic — keep them in separate reactive structures for
maintainability.