Skip to content

Fieldset

Experimental Alpha

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

Vue
Lit
React
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.

Delete Account
Cancel
Reset to Default Save Changes

Complete Checkout Form

Realistic payment form with validation and action buttons

← Back to Cart Complete Purchase

Compact Forms with Small Components

Create compact UIs with small inputs, buttons, and fieldsets

Clear Apply
Unsubscribe Subscribe
Confirm & Continue

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>
  );
}
Open in StackBlitz

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:

bash
npx ag init --framework FRAMEWORK # react, vue, lit, svelte, etc.
npx ag add Fieldset

The 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
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
tsx
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)
typescript
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

PropTypeDefaultDescription
legendstring''Legend text for the fieldset. Provides a descriptive title for the group of form controls.
borderedbooleanfalseWhether 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.
legendHiddenbooleanfalseVisually hides the legend while keeping it accessible to screen readers.

Slots

SlotDescription
defaultContent 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 legendHidden if 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):

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

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

vue
<VueFieldset legend="Shipping Address">
  <VueInput label="Street" />
  <VueInput label="City" />
  <VueInput label="ZIP Code" />
</VueFieldset>

Multi-part Inputs:

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

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

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

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

vue
<VueFieldset
  legend="Account Settings"
  :bordered="true"
>
</VueFieldset>

Hidden Legend

Keep the legend accessible to screen readers while hiding it visually:

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

vue
<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

  1. Always Include a Legend - Essential for accessibility and context. Use legendHidden if you need to hide it visually.

  2. Use for Radio Groups - Radio button groups should always be wrapped in a fieldset with a descriptive legend.

  3. Group Related Fields - Use fieldsets to group fields that share a common purpose or context (e.g., shipping address, payment info).

  4. Keep Legends Descriptive - Write clear, concise legends that describe the purpose of the grouped controls.

  5. Consider Bordered Style - Use bordered prop for better visual separation in complex forms.

  6. Choose Appropriate Layout - Use layout="horizontal" for compact, related inputs (like date parts). Use default vertical for most forms.

  7. Don't Overuse - Not every form needs fieldsets. Use them when grouping provides meaningful context.

  8. 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:

PartDescription
ag-fieldsetThe fieldset element itself
ag-legendThe legend element
ag-contentThe content wrapper div that holds slotted controls

Customization Examples

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

css
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);
}

Resources