Fieldset
This library is a work-in-progress. We are releasing it early to gather feedback, but it is not ready for production.
A semantic fieldset component that groups related form controls with an optional legend. Follows WAI-ARIA best practices for accessible form grouping.
Examples
Live Preview
Basic Fieldset
Group related form controls with a descriptive legend
Bordered Fieldset
Add visual borders and padding for better content grouping
Radio Button Group
Use fieldset to group related radio button choices
Checkbox Group
Group multiple checkboxes for selecting multiple options
Horizontal Layout
Arrange form controls horizontally with flexible wrapping
Visually Hidden Legend
Hide legend visually while keeping it accessible to screen readers
Nested Fieldsets
Organize complex forms with nested groupings and action buttons
Permanently delete your account and all associated data. This action cannot be undone.
Complete Checkout Form
Realistic payment form with validation and action buttons
Compact Forms with Small Components
Create compact UIs with small inputs, buttons, and fieldsets
CSS Shadow Parts Customization
Use CSS Shadow Parts to customize the component's appearance: ::part(ag-fieldset), ::part(ag-legend), ::part(ag-content)
View Vue Code
<template>
<section>
<div class="mbe4">
<h2>Basic Fieldset</h2>
<p class="mbs2 mbe3">Group related form controls with a descriptive legend</p>
</div>
<VueFieldset
legend="Personal Information"
class="mbe6"
>
<VueInput
v-model:value="personalInfo.firstName"
label="First Name"
placeholder="John"
class="mbe3"
>
<template #addon-left>
<User
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="personalInfo.lastName"
label="Last Name"
placeholder="Doe"
class="mbe3"
>
<template #addon-left>
<User
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="personalInfo.email"
label="Email"
type="email"
placeholder="john.doe@example.com"
class="mbe3"
>
<template #addon-left>
<Mail
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="personalInfo.phone"
label="Phone Number"
type="tel"
placeholder="(555) 123-4567"
>
<template #addon-left>
<Phone
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
</VueFieldset>
<div class="mbe4">
<h2>Bordered Fieldset</h2>
<p class="mbs2 mbe3">Add visual borders and padding for better content grouping</p>
</div>
<VueFieldset
legend="Shipping Address"
:bordered="true"
class="mbe6"
>
<VueInput
v-model:value="address.street"
label="Street Address"
placeholder="123 Main St"
class="mbe3"
>
<template #addon-left>
<MapPin
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<div
class="mbe3"
style="display: grid; grid-template-columns: 2fr 1fr; gap: var(--ag-space-3);"
>
<VueInput
v-model:value="address.city"
label="City"
placeholder="San Francisco"
>
<template #addon-left>
<Building2
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="address.zip"
label="ZIP Code"
placeholder="94102"
/>
</div>
<VueInput
v-model:value="address.country"
label="Country"
placeholder="United States"
>
<template #addon-left>
<MapPin
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
</VueFieldset>
<div class="mbe4">
<h2>Radio Button Group</h2>
<p class="mbs2 mbe3">Use fieldset to group related radio button choices</p>
</div>
<VueFieldset
legend="Preferred Contact Method"
:bordered="true"
class="mbe6"
>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-3);">
<VueRadio
name="contact-method"
value="email"
label-text="Email"
:checked="contactMethod === 'email'"
@change="contactMethod = 'email'"
/>
<VueRadio
name="contact-method"
value="phone"
label-text="Phone"
:checked="contactMethod === 'phone'"
@change="contactMethod = 'phone'"
/>
<VueRadio
name="contact-method"
value="sms"
label-text="Text Message (SMS)"
:checked="contactMethod === 'sms'"
@change="contactMethod = 'sms'"
/>
<VueRadio
name="contact-method"
value="mail"
label-text="Postal Mail"
:checked="contactMethod === 'mail'"
@change="contactMethod = 'mail'"
/>
</div>
</VueFieldset>
<div class="mbe4">
<h2>Checkbox Group</h2>
<p class="mbs2 mbe3">Group multiple checkboxes for selecting multiple options</p>
</div>
<VueFieldset
legend="Notification Preferences"
:bordered="true"
class="mbe6"
>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-3);">
<VueCheckbox
name="notifications"
value="product-updates"
label-text="Product Updates"
:checked="notifications.productUpdates"
@change="notifications.productUpdates = !notifications.productUpdates"
/>
<VueCheckbox
name="notifications"
value="newsletter"
label-text="Weekly Newsletter"
:checked="notifications.newsletter"
@change="notifications.newsletter = !notifications.newsletter"
/>
<VueCheckbox
name="notifications"
value="special-offers"
label-text="Special Offers & Promotions"
:checked="notifications.specialOffers"
@change="notifications.specialOffers = !notifications.specialOffers"
/>
<VueCheckbox
name="notifications"
value="security-alerts"
label-text="Security Alerts"
:checked="notifications.securityAlerts"
@change="notifications.securityAlerts = !notifications.securityAlerts"
/>
</div>
</VueFieldset>
<div class="mbe4">
<h2>Horizontal Layout</h2>
<p class="mbs2 mbe3">Arrange form controls horizontally with flexible wrapping</p>
</div>
<VueFieldset
legend="Date of Birth"
:bordered="true"
layout="horizontal"
class="mbe6"
>
<VueInput
v-model:value="dateOfBirth.month"
label="Month"
placeholder="MM"
size="small"
style="width: 80px;"
/>
<VueInput
v-model:value="dateOfBirth.day"
label="Day"
placeholder="DD"
size="small"
style="width: 80px;"
/>
<VueInput
v-model:value="dateOfBirth.year"
label="Year"
placeholder="YYYY"
size="small"
style="width: 100px;"
/>
</VueFieldset>
<div class="mbe4">
<h2>Visually Hidden Legend</h2>
<p class="mbs2 mbe3">Hide legend visually while keeping it accessible to screen readers</p>
</div>
<VueFieldset
legend="Search Options"
:bordered="true"
:legend-hidden="true"
class="mbe6"
>
<VueInput
v-model:value="search.query"
label="Search Query"
placeholder="Enter search terms..."
class="mbe3"
>
<template #addon-left>
<Search
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-2);">
<VueCheckbox
name="search-options"
value="case-sensitive"
label-text="Case Sensitive"
size="small"
:checked="search.caseSensitive"
@change="search.caseSensitive = !search.caseSensitive"
/>
<VueCheckbox
name="search-options"
value="whole-words"
label-text="Match Whole Words Only"
size="small"
:checked="search.wholeWords"
@change="search.wholeWords = !search.wholeWords"
/>
</div>
</VueFieldset>
<div class="mbe4">
<h2>Nested Fieldsets</h2>
<p class="mbs2 mbe3">Organize complex forms with nested groupings and action buttons</p>
</div>
<div class="mbe6">
<VueFieldset
legend="Account Settings"
:bordered="true"
class="mbe4"
>
<VueFieldset
legend="Profile"
class="mbe4"
>
<VueInput
v-model:value="account.username"
label="Username"
placeholder="johndoe"
class="mbe3"
>
<template #addon-left>
<User
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="account.displayName"
label="Display Name"
placeholder="John Doe"
>
<template #addon-left>
<User
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
</VueFieldset>
<VueFieldset
legend="Privacy Settings"
class="mbe4"
>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-3);">
<VueCheckbox
name="privacy"
value="profile-public"
label-text="Make Profile Public"
:checked="account.privacy.profilePublic"
@change="account.privacy.profilePublic = !account.privacy.profilePublic"
/>
<VueCheckbox
name="privacy"
value="activity-visible"
label-text="Show Activity to Followers"
:checked="account.privacy.activityVisible"
@change="account.privacy.activityVisible = !account.privacy.activityVisible"
/>
<VueCheckbox
name="privacy"
value="searchable"
label-text="Allow Search Engines to Index Profile"
:checked="account.privacy.searchable"
@change="account.privacy.searchable = !account.privacy.searchable"
/>
</div>
</VueFieldset>
<VueFieldset
legend="Danger Zone"
class="mbe4"
>
<p style="color: var(--ag-text-secondary); font-size: 0.875rem; margin-bottom: var(--ag-space-3);">
Permanently delete your account and all associated data. This action cannot be undone.
</p>
<VueButton
:bordered="true"
variant="danger"
shape="rounded"
size="sm"
>
Delete Account
</VueButton>
</VueFieldset>
</VueFieldset>
<div style="display: flex; gap: var(--ag-space-3); justify-content: space-between;">
<VueButton
:bordered="true"
shape="rounded"
>
Cancel
</VueButton>
<div style="display: flex; gap: var(--ag-space-3);">
<VueButton
:bordered="true"
shape="rounded"
class="monochrome-button"
>
Reset to Default
</VueButton>
<VueButton
shape="rounded"
class="monochrome-button-filled"
>
Save Changes
</VueButton>
</div>
</div>
</div>
<div class="mbe4">
<h2>Complete Checkout Form</h2>
<p class="mbs2 mbe3">Realistic payment form with validation and action buttons</p>
</div>
<div class="mbe6">
<VueFieldset
legend="Payment Information"
:bordered="true"
class="mbe4"
>
<VueInput
v-model:value="payment.cardNumber"
label="Card Number"
placeholder="1234 5678 9012 3456"
:required="true"
:invalid="!!paymentErrors.cardNumber"
:error-message="paymentErrors.cardNumber"
@blur="validateCardNumber"
class="mbe3"
>
<template #addon-left>
<CreditCard
:size="18"
:color="paymentErrors.cardNumber ? 'var(--ag-error)' : 'var(--ag-secondary)'"
/>
</template>
</VueInput>
<div
class="mbe3"
style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--ag-space-3);"
>
<VueInput
v-model:value="payment.expiry"
label="Expiry Date"
placeholder="MM/YY"
:required="true"
:invalid="!!paymentErrors.expiry"
:error-message="paymentErrors.expiry"
@blur="validateExpiry"
>
<template #addon-left>
<Calendar
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="payment.cvv"
label="CVV"
type="password"
placeholder="123"
:required="true"
:invalid="!!paymentErrors.cvv"
:error-message="paymentErrors.cvv"
@blur="validateCVV"
/>
</div>
<VueInput
v-model:value="payment.nameOnCard"
label="Name on Card"
placeholder="John Doe"
:required="true"
:invalid="!!paymentErrors.nameOnCard"
:error-message="paymentErrors.nameOnCard"
@blur="validateNameOnCard"
class="mbe3"
>
<template #addon-left>
<User
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueInput
v-model:value="payment.billingZip"
label="Billing ZIP Code"
placeholder="94102"
:required="true"
>
<template #addon-left>
<MapPin
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
</VueFieldset>
<div style="display: flex; gap: var(--ag-space-3); justify-content: flex-end;">
<VueButton
:bordered="true"
shape="rounded"
>
← Back to Cart
</VueButton>
<VueButton
variant="primary"
shape="rounded"
>
Complete Purchase
</VueButton>
</div>
</div>
<div class="mbe4">
<h2>Compact Forms with Small Components</h2>
<p class="mbs2 mbe3">Create compact UIs with small inputs, buttons, and fieldsets</p>
</div>
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: var(--ag-space-4);"
class="mbe6"
>
<div>
<VueFieldset
legend="Quick Filter"
:bordered="true"
class="mbe3"
>
<VueInput
v-model:value="sizes.smallName"
label="Search"
size="small"
placeholder="Type to search..."
class="mbe2"
>
<template #addon-left>
<Search
:size="16"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-2);">
<VueCheckbox
name="small-filter"
value="active"
label-text="Active only"
size="small"
/>
<VueCheckbox
name="small-filter"
value="recent"
label-text="Recent"
size="small"
:checked="true"
/>
</div>
</VueFieldset>
<div style="display: flex; gap: var(--ag-space-2); justify-content: flex-end;">
<VueButton
:bordered="true"
size="sm"
shape="rounded"
>
Clear
</VueButton>
<VueButton
size="sm"
shape="rounded"
>
Apply
</VueButton>
</div>
</div>
<div>
<VueFieldset
legend="Email Preferences"
:bordered="true"
class="mbe3"
>
<VueInput
v-model:value="sizes.defaultName"
label="Email Address"
placeholder="you@example.com"
class="mbe3"
>
<template #addon-left>
<Mail
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueCheckbox
name="default-agree"
value="agree"
label-text="Send me product updates"
/>
</VueFieldset>
<div style="display: flex; gap: var(--ag-space-2); justify-content: flex-end;">
<VueButton
:bordered="true"
shape="rounded"
>
Unsubscribe
</VueButton>
<VueButton
variant="success"
shape="rounded"
>
Subscribe
</VueButton>
</div>
</div>
<div>
<VueFieldset
legend="Confirmation"
:bordered="true"
class="mbe3"
>
<VueInput
v-model:value="sizes.largeName"
label="Full Name"
size="default"
placeholder="Enter your name"
class="mbe3"
>
<template #addon-left>
<User
:size="18"
color="var(--ag-secondary)"
/>
</template>
</VueInput>
<VueCheckbox
name="large-agree"
value="agree"
label-text="I understand and agree to the terms"
size="medium"
/>
</VueFieldset>
<VueButton
size="md"
shape="rounded"
class="monochrome-button-filled"
>
Confirm & Continue
</VueButton>
</div>
</div>
<div class="mbe4">
<h2>CSS Shadow Parts Customization</h2>
<p class="mbs2 mbe3">
Use CSS Shadow Parts to customize the component's appearance:
<code>::part(ag-fieldset)</code>,
<code>::part(ag-legend)</code>,
<code>::part(ag-content)</code>
</p>
</div>
<div class="mbe6">
<VueFieldset
legend="Minimal Accent Border"
:bordered="true"
class="custom-fieldset-1 mbe4"
>
<VueInput
v-model:value="custom.field1"
label="Email Address"
type="email"
placeholder="you@company.com"
class="mbe3"
/>
<VueInput
v-model:value="custom.field2"
label="Department"
placeholder="Engineering"
/>
</VueFieldset>
<VueFieldset
legend="Subtle Card Style"
:bordered="true"
class="custom-fieldset-2"
>
<VueInput
v-model:value="custom.field3"
label="Project Name"
placeholder="Q4 Marketing Campaign"
class="mbe3"
/>
<VueInput
v-model:value="custom.field4"
label="Budget"
placeholder="$50,000"
/>
</VueFieldset>
</div>
</section>
</template>
<script>
import { VueFieldset } from "agnosticui-core/fieldset/vue";
import VueInput from "agnosticui-core/input/vue";
import { VueRadio } from "agnosticui-core/radio/vue";
import { VueCheckbox } from "agnosticui-core/checkbox/vue";
import VueButton from "agnosticui-core/button/vue";
import {
Search,
CreditCard,
Mail,
Phone,
MapPin,
User,
Building2,
Calendar,
} from "lucide-vue-next";
export default {
name: "FieldsetExamples",
components: {
VueFieldset,
VueInput,
VueRadio,
VueCheckbox,
VueButton,
Search,
CreditCard,
Mail,
Phone,
MapPin,
User,
Building2,
Calendar,
},
data() {
return {
// Personal Information
personalInfo: {
firstName: "",
lastName: "",
email: "",
phone: "",
},
// Shipping Address
address: {
street: "",
city: "",
zip: "",
country: "",
},
// Contact Method
contactMethod: "email",
// Notifications
notifications: {
productUpdates: true,
newsletter: false,
specialOffers: true,
securityAlerts: true,
},
// Date of Birth
dateOfBirth: {
month: "",
day: "",
year: "",
},
// Search
search: {
query: "",
caseSensitive: false,
wholeWords: false,
},
// Account Settings
account: {
username: "",
displayName: "",
privacy: {
profilePublic: true,
activityVisible: true,
searchable: false,
},
},
// Payment Information
payment: {
cardNumber: "",
expiry: "",
cvv: "",
nameOnCard: "",
billingZip: "",
},
paymentErrors: {
cardNumber: "",
expiry: "",
cvv: "",
nameOnCard: "",
},
// Sizes
sizes: {
smallName: "",
defaultName: "",
largeName: "",
},
// Custom
custom: {
field1: "",
field2: "",
field3: "",
field4: "",
},
};
},
methods: {
validateCardNumber() {
// Simple validation - just check length (real validation would be more complex)
const cleaned = this.payment.cardNumber.replace(/\s/g, "");
if (cleaned && cleaned.length < 13) {
this.paymentErrors.cardNumber =
"Card number must be at least 13 digits";
} else {
this.paymentErrors.cardNumber = "";
}
},
validateExpiry() {
// Simple MM/YY validation
const expiryPattern = /^(0[1-9]|1[0-2])\/\d{2}$/;
if (this.payment.expiry && !expiryPattern.test(this.payment.expiry)) {
this.paymentErrors.expiry = "Format must be MM/YY";
} else {
this.paymentErrors.expiry = "";
}
},
validateCVV() {
if (
this.payment.cvv &&
(this.payment.cvv.length < 3 || this.payment.cvv.length > 4)
) {
this.paymentErrors.cvv = "CVV must be 3 or 4 digits";
} else {
this.paymentErrors.cvv = "";
}
},
validateNameOnCard() {
if (this.payment.nameOnCard && this.payment.nameOnCard.length < 2) {
this.paymentErrors.nameOnCard = "Please enter the name on your card";
} else {
this.paymentErrors.nameOnCard = "";
}
},
},
};
</script>
<style scoped>
/* Custom Fieldset 1 - Minimal with accent border */
.custom-fieldset-1::part(ag-fieldset) {
border-left: 3px solid var(--ag-primary);
border-top: var(--ag-border-width-1) solid var(--ag-border);
border-right: var(--ag-border-width-1) solid var(--ag-border);
border-bottom: var(--ag-border-width-1) solid var(--ag-border);
border-radius: var(--ag-radius-md);
padding: var(--ag-space-5);
}
.custom-fieldset-1::part(ag-legend) {
font-weight: 600;
color: var(--ag-text-primary);
}
/* Custom Fieldset 2 - Subtle card with shadow */
.custom-fieldset-2::part(ag-fieldset) {
border: var(--ag-border-width-1) solid var(--ag-border);
border-radius: var(--ag-radius-lg);
padding: var(--ag-space-5);
background: var(--ag-background-secondary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.custom-fieldset-2::part(ag-legend) {
font-weight: 600;
font-size: 1.125rem;
}
/* Monochrome button styling using CSS parts */
.monochrome-button::part(ag-button) {
background: transparent;
color: var(--ag-text-primary);
border: 2px solid var(--ag-text-primary);
}
.monochrome-button::part(ag-button):hover {
background: var(--ag-text-primary);
color: var(--ag-background-primary);
}
.monochrome-button-filled::part(ag-button) {
background: var(--ag-text-primary);
color: var(--ag-background-primary);
border: 2px solid var(--ag-text-primary);
}
.monochrome-button-filled::part(ag-button):hover {
background: var(--ag-text-secondary);
border-color: var(--ag-text-secondary);
}
</style>
Live Preview
View Lit / Web Component Code
import { LitElement, html } from 'lit';
import 'agnosticui-core/fieldset';
import 'agnosticui-core/input';
import 'agnosticui-core/radio';
import 'agnosticui-core/checkbox';
import 'agnosticui-core/button';
import 'agnosticui-core/icon';
export class FieldsetLitExamples extends LitElement {
constructor() {
super();
// Personal Information
this.personalInfo = {
firstName: '',
lastName: '',
email: '',
phone: '',
};
// Shipping Address
this.address = {
street: '',
city: '',
zip: '',
country: '',
};
// Contact Method
this.contactMethod = 'email';
// Notifications
this.notifications = {
productUpdates: true,
newsletter: false,
specialOffers: true,
securityAlerts: true,
};
// Date of Birth
this.dateOfBirth = {
month: '',
day: '',
year: '',
};
// Search
this.search = {
query: '',
caseSensitive: false,
wholeWords: false,
};
// Account Settings
this.account = {
username: '',
displayName: '',
privacy: {
profilePublic: true,
activityVisible: true,
searchable: false,
},
};
// Payment Information
this.payment = {
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
billingZip: '',
};
this.paymentErrors = {
cardNumber: '',
expiry: '',
cvv: '',
nameOnCard: '',
};
// Sizes
this.sizes = {
smallName: '',
defaultName: '',
largeName: '',
};
// Custom
this.custom = {
field1: '',
field2: '',
field3: '',
field4: '',
};
}
// Render in light DOM to access global utility classes
createRenderRoot() {
return this;
}
validateCardNumber() {
const cleaned = this.payment.cardNumber.replace(/\s/g, '');
if (cleaned && cleaned.length < 13) {
this.paymentErrors.cardNumber = 'Card number must be at least 13 digits';
} else {
this.paymentErrors.cardNumber = '';
}
this.requestUpdate();
}
validateExpiry() {
const expiryPattern = /^(0[1-9]|1[0-2])\/\d{2}$/;
if (this.payment.expiry && !expiryPattern.test(this.payment.expiry)) {
this.paymentErrors.expiry = 'Format must be MM/YY';
} else {
this.paymentErrors.expiry = '';
}
this.requestUpdate();
}
validateCVV() {
if (this.payment.cvv && (this.payment.cvv.length < 3 || this.payment.cvv.length > 4)) {
this.paymentErrors.cvv = 'CVV must be 3 or 4 digits';
} else {
this.paymentErrors.cvv = '';
}
this.requestUpdate();
}
validateNameOnCard() {
if (this.payment.nameOnCard && this.payment.nameOnCard.length < 2) {
this.paymentErrors.nameOnCard = 'Please enter the name on your card';
} else {
this.paymentErrors.nameOnCard = '';
}
this.requestUpdate();
}
render() {
return html`
<style>
/* Custom Fieldset 1 - Minimal with accent border */
.custom-fieldset-1::part(ag-fieldset) {
border-left: 3px solid var(--ag-primary);
border-top: var(--ag-border-width-1) solid var(--ag-border);
border-right: var(--ag-border-width-1) solid var(--ag-border);
border-bottom: var(--ag-border-width-1) solid var(--ag-border);
border-radius: var(--ag-radius-md);
padding: var(--ag-space-5);
}
.custom-fieldset-1::part(ag-legend) {
font-weight: 600;
color: var(--ag-text-primary);
}
/* Custom Fieldset 2 - Subtle card with shadow */
.custom-fieldset-2::part(ag-fieldset) {
border: var(--ag-border-width-1) solid var(--ag-border);
border-radius: var(--ag-radius-lg);
padding: var(--ag-space-5);
background: var(--ag-background-secondary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.custom-fieldset-2::part(ag-legend) {
font-weight: 600;
font-size: 1.125rem;
}
/* Monochrome button styling using CSS parts */
.monochrome-button::part(ag-button) {
background: transparent;
color: var(--ag-text-primary);
border: 2px solid var(--ag-text-primary);
}
.monochrome-button::part(ag-button):hover {
background: var(--ag-text-primary);
color: var(--ag-background-primary);
}
.monochrome-button-filled::part(ag-button) {
background: var(--ag-text-primary);
color: var(--ag-background-primary);
border: 2px solid var(--ag-text-primary);
}
.monochrome-button-filled::part(ag-button):hover {
background: var(--ag-text-secondary);
border-color: var(--ag-text-secondary);
}
</style>
<section>
<div class="mbe4">
<h2>Basic Fieldset</h2>
<p class="mbs2 mbe3">Group related form controls with a descriptive legend</p>
</div>
<ag-fieldset
legend="Personal Information"
class="mbe6"
>
<ag-input
.value=${this.personalInfo.firstName}
@input=${(e) => { this.personalInfo.firstName = e.target.value; this.requestUpdate(); }}
label="First Name"
placeholder="John"
class="mbe3"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</ag-icon>
</ag-input>
<ag-input
.value=${this.personalInfo.lastName}
@input=${(e) => { this.personalInfo.lastName = e.target.value; this.requestUpdate(); }}
label="Last Name"
placeholder="Doe"
class="mbe3"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</ag-icon>
</ag-input>
<ag-input
.value=${this.personalInfo.email}
@input=${(e) => { this.personalInfo.email = e.target.value; this.requestUpdate(); }}
label="Email"
type="email"
placeholder="john.doe@example.com"
class="mbe3"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</ag-icon>
</ag-input>
<ag-input
.value=${this.personalInfo.phone}
@input=${(e) => { this.personalInfo.phone = e.target.value; this.requestUpdate(); }}
label="Phone Number"
type="tel"
placeholder="(555) 123-4567"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</ag-icon>
</ag-input>
</ag-fieldset>
<div class="mbe4">
<h2>Bordered Fieldset</h2>
<p class="mbs2 mbe3">Add visual borders and padding for better content grouping</p>
</div>
<ag-fieldset
legend="Shipping Address"
bordered
class="mbe6"
>
<ag-input
.value=${this.address.street}
@input=${(e) => { this.address.street = e.target.value; this.requestUpdate(); }}
label="Street Address"
placeholder="123 Main St"
class="mbe3"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</ag-icon>
</ag-input>
<div
class="mbe3"
style="display: grid; grid-template-columns: 2fr 1fr; gap: var(--ag-space-3);"
>
<ag-input
.value=${this.address.city}
@input=${(e) => { this.address.city = e.target.value; this.requestUpdate(); }}
label="City"
placeholder="San Francisco"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/>
<path d="M9 22v-4h6v4"/>
<path d="M8 6h.01"/>
<path d="M16 6h.01"/>
<path d="M12 6h.01"/>
<path d="M12 10h.01"/>
<path d="M12 14h.01"/>
<path d="M16 10h.01"/>
<path d="M16 14h.01"/>
<path d="M8 10h.01"/>
<path d="M8 14h.01"/>
</svg>
</ag-icon>
</ag-input>
<ag-input
.value=${this.address.zip}
@input=${(e) => { this.address.zip = e.target.value; this.requestUpdate(); }}
label="ZIP Code"
placeholder="94102"
/>
</div>
<ag-input
.value=${this.address.country}
@input=${(e) => { this.address.country = e.target.value; this.requestUpdate(); }}
label="Country"
placeholder="United States"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</ag-icon>
</ag-input>
</ag-fieldset>
<div class="mbe4">
<h2>Radio Button Group</h2>
<p class="mbs2 mbe3">Use fieldset to group related radio button choices</p>
</div>
<ag-fieldset
legend="Preferred Contact Method"
bordered
class="mbe6"
>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-3);">
<ag-radio
name="contact-method"
value="email"
label-text="Email"
?checked=${this.contactMethod === 'email'}
@change=${() => { this.contactMethod = 'email'; this.requestUpdate(); }}
/>
<ag-radio
name="contact-method"
value="phone"
label-text="Phone"
?checked=${this.contactMethod === 'phone'}
@change=${() => { this.contactMethod = 'phone'; this.requestUpdate(); }}
/>
<ag-radio
name="contact-method"
value="sms"
label-text="Text Message (SMS)"
?checked=${this.contactMethod === 'sms'}
@change=${() => { this.contactMethod = 'sms'; this.requestUpdate(); }}
/>
<ag-radio
name="contact-method"
value="mail"
label-text="Postal Mail"
?checked=${this.contactMethod === 'mail'}
@change=${() => { this.contactMethod = 'mail'; this.requestUpdate(); }}
/>
</div>
</ag-fieldset>
<div class="mbe4">
<h2>Checkbox Group</h2>
<p class="mbs2 mbe3">Group multiple checkboxes for selecting multiple options</p>
</div>
<ag-fieldset
legend="Notification Preferences"
bordered
class="mbe6"
>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-3);">
<ag-checkbox
name="notifications"
value="product-updates"
label-text="Product Updates"
?checked=${this.notifications.productUpdates}
@change=${() => { this.notifications.productUpdates = !this.notifications.productUpdates; this.requestUpdate(); }}
/>
<ag-checkbox
name="notifications"
value="newsletter"
label-text="Weekly Newsletter"
?checked=${this.notifications.newsletter}
@change=${() => { this.notifications.newsletter = !this.notifications.newsletter; this.requestUpdate(); }}
/>
<ag-checkbox
name="notifications"
value="special-offers"
label-text="Special Offers & Promotions"
?checked=${this.notifications.specialOffers}
@change=${() => { this.notifications.specialOffers = !this.notifications.specialOffers; this.requestUpdate(); }}
/>
<ag-checkbox
name="notifications"
value="security-alerts"
label-text="Security Alerts"
?checked=${this.notifications.securityAlerts}
@change=${() => { this.notifications.securityAlerts = !this.notifications.securityAlerts; this.requestUpdate(); }}
/>
</div>
</ag-fieldset>
<div class="mbe4">
<h2>Horizontal Layout</h2>
<p class="mbs2 mbe3">Arrange form controls horizontally with flexible wrapping</p>
</div>
<ag-fieldset
legend="Date of Birth"
bordered
layout="horizontal"
class="mbe6"
>
<ag-input
.value=${this.dateOfBirth.month}
@input=${(e) => { this.dateOfBirth.month = e.target.value; this.requestUpdate(); }}
label="Month"
placeholder="MM"
size="small"
style="width: 80px;"
/>
<ag-input
.value=${this.dateOfBirth.day}
@input=${(e) => { this.dateOfBirth.day = e.target.value; this.requestUpdate(); }}
label="Day"
placeholder="DD"
size="small"
style="width: 80px;"
/>
<ag-input
.value=${this.dateOfBirth.year}
@input=${(e) => { this.dateOfBirth.year = e.target.value; this.requestUpdate(); }}
label="Year"
placeholder="YYYY"
size="small"
style="width: 100px;"
/>
</ag-fieldset>
<div class="mbe4">
<h2>Visually Hidden Legend</h2>
<p class="mbs2 mbe3">Hide legend visually while keeping it accessible to screen readers</p>
</div>
<ag-fieldset
legend="Search Options"
bordered
legend-hidden
class="mbe6"
>
<ag-input
.value=${this.search.query}
@input=${(e) => { this.search.query = e.target.value; this.requestUpdate(); }}
label="Search Query"
placeholder="Enter search terms..."
class="mbe3"
>
<ag-icon slot="addon-left" size="18">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--ag-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
</ag-icon>
</ag-input>
<div style="display: flex; flex-direction: column; gap: var(--ag-space-2);">
<ag-checkbox
name="search-options"
value="case-sensitive"
label-text="Case Sensitive"
size="small"
?checked=${this.search.caseSensitive}
@change=${() => { this.search.caseSensitive = !this.search.caseSensitive; this.requestUpdate(); }}
/>
<ag-checkbox
name="search-options"
value="whole-words"
label-text="Match Whole Words Only"
size="small"
?checked=${this.search.wholeWords}
@change=${() => { this.search.wholeWords = !this.search.wholeWords; this.requestUpdate(); }}
/>
</div>
</ag-fieldset>
<div class="mbe4">
<h2>CSS Shadow Parts Customization</h2>
<p class="mbs2 mbe3">
Use CSS Shadow Parts to customize the component's appearance:
<code>::part(ag-fieldset)</code>,
<code>::part(ag-legend)</code>,
<code>::part(ag-content)</code>
</p>
</div>
<div class="mbe6">
<ag-fieldset
legend="Minimal Accent Border"
bordered
class="custom-fieldset-1 mbe4"
>
<ag-input
.value=${this.custom.field1}
@input=${(e) => { this.custom.field1 = e.target.value; this.requestUpdate(); }}
label="Email Address"
type="email"
placeholder="you@company.com"
class="mbe3"
/>
<ag-input
.value=${this.custom.field2}
@input=${(e) => { this.custom.field2 = e.target.value; this.requestUpdate(); }}
label="Department"
placeholder="Engineering"
/>
</ag-fieldset>
<ag-fieldset
legend="Subtle Card Style"
bordered
class="custom-fieldset-2"
>
<ag-input
.value=${this.custom.field3}
@input=${(e) => { this.custom.field3 = e.target.value; this.requestUpdate(); }}
label="Project Name"
placeholder="Q4 Marketing Campaign"
class="mbe3"
/>
<ag-input
.value=${this.custom.field4}
@input=${(e) => { this.custom.field4 = e.target.value; this.requestUpdate(); }}
label="Budget"
placeholder="$50,000"
/>
</ag-fieldset>
</div>
</section>
`;
}
}
// Register the custom element
customElements.define('fieldset-lit-examples', FieldsetLitExamples);
Interactive Preview: Click the "Open in StackBlitz" button below to see this example running live in an interactive playground.
View React Code
import { useState } from "react";
import { ReactFieldset } from "agnosticui-core/fieldset/react";
import { ReactInput } from "agnosticui-core/input/react";
import { ReactRadio } from "agnosticui-core/radio/react";
import { ReactCheckbox } from "agnosticui-core/checkbox/react";
import { ReactButton } from "agnosticui-core/button/react";
import {
Search,
CreditCard,
Mail,
Phone,
MapPin,
User,
Building2,
Calendar,
} from "lucide-react";
export default function FieldsetReactExamples() {
// Personal Information
const [personalInfo, setPersonalInfo] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
});
// Shipping Address
const [address, setAddress] = useState({
street: "",
city: "",
zip: "",
country: "",
});
// Contact Method
const [contactMethod, setContactMethod] = useState("email");
// Notifications
const [notifications, setNotifications] = useState({
productUpdates: true,
newsletter: false,
specialOffers: true,
securityAlerts: true,
});
// Date of Birth
const [dateOfBirth, setDateOfBirth] = useState({
month: "",
day: "",
year: "",
});
// Search
const [search, setSearch] = useState({
query: "",
caseSensitive: false,
wholeWords: false,
});
// Account Settings
const [account, setAccount] = useState({
username: "",
displayName: "",
privacy: {
profilePublic: true,
activityVisible: true,
searchable: false,
},
});
// Payment Information
const [payment, setPayment] = useState({
cardNumber: "",
expiry: "",
cvv: "",
nameOnCard: "",
billingZip: "",
});
const [paymentErrors, setPaymentErrors] = useState({
cardNumber: "",
expiry: "",
cvv: "",
nameOnCard: "",
});
// Sizes
const [sizes, setSizes] = useState({
smallName: "",
defaultName: "",
largeName: "",
});
// Custom
const [custom, setCustom] = useState({
field1: "",
field2: "",
field3: "",
field4: "",
});
const validateCardNumber = () => {
const cleaned = payment.cardNumber.replace(/\s/g, "");
if (cleaned && cleaned.length < 13) {
setPaymentErrors((prev) => ({
...prev,
cardNumber: "Card number must be at least 13 digits",
}));
} else {
setPaymentErrors((prev) => ({ ...prev, cardNumber: "" }));
}
};
const validateExpiry = () => {
const expiryPattern = /^(0[1-9]|1[0-2])\/\d{2}$/;
if (payment.expiry && !expiryPattern.test(payment.expiry)) {
setPaymentErrors((prev) => ({ ...prev, expiry: "Format must be MM/YY" }));
} else {
setPaymentErrors((prev) => ({ ...prev, expiry: "" }));
}
};
const validateCVV = () => {
if (payment.cvv && (payment.cvv.length < 3 || payment.cvv.length > 4)) {
setPaymentErrors((prev) => ({ ...prev, cvv: "CVV must be 3 or 4 digits" }));
} else {
setPaymentErrors((prev) => ({ ...prev, cvv: "" }));
}
};
const validateNameOnCard = () => {
if (payment.nameOnCard && payment.nameOnCard.length < 2) {
setPaymentErrors((prev) => ({
...prev,
nameOnCard: "Please enter the name on your card",
}));
} else {
setPaymentErrors((prev) => ({ ...prev, nameOnCard: "" }));
}
};
return (
<section>
<style>{`
/* Custom Fieldset 1 - Minimal with accent border */
.custom-fieldset-1::part(ag-fieldset) {
border-left: 3px solid var(--ag-primary);
border-top: var(--ag-border-width-1) solid var(--ag-border);
border-right: var(--ag-border-width-1) solid var(--ag-border);
border-bottom: var(--ag-border-width-1) solid var(--ag-border);
border-radius: var(--ag-radius-md);
padding: var(--ag-space-5);
}
.custom-fieldset-1::part(ag-legend) {
font-weight: 600;
color: var(--ag-text-primary);
}
/* Custom Fieldset 2 - Subtle card with shadow */
.custom-fieldset-2::part(ag-fieldset) {
border: var(--ag-border-width-1) solid var(--ag-border);
border-radius: var(--ag-radius-lg);
padding: var(--ag-space-5);
background: var(--ag-background-secondary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.custom-fieldset-2::part(ag-legend) {
font-weight: 600;
font-size: 1.125rem;
}
/* Monochrome button styling using CSS parts */
.monochrome-button::part(ag-button) {
background: transparent;
color: var(--ag-text-primary);
border: 2px solid var(--ag-text-primary);
}
.monochrome-button::part(ag-button):hover {
background: var(--ag-text-primary);
color: var(--ag-background-primary);
}
.monochrome-button-filled::part(ag-button) {
background: var(--ag-text-primary);
color: var(--ag-background-primary);
border: 2px solid var(--ag-text-primary);
}
.monochrome-button-filled::part(ag-button):hover {
background: var(--ag-text-secondary);
border-color: var(--ag-text-secondary);
}
`}</style>
<div className="mbe4">
<h2>Basic Fieldset</h2>
<p className="mbs2 mbe3">Group related form controls with a descriptive legend</p>
</div>
<ReactFieldset legend="Personal Information" className="mbe6">
<ReactInput
value={personalInfo.firstName}
onInput={(e) => setPersonalInfo({ ...personalInfo, firstName: e.target.value })}
label="First Name"
placeholder="John"
className="mbe3"
>
<User size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<ReactInput
value={personalInfo.lastName}
onInput={(e) => setPersonalInfo({ ...personalInfo, lastName: e.target.value })}
label="Last Name"
placeholder="Doe"
className="mbe3"
>
<User size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<ReactInput
value={personalInfo.email}
onInput={(e) => setPersonalInfo({ ...personalInfo, email: e.target.value })}
label="Email"
type="email"
placeholder="john.doe@example.com"
className="mbe3"
>
<Mail size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<ReactInput
value={personalInfo.phone}
onInput={(e) => setPersonalInfo({ ...personalInfo, phone: e.target.value })}
label="Phone Number"
type="tel"
placeholder="(555) 123-4567"
>
<Phone size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
</ReactFieldset>
<div className="mbe4">
<h2>Bordered Fieldset</h2>
<p className="mbs2 mbe3">Add visual borders and padding for better content grouping</p>
</div>
<ReactFieldset legend="Shipping Address" bordered className="mbe6">
<ReactInput
value={address.street}
onInput={(e) => setAddress({ ...address, street: e.target.value })}
label="Street Address"
placeholder="123 Main St"
className="mbe3"
>
<MapPin size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<div
className="mbe3"
style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "var(--ag-space-3)" }}
>
<ReactInput
value={address.city}
onInput={(e) => setAddress({ ...address, city: e.target.value })}
label="City"
placeholder="San Francisco"
>
<Building2 size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<ReactInput
value={address.zip}
onInput={(e) => setAddress({ ...address, zip: e.target.value })}
label="ZIP Code"
placeholder="94102"
/>
</div>
<ReactInput
value={address.country}
onInput={(e) => setAddress({ ...address, country: e.target.value })}
label="Country"
placeholder="United States"
>
<MapPin size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
</ReactFieldset>
<div className="mbe4">
<h2>Radio Button Group</h2>
<p className="mbs2 mbe3">Use fieldset to group related radio button choices</p>
</div>
<ReactFieldset legend="Preferred Contact Method" bordered className="mbe6">
<div style={{ display: "flex", flexDirection: "column", gap: "var(--ag-space-3)" }}>
<ReactRadio
name="contact-method"
value="email"
labelText="Email"
checked={contactMethod === "email"}
onChange={() => setContactMethod("email")}
/>
<ReactRadio
name="contact-method"
value="phone"
labelText="Phone"
checked={contactMethod === "phone"}
onChange={() => setContactMethod("phone")}
/>
<ReactRadio
name="contact-method"
value="sms"
labelText="Text Message (SMS)"
checked={contactMethod === "sms"}
onChange={() => setContactMethod("sms")}
/>
<ReactRadio
name="contact-method"
value="mail"
labelText="Postal Mail"
checked={contactMethod === "mail"}
onChange={() => setContactMethod("mail")}
/>
</div>
</ReactFieldset>
<div className="mbe4">
<h2>Checkbox Group</h2>
<p className="mbs2 mbe3">Group multiple checkboxes for selecting multiple options</p>
</div>
<ReactFieldset legend="Notification Preferences" bordered className="mbe6">
<div style={{ display: "flex", flexDirection: "column", gap: "var(--ag-space-3)" }}>
<ReactCheckbox
name="notifications"
value="product-updates"
labelText="Product Updates"
checked={notifications.productUpdates}
onChange={() =>
setNotifications({ ...notifications, productUpdates: !notifications.productUpdates })
}
/>
<ReactCheckbox
name="notifications"
value="newsletter"
labelText="Weekly Newsletter"
checked={notifications.newsletter}
onChange={() =>
setNotifications({ ...notifications, newsletter: !notifications.newsletter })
}
/>
<ReactCheckbox
name="notifications"
value="special-offers"
labelText="Special Offers & Promotions"
checked={notifications.specialOffers}
onChange={() =>
setNotifications({ ...notifications, specialOffers: !notifications.specialOffers })
}
/>
<ReactCheckbox
name="notifications"
value="security-alerts"
labelText="Security Alerts"
checked={notifications.securityAlerts}
onChange={() =>
setNotifications({ ...notifications, securityAlerts: !notifications.securityAlerts })
}
/>
</div>
</ReactFieldset>
<div className="mbe4">
<h2>Horizontal Layout</h2>
<p className="mbs2 mbe3">Arrange form controls horizontally with flexible wrapping</p>
</div>
<ReactFieldset legend="Date of Birth" bordered layout="horizontal" className="mbe6">
<ReactInput
value={dateOfBirth.month}
onInput={(e) => setDateOfBirth({ ...dateOfBirth, month: e.target.value })}
label="Month"
placeholder="MM"
size="small"
style={{ width: "80px" }}
/>
<ReactInput
value={dateOfBirth.day}
onInput={(e) => setDateOfBirth({ ...dateOfBirth, day: e.target.value })}
label="Day"
placeholder="DD"
size="small"
style={{ width: "80px" }}
/>
<ReactInput
value={dateOfBirth.year}
onInput={(e) => setDateOfBirth({ ...dateOfBirth, year: e.target.value })}
label="Year"
placeholder="YYYY"
size="small"
style={{ width: "100px" }}
/>
</ReactFieldset>
<div className="mbe4">
<h2>Visually Hidden Legend</h2>
<p className="mbs2 mbe3">Hide legend visually while keeping it accessible to screen readers</p>
</div>
<ReactFieldset legend="Search Options" bordered legendHidden className="mbe6">
<ReactInput
value={search.query}
onInput={(e) => setSearch({ ...search, query: e.target.value })}
label="Search Query"
placeholder="Enter search terms..."
className="mbe3"
>
<Search size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--ag-space-2)" }}>
<ReactCheckbox
name="search-options"
value="case-sensitive"
labelText="Case Sensitive"
size="small"
checked={search.caseSensitive}
onChange={() => setSearch({ ...search, caseSensitive: !search.caseSensitive })}
/>
<ReactCheckbox
name="search-options"
value="whole-words"
labelText="Match Whole Words Only"
size="small"
checked={search.wholeWords}
onChange={() => setSearch({ ...search, wholeWords: !search.wholeWords })}
/>
</div>
</ReactFieldset>
<div className="mbe4">
<h2>Complete Checkout Form</h2>
<p className="mbs2 mbe3">Realistic payment form with validation and action buttons</p>
</div>
<div className="mbe6">
<ReactFieldset legend="Payment Information" bordered className="mbe4">
<ReactInput
value={payment.cardNumber}
onInput={(e) => setPayment({ ...payment, cardNumber: e.target.value })}
label="Card Number"
placeholder="1234 5678 9012 3456"
required
invalid={!!paymentErrors.cardNumber}
errorMessage={paymentErrors.cardNumber}
onBlur={validateCardNumber}
className="mbe3"
>
<CreditCard
size={18}
color={paymentErrors.cardNumber ? "var(--ag-error)" : "var(--ag-secondary)"}
slot="addon-left"
/>
</ReactInput>
<div
className="mbe3"
style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--ag-space-3)" }}
>
<ReactInput
value={payment.expiry}
onInput={(e) => setPayment({ ...payment, expiry: e.target.value })}
label="Expiry Date"
placeholder="MM/YY"
required
invalid={!!paymentErrors.expiry}
errorMessage={paymentErrors.expiry}
onBlur={validateExpiry}
>
<Calendar size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<ReactInput
value={payment.cvv}
onInput={(e) => setPayment({ ...payment, cvv: e.target.value })}
label="CVV"
type="password"
placeholder="123"
required
invalid={!!paymentErrors.cvv}
errorMessage={paymentErrors.cvv}
onBlur={validateCVV}
/>
</div>
<ReactInput
value={payment.nameOnCard}
onInput={(e) => setPayment({ ...payment, nameOnCard: e.target.value })}
label="Name on Card"
placeholder="John Doe"
required
invalid={!!paymentErrors.nameOnCard}
errorMessage={paymentErrors.nameOnCard}
onBlur={validateNameOnCard}
className="mbe3"
>
<User size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
<ReactInput
value={payment.billingZip}
onInput={(e) => setPayment({ ...payment, billingZip: e.target.value })}
label="Billing ZIP Code"
placeholder="94102"
required
>
<MapPin size={18} color="var(--ag-secondary)" slot="addon-left" />
</ReactInput>
</ReactFieldset>
<div style={{ display: "flex", gap: "var(--ag-space-3)", justifyContent: "flex-end" }}>
<ReactButton bordered shape="rounded">
← Back to Cart
</ReactButton>
<ReactButton variant="primary" shape="rounded">
Complete Purchase
</ReactButton>
</div>
</div>
<div className="mbe4">
<h2>CSS Shadow Parts Customization</h2>
<p className="mbs2 mbe3">
Use CSS Shadow Parts to customize the component's appearance:
<code>::part(ag-fieldset)</code>,
<code>::part(ag-legend)</code>,
<code>::part(ag-content)</code>
</p>
</div>
<div className="mbe6">
<ReactFieldset legend="Minimal Accent Border" bordered className="custom-fieldset-1 mbe4">
<ReactInput
value={custom.field1}
onInput={(e) => setCustom({ ...custom, field1: e.target.value })}
label="Email Address"
type="email"
placeholder="you@company.com"
className="mbe3"
/>
<ReactInput
value={custom.field2}
onInput={(e) => setCustom({ ...custom, field2: e.target.value })}
label="Department"
placeholder="Engineering"
/>
</ReactFieldset>
<ReactFieldset legend="Subtle Card Style" bordered className="custom-fieldset-2">
<ReactInput
value={custom.field3}
onInput={(e) => setCustom({ ...custom, field3: e.target.value })}
label="Project Name"
placeholder="Q4 Marketing Campaign"
className="mbe3"
/>
<ReactInput
value={custom.field4}
onInput={(e) => setCustom({ ...custom, field4: e.target.value })}
label="Budget"
placeholder="$50,000"
/>
</ReactFieldset>
</div>
</section>
);
}
Usage
TIP
The framework examples below import AgnosticUI as an npm package. Alternatively, you can use the CLI for complete control, AI/LLM visibility, and full code ownership:
npx ag init --framework FRAMEWORK # react, vue, lit, svelte, etc.
npx ag add FieldsetThe CLI copies source code directly into your project, giving you full visibility and control. After running npx ag add, you'll receive exact import instructions.
Vue
<template>
<section>
<VueFieldset legend="Personal Information">
<VueInput
v-model:value="firstName"
label="First Name"
placeholder="John"
/>
<VueInput
v-model:value="lastName"
label="Last Name"
placeholder="Doe"
/>
</VueFieldset>
<VueFieldset
legend="Shipping Address"
:bordered="true"
>
<VueInput
v-model:value="street"
label="Street Address"
placeholder="123 Main St"
/>
<VueInput
v-model:value="city"
label="City"
placeholder="San Francisco"
/>
</VueFieldset>
<VueFieldset
legend="Preferred Contact Method"
:bordered="true"
>
<VueRadio
name="contact"
value="email"
label-text="Email"
:checked="contact === 'email'"
@change="contact = 'email'"
/>
<VueRadio
name="contact"
value="phone"
label-text="Phone"
:checked="contact === 'phone'"
@change="contact = 'phone'"
/>
</VueFieldset>
<VueFieldset
legend="Date of Birth"
layout="horizontal"
:bordered="true"
>
<VueInput
v-model:value="month"
label="Month"
placeholder="MM"
size="small"
/>
<VueInput
v-model:value="day"
label="Day"
placeholder="DD"
size="small"
/>
<VueInput
v-model:value="year"
label="Year"
placeholder="YYYY"
size="small"
/>
</VueFieldset>
<VueFieldset
legend="Search Options"
:legend-hidden="true"
:bordered="true"
>
<VueInput
v-model:value="query"
label="Search Query"
placeholder="Enter search terms..."
/>
<VueCheckbox
name="options"
value="case-sensitive"
label-text="Case Sensitive"
/>
</VueFieldset>
</section>
</template>
<script>
import VueFieldset from "agnosticui-core/fieldset/vue";
import VueInput from "agnosticui-core/input/vue";
import VueRadio from "agnosticui-core/radio/vue";
import VueCheckbox from "agnosticui-core/checkbox/vue";
export default {
components: {
VueFieldset,
VueInput,
VueRadio,
VueCheckbox,
},
data() {
return {
firstName: "",
lastName: "",
street: "",
city: "",
contact: "email",
month: "",
day: "",
year: "",
query: "",
};
},
};
</script>React
import { useState } from "react";
import { ReactFieldset } from "agnosticui-core/react";
import { ReactInput } from "agnosticui-core/react";
import { ReactRadio } from "agnosticui-core/react";
import { ReactCheckbox } from "agnosticui-core/react";
export default function FieldsetExample() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [street, setStreet] = useState("");
const [city, setCity] = useState("");
const [contact, setContact] = useState("email");
const [month, setMonth] = useState("");
const [day, setDay] = useState("");
const [year, setYear] = useState("");
const [query, setQuery] = useState("");
return (
<section>
<ReactFieldset legend="Personal Information">
<ReactInput
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
label="First Name"
placeholder="John"
/>
<ReactInput
value={lastName}
onChange={(e) => setLastName(e.target.value)}
label="Last Name"
placeholder="Doe"
/>
</ReactFieldset>
<ReactFieldset
legend="Shipping Address"
bordered
>
<ReactInput
value={street}
onChange={(e) => setStreet(e.target.value)}
label="Street Address"
placeholder="123 Main St"
/>
<ReactInput
value={city}
onChange={(e) => setCity(e.target.value)}
label="City"
placeholder="San Francisco"
/>
</ReactFieldset>
<ReactFieldset
legend="Preferred Contact Method"
bordered
>
<ReactRadio
name="contact"
value="email"
labelText="Email"
checked={contact === "email"}
onChange={() => setContact("email")}
/>
<ReactRadio
name="contact"
value="phone"
labelText="Phone"
checked={contact === "phone"}
onChange={() => setContact("phone")}
/>
</ReactFieldset>
<ReactFieldset
legend="Date of Birth"
layout="horizontal"
bordered
>
<ReactInput
value={month}
onChange={(e) => setMonth(e.target.value)}
label="Month"
placeholder="MM"
size="small"
/>
<ReactInput
value={day}
onChange={(e) => setDay(e.target.value)}
label="Day"
placeholder="DD"
size="small"
/>
<ReactInput
value={year}
onChange={(e) => setYear(e.target.value)}
label="Year"
placeholder="YYYY"
size="small"
/>
</ReactFieldset>
<ReactFieldset
legend="Search Options"
legendHidden
bordered
>
<ReactInput
value={query}
onChange={(e) => setQuery(e.target.value)}
label="Search Query"
placeholder="Enter search terms..."
/>
<ReactCheckbox
name="options"
value="case-sensitive"
labelText="Case Sensitive"
/>
</ReactFieldset>
</section>
);
}Lit (Web Components)
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import 'agnosticui-core/fieldset';
import 'agnosticui-core/input';
import 'agnosticui-core/radio';
import 'agnosticui-core/checkbox';
@customElement('fieldset-example')
export class FieldsetExample extends LitElement {
static styles = css`
:host {
display: block;
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
`;
firstUpdated() {
// Set up event listeners for radio buttons in the shadow DOM
const emailRadio = this.shadowRoot?.querySelector('#contact-email');
const phoneRadio = this.shadowRoot?.querySelector('#contact-phone');
emailRadio?.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
console.log('Email selected');
}
});
phoneRadio?.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.checked) {
console.log('Phone selected');
}
});
}
render() {
return html`
<section>
<ag-fieldset legend="Personal Information">
<ag-input
label="First Name"
placeholder="John"
></ag-input>
<ag-input
label="Last Name"
placeholder="Doe"
></ag-input>
</ag-fieldset>
<ag-fieldset
legend="Shipping Address"
bordered
>
<ag-input
label="Street Address"
placeholder="123 Main St"
></ag-input>
<ag-input
label="City"
placeholder="San Francisco"
></ag-input>
</ag-fieldset>
<ag-fieldset
legend="Preferred Contact Method"
bordered
>
<ag-radio
id="contact-email"
name="contact"
value="email"
label-text="Email"
checked
></ag-radio>
<ag-radio
id="contact-phone"
name="contact"
value="phone"
label-text="Phone"
></ag-radio>
</ag-fieldset>
<ag-fieldset
legend="Date of Birth"
layout="horizontal"
bordered
>
<ag-input
label="Month"
placeholder="MM"
size="small"
></ag-input>
<ag-input
label="Day"
placeholder="DD"
size="small"
></ag-input>
<ag-input
label="Year"
placeholder="YYYY"
size="small"
></ag-input>
</ag-fieldset>
<ag-fieldset
legend="Search Options"
legend-hidden
bordered
>
<ag-input
label="Search Query"
placeholder="Enter search terms..."
></ag-input>
<ag-checkbox
name="options"
value="case-sensitive"
label-text="Case Sensitive"
></ag-checkbox>
</ag-fieldset>
</section>
`;
}
}Note: When using fieldset components within a custom element's shadow DOM, set up event listeners in the component's lifecycle (e.g., firstUpdated()) rather than using DOMContentLoaded, as document.querySelector() cannot access elements inside shadow DOM. Use this.shadowRoot.querySelector() instead.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
legend | string | '' | Legend text for the fieldset. Provides a descriptive title for the group of form controls. |
bordered | boolean | false | Whether to apply borders and padding around the fieldset for visual grouping. |
layout | 'vertical' | 'horizontal' | 'vertical' | Layout mode for the fieldset content. Use 'horizontal' for side-by-side form controls. |
legendHidden | boolean | false | Visually hides the legend while keeping it accessible to screen readers. |
Slots
| Slot | Description |
|---|---|
| default | Content slot for form controls and other elements to be grouped within the fieldset. |
Accessibility
The Fieldset component follows W3C WAI-ARIA Grouping Content best practices and WCAG 2.1 Level AA:
- Semantic Grouping: Uses native
<fieldset>and<legend>elements for proper semantic structure - Screen Reader Support: Legend is announced before each form control in the group, providing essential context
- Required Context: Always include a legend (use
legendHiddenif you need to hide it visually) - Keyboard Navigation: Fieldset doesn't interfere with natural keyboard navigation of form controls
- Focus Management: Form controls within maintain their native focus behavior
- ARIA Labels: Legend provides automatic labeling context for grouped controls
When to Use Fieldset
Use fieldset to group related form controls in these scenarios:
Radio Button Groups (Required):
<VueFieldset legend="Preferred Contact Method">
<VueRadio name="contact" value="email" label-text="Email" />
<VueRadio name="contact" value="phone" label-text="Phone" />
<VueRadio name="contact" value="sms" label-text="SMS" />
</VueFieldset>Checkbox Groups:
<VueFieldset legend="Notification Preferences">
<VueCheckbox name="notifications" value="email" label-text="Email Updates" />
<VueCheckbox name="notifications" value="sms" label-text="SMS Alerts" />
</VueFieldset>Related Form Fields:
<VueFieldset legend="Shipping Address">
<VueInput label="Street" />
<VueInput label="City" />
<VueInput label="ZIP Code" />
</VueFieldset>Multi-part Inputs:
<VueFieldset legend="Credit Card Expiration" layout="horizontal">
<VueInput label="Month" size="small" />
<VueInput label="Year" size="small" />
</VueFieldset>Form Integration
Fieldsets are essential for organizing complex forms and providing accessibility context:
<template>
<form @submit.prevent="handleSubmit">
<VueFieldset
legend="Personal Information"
:bordered="true"
>
<VueInput
v-model:value="form.firstName"
label="First Name"
name="firstName"
:required="true"
:invalid="!!errors.firstName"
:error-message="errors.firstName"
/>
<VueInput
v-model:value="form.lastName"
label="Last Name"
name="lastName"
:required="true"
:invalid="!!errors.lastName"
:error-message="errors.lastName"
/>
<VueInput
v-model:value="form.email"
label="Email"
name="email"
type="email"
:required="true"
:invalid="!!errors.email"
:error-message="errors.email"
/>
</VueFieldset>
<VueFieldset
legend="Communication Preferences"
:bordered="true"
>
<VueCheckbox
name="preferences"
value="newsletter"
label-text="Subscribe to Newsletter"
:checked="form.preferences.newsletter"
@change="form.preferences.newsletter = !form.preferences.newsletter"
/>
<VueCheckbox
name="preferences"
value="updates"
label-text="Product Updates"
:checked="form.preferences.updates"
@change="form.preferences.updates = !form.preferences.updates"
/>
</VueFieldset>
<button type="submit">Submit</button>
</form>
</template>
<script>
export default {
data() {
return {
form: {
firstName: "",
lastName: "",
email: "",
preferences: {
newsletter: false,
updates: true,
},
},
errors: {
firstName: "",
lastName: "",
email: "",
},
};
},
methods: {
handleSubmit() {
this.validateForm();
if (this.isFormValid()) {
console.log("Form submitted:", this.form);
}
},
validateForm() {
this.errors.firstName = this.form.firstName ? "" : "First name is required";
this.errors.lastName = this.form.lastName ? "" : "Last name is required";
this.errors.email = this.isValidEmail(this.form.email) ? "" : "Valid email is required";
},
isFormValid() {
return !this.errors.firstName && !this.errors.lastName && !this.errors.email;
},
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
},
},
};
</script>Layout Variants
Vertical Layout (Default)
Form controls are stacked vertically, which is the standard form layout:
<VueFieldset legend="Contact Information">
<VueInput v-model:value="name" label="Name" />
<VueInput v-model:value="email" label="Email" type="email" />
<VueInput v-model:value="phone" label="Phone" type="tel" />
</VueFieldset>Horizontal Layout
Form controls are arranged side-by-side with flexible wrapping:
<VueFieldset
legend="Date of Birth"
layout="horizontal"
>
<VueInput v-model:value="month" label="Month" size="small" />
<VueInput v-model:value="day" label="Day" size="small" />
<VueInput v-model:value="year" label="Year" size="small" />
</VueFieldset>The horizontal layout uses flexbox with wrapping, so controls will wrap to the next line on smaller screens.
Styling Options
Bordered Fieldset
Add visual borders and padding for clearer visual grouping:
<VueFieldset
legend="Account Settings"
:bordered="true"
>
</VueFieldset>Hidden Legend
Keep the legend accessible to screen readers while hiding it visually:
<VueFieldset
legend="Filter Options"
:legend-hidden="true"
>
</VueFieldset>Important: Always provide a legend for accessibility. Use legendHidden instead of omitting the legend entirely.
Nested Fieldsets
For complex forms, you can nest fieldsets to create hierarchical groupings:
<VueFieldset legend="Account Settings" :bordered="true">
<VueFieldset legend="Profile">
<VueInput v-model:value="username" label="Username" />
<VueInput v-model:value="displayName" label="Display Name" />
</VueFieldset>
<VueFieldset legend="Privacy">
<VueCheckbox value="public" label-text="Make Profile Public" />
<VueCheckbox value="searchable" label-text="Allow Search Indexing" />
</VueFieldset>
</VueFieldset>Best Practices
Always Include a Legend - Essential for accessibility and context. Use
legendHiddenif you need to hide it visually.Use for Radio Groups - Radio button groups should always be wrapped in a fieldset with a descriptive legend.
Group Related Fields - Use fieldsets to group fields that share a common purpose or context (e.g., shipping address, payment info).
Keep Legends Descriptive - Write clear, concise legends that describe the purpose of the grouped controls.
Consider Bordered Style - Use
borderedprop for better visual separation in complex forms.Choose Appropriate Layout - Use
layout="horizontal"for compact, related inputs (like date parts). Use default vertical for most forms.Don't Overuse - Not every form needs fieldsets. Use them when grouping provides meaningful context.
Validate as a Group - When validating forms, consider fieldset boundaries for error messaging and focus management.
CSS Shadow Parts
The Fieldset component exposes the following CSS Shadow Parts for custom styling:
| Part | Description |
|---|---|
ag-fieldset | The fieldset element itself |
ag-legend | The legend element |
ag-content | The content wrapper div that holds slotted controls |
Customization Examples
ag-fieldset::part(ag-fieldset) {
border: 2px solid transparent;
background: linear-gradient(white, white) padding-box,
linear-gradient(135deg, #667eea 0%, #764ba2 100%) border-box;
border-radius: 16px;
padding: 1.5rem;
}
ag-fieldset::part(ag-legend) {
font-weight: 700;
font-size: 1.125rem;
color: #667eea;
text-transform: uppercase;
letter-spacing: 0.05em;
}
ag-fieldset::part(ag-fieldset) {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border: 2px solid #475569;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
ag-fieldset::part(ag-legend) {
font-weight: 700;
color: #f1f5f9;
padding-bottom: 0.5rem;
border-bottom: 2px solid #475569;
}
/* Minimalist style */
ag-fieldset::part(ag-fieldset) {
border: none;
border-left: 4px solid #12623e;
padding-left: 1.5rem;
background: #f3f4f6;
}
ag-fieldset::part(ag-legend) {
font-weight: 600;
color: #12623e;
text-transform: uppercase;
font-size: 0.875rem;
letter-spacing: 0.1em;
}
/* Content spacing customization */
ag-fieldset::part(ag-content) {
display: flex;
flex-direction: column;
gap: var(--ag-space-5);
}See the CSS Shadow Parts Customization section in the examples above for live demonstrations.
When to Use
Use Fieldset when:
- Grouping radio buttons (always required for radio groups)
- Grouping related checkboxes for multiple selections
- Organizing related form fields (address, payment info, etc.)
- Creating multi-part inputs (date, phone number, etc.)
- Building complex forms that need logical sections
Consider alternatives when:
- You only have a single form control - no grouping needed
- The form is very simple (1-2 fields) - grouping may add unnecessary complexity
- You need visual sections without semantic grouping - consider using divs with headings instead
Customization with CSS Variables
You can customize spacing using CSS variables:
ag-fieldset {
--ag-fieldset-padding: var(--ag-space-6);
--ag-fieldset-gap: var(--ag-space-5);
--ag-fieldset-legend-margin-bottom: var(--ag-space-4);
}