@charpente-ui/vue
v2.8.1
Published
Headless, unstyled Vue 3 components for accessible forms. Logic only, zero CSS.
Downloads
2,123
Maintainers
Readme
Charpente UI
Introduction
A logic-first, headless UI library for Vue 3. The logic you need, without the CSS you don't.
Philosophy: Don't Reinvent the Wheel
Charpente UI is built on a simple promise: We handle the boring stuff, you handle the design.
Most UI libraries are bloated because they try to impose a visual style. Charpente UI is headless. We provide the "chassis" (HTML structure and complex input logic) and you bring the "paint" (Tailwind, CSS Modules, or Styled Components).
Core Principles:
- Zero Style: No CSS included. Total freedom for your UI.
- Transparent Wrapper: We don't hide native HTML. Attributes like type, placeholder, or required work exactly like standard HTML via attribute inheritance.
- Smart Logic: Complex components like
CCheckboxorCRadiohandle array management and state internally so you don't have to "take the lead" on complex boilerplate.
Installing
npm install @charpente-ui/vuePlayground
Try it live on StackBlitz — no installation required:
Or run it locally:
npm run devUsage
<script setup>
import { ref } from 'vue';
import { CForm, CField, CLabel, CInput, CSupportingText, CButton } from '@charpente-ui/vue';
const email = ref('');
function onSubmit() {
// Only called once the form passes native validation.
}
</script>
<template>
<CForm validate @submit="onSubmit">
<CField class="form-group">
<CLabel>Email Address</CLabel>
<CInput v-model="email" type="email" placeholder="[email protected]" required
class="my-custom-input-style"/>
<CSupportingText validation>We never share your email.</CSupportingText>
</CField>
<CButton type="submit" class="btn-primary">
Subscribe
</CButton>
</CForm>
</template>No for/id wiring, no validation library: the label, the hint and the browser-localized error messages are linked and
accessible automatically — and every class lands on the native element, ready for your CSS.
Component Reference
- Form Inputs (CInput, CTextarea, CSelect)
These components are thin wrappers around native elements. They use v-model and automatically link with labels via
useId(). Full attribute inheritance. CInput and CTextarea support the native v-model modifiers:
[!NOTE] Every auto-generated
id/nameis prefixed withcui-(e.g.cui-v-0), so it never collides with an id from the host app or another component library on the same page. Pass an explicitid/nameto opt out.
<CInput v-model.trim="name"/>
<CInput v-model.number="age"/>
<CInput v-model.lazy="query"/>- Selection Logic (CCheckbox, CRadio, CSelect)
Managing checkbox arrays in Vue can be repetitive. Charpente UI simplifies this:
<CCheckbox v-model="tags" value="foo"/>
<CCheckbox v-model="tags" value="bar"/>CCheckbox also supports the indeterminate state for partial selections:
<CCheckbox v-model="allSelected" :indeterminate="someSelected"/>CSelect supports multiple selection via the native multiple attribute:
<CSelect v-model="selectedItems" multiple>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
</CSelect>Declaring options with a prop
Writing <option> by hand gets tedious when the list comes from data, so CSelect also accepts an options prop.
Each entry is either a { label, value, disabled? } object or a plain string/number used as both label and value:
<CSelect v-model="fruit" :options="['apple', 'banana']"/>
<CSelect
v-model="fruit"
:options="[
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana', disabled: true }
]"
/>An entry carrying its own options array renders an <optgroup>:
<CSelect
v-model="fruit"
:options="[
{ label: 'Citrus', options: ['lemon', 'orange'] },
{ label: 'Berries', options: ['strawberry'], disabled: true }
]"
/>Values are bound with :value, so numbers, and anything else you put in value, survive the round-trip — the
v-model gives you back exactly what you passed in.
The prop completes the slot, it does not replace it. Slot content renders first, which is what you want for a placeholder:
<CSelect v-model="fruit" :options="fruits">
<option value="" disabled>Choose a fruit…</option>
</CSelect>The SelectOption, SelectOptionGroup and SelectOptionItem types are exported for typing your own lists.
- Group Components (CRadioGroup, CCheckboxGroup)
Groups wrap related inputs in a semantic <fieldset>, sharing a v-model and a name attribute across all children
automatically.
<CRadioGroup v-model="selected">
<legend>Favorite fruit</legend>
<CLabel for="opt-a">Option A</CLabel>
<CRadio id="opt-a" value="a"/>
<CLabel for="opt-b">Option B</CLabel>
<CRadio id="opt-b" value="b"/>
</CRadioGroup><CCheckboxGroup v-model="selected">
<legend>Favorite fruits</legend>
<CLabel for="cb-a">Option A</CLabel>
<CCheckbox id="cb-a" value="a"/>
<CLabel for="cb-b">Option B</CLabel>
<CCheckbox id="cb-b" value="b"/>
</CCheckboxGroup>Naming the group
A <fieldset> takes its accessible name from a <legend>, so that's what names the group — pass one as the first
child and the browser does the rest. There is no CLegend: the native element already works, and the group renders
your slot content untouched.
[!WARNING] Do not use
CLabelto name a group. A<label for>can only point at a labelable element, and a<fieldset>is not one. Inside a groupCLabelalso finds no field id to pick up (the group masks any surroundingCFieldon purpose), so it renders a<label>with noforat all — a label attached to nothing, silently.CLabelis for individual controls;<legend>is for the group.
Set name on the group and every child inherits it. A child can still override it with its own name attribute.
<CRadioGroup v-model="selected" name="my-group">...</CRadioGroup>
<CCheckboxGroup v-model="selected" name="my-group">...</CCheckboxGroup>The two groups differ when you leave name out, on purpose:
CCheckboxGroupemits noname. The attribute is the key your data is submitted under, and the library cannot know your server's schema — inventing one would post the boxes under a meaningless key. With noname, the boxes are simply not submitted, which is what you asked for by omitting it.v-modelis unaffected.CRadioGroupfalls back to a generated name (useId(), shared across the children). On radios the sharednameis not just a payload key: the browser relies on it for arrow-key navigation between the buttons and for group-levelrequiredvalidation. Without it a forgottennamewould silently break keyboard accessibility, so the fallback trades a throwaway key inFormDatafor behaviour that works out of the box.
Set name explicitly on CRadioGroup whenever the form is submitted natively, and the generated value never
reaches your payload.
[!NOTE] Standalone
CRadioandCCheckboxaccept anyvalue(strings, numbers, booleans, objects —v-modelcompares by reference, as in native Vue). Inside a group, the groupv-modelis typedstring | number((string | number)[]for checkboxes): stick to those value types in grouped mode, as TypeScript cannot enforce it on thevalueprop without dropping standalone object support.
Describing a group
Wrapped in a CField, a group is described as a whole: the <fieldset> carries the aria-describedby pointing at the
supporting texts and the aria-invalid flag, so a screen reader announces the hint or the error once when entering the
group rather than on every item. Write the CSupportingText inside the group or next to it — both register with the
field.
<CField>
<CRadioGroup v-model="plan">
<legend>Plan</legend>
<CRadio value="free"/>
<CRadio value="pro"/>
<CSupportingText validation/>
</CRadioGroup>
</CField>The items themselves get no aria-describedby, no aria-invalid and no field id — each keeps its own generated id so
labels stay paired one-to-one. Set aria-describedby or aria-invalid on the group to override either.
- Polymorphic Elements (CButton)
The button can change its HTML tag while keeping its behavior.
<CButton as="a" href="/login">Login Link</CButton>
<CButton as="RouterLink" to="/dashboard">Dashboard</CButton>When rendering a native <button>, CButton defaults to type="button" (instead of the native type="submit") to
avoid accidental form submissions. Pass type="submit" explicitly for submit buttons.
- Field Wrapper (CField)
CField links a label and an input automatically: it provides a shared auto-generated id that CLabel picks up as
for and the wrapped input picks up as id — no manual wiring.
<CField>
<CLabel>Email</CLabel>
<CInput v-model="email" type="email"/>
</CField>CSupportingText renders a field's hint or error text inside a CField: the input automatically gets an
aria-describedby pointing to it, and the attribute is removed when the text unmounts (e.g. behind a v-if). Several
supporting texts in the same field are all referenced, in mount order — a permanent hint and a conditional error can
coexist.
<CField>
<CLabel>Email</CLabel>
<CInput v-model="email" type="email"/>
<CSupportingText v-if="error">{{ error }}</CSupportingText>
</CField>An explicit aria-describedby on the input always wins, and a standalone CSupportingText (outside a field)
simply renders its content with an id. Likewise, an explicit id on the input or for on the label always wins over
the field id.
Passing id to CField itself names that pairing instead of the wrapper <div> — reusing it on both would put the
same id on two different DOM elements:
<CField id="email-field">
<CLabel>Email</CLabel>
<CInput v-model="email" type="email"/>
</CField>Renders <label for="email-field"> and <input id="email-field">; the <div> itself gets no id. Use class
to target the wrapper.
A CField wrapping a whole group is ignored by the items (a single id must not land on every input); wrap each item in
its own CField instead:
<CRadioGroup v-model="selected">
<CField>
<CLabel>Option A</CLabel>
<CRadio value="a"/>
</CField>
<CField>
<CLabel>Option B</CLabel>
<CRadio value="b"/>
</CField>
</CRadioGroup>- Native Validation (CForm + CField + CSupportingText)
Browsers already validate forms (required, type="email", minlength, pattern…) and localize their error messages
for free. Charpente UI exposes that instead of reinventing it — opt in with the validate prop:
<CForm validate @submit="onSubmit">
<CField>
<CLabel>Email</CLabel>
<CInput v-model="email" type="email" required/>
<CSupportingText validation>We never share your email.</CSupportingText>
</CField>
</CForm>CForm validatesuppresses the native bubbles (novalidate), blockssubmituntil the form is valid, and focuses the first invalid control.- Errors appear after the first submit attempt, then update live as the user fixes the value. Resetting the form
(
<button type="reset">orform.reset()) clears them along with the values, back to the pre-submit state. CSupportingText validationshows the browser's localizedvalidationMessagewhile invalid, and falls back to its slot content otherwise. The control also getsaria-invalidautomatically, and the text becomes arole="alert"live region so screen readers announce the message when it swaps in. Pass an explicitrole(e.g.role="status"for a gentler, polite announcement) to override it.- Without
validate, nothing changes — bring your own validation library if you prefer. - Native escapes still work: a submit button carrying
formnovalidateskips validation for its own submission, sosubmitis emitted with the form still invalid. The HTML spec puts the no-validate state on the submitter as much as on the form, andvalidatehonours it — that's what a "save draft" button next to a "publish" one relies on.
<CForm validate @submit="onSubmit">
<CField>
<CLabel>Title</CLabel>
<CInput v-model="title" required/>
</CField>
<CButton type="submit">Publish</CButton>
<CButton type="submit" formnovalidate>Save draft</CButton>
</CForm>[!WARNING]
validateguarantees a valid form onsubmitexcept when the submitter carriesformnovalidate. Readevent.submitterin your handler if the two paths need to behave differently.
Rules of your own
The browser cannot know that two passwords must match. Pass a rule and it joins the browser's own validation instead
of running beside it — the control becomes :invalid, the form refuses to submit, and the message flows to
CSupportingText validation like any native error:
<CField>
<CLabel>Confirm password</CLabel>
<CInput v-model="confirm" type="password" required
:rule="value => value === password ? '' : 'Both passwords must match.'"/>
<CSupportingText validation/>
</CField>A rule returns the error message, or an empty string when the value is acceptable — the contract of
setCustomValidity(), which is what it drives underneath. It receives the model value, not element.value.
Cross-field rules come for free. The rule runs inside a reactive effect, so anything reactive it reads becomes a dependency: editing
passwordre-checks the confirmation on its own. No watcher, no dependency list. Keep the rule pure for the same reason — a side effect inside it fires on every re-evaluation.Native constraints win. A rule only runs once the HTML constraints already pass, so an empty
requiredfield keeps the browser's own localized message rather than your English-only string. The flip side: your messages are yours to translate.Async checks work without any async API. A rule cannot
await, but it is evaluated reactively, so it only needs to read a ref you fill asynchronously. Returning a message while the request is in flight is what blocks a submit fired before the answer arrives:const rule = () => pending.value ? 'Checking availability…' : serverError.value;The same ref carries an error the server returns after submission. The debounce stays yours.
Available on
CInput,CTextarea,CSelect,CFile,CCheckboxandCRadio. A<fieldset>has no validity of its own, so groups take no rule — put it on an item. TheValidationRuletype is exported.
How CField tracks validity: the events it listens to
CField never calls the Constraint Validation API itself — it listens to three native events on its wrapper <div>,
all in the capture phase, so they're caught on the way down regardless of which control inside the field fired them:
| Event | When it fires | What CField does |
|----------------------------|---------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|
| invalid (capture) | The browser rejects a control's value on submit/checkValidity() | Sets invalid = true and message to the control's validationMessage |
| input (capture) | The user types/changes a value, before the field was ever flagged invalid | Ignored — no cost paid until the field actually fails once |
| input/change (capture) | The user edits a value after the field was flagged invalid | Re-checks target.validity.valid live, clearing invalid/message as soon as the value passes |
The invalid event matters here because it does not bubble — it only reaches ancestors during the capture phase, so
@invalid.capture on the field's own root is the only way to observe it without wiring a listener on every control by
hand. input/change are only used after the first invalid, so a field that has never failed validation pays no
per-keystroke cost.
CField stays zero-CSS: it never applies a class itself. If you need custom styling beyond [aria-invalid] or
:invalid selectors, it gives you two ways to read its invalid/message state:
- Default slot — reaches elements placed directly inside
CField:
<CField v-slot="{ invalid, message }">
<CLabel>Email</CLabel>
<CInput v-model="email" type="email" required :class="{ 'is-invalid': invalid }"/>
<span v-if="invalid" class="error">{{ message }}</span>
</CField>- Template ref — to style
CField's own wrapper element instead of a child:
<CField ref="fieldRef" :class="{ 'is-invalid': fieldRef?.invalid }">
<CLabel>Email</CLabel>
<CInput v-model="email" type="email" required/>
</CField>Both only reach elements placed directly inside CField's default slot, or the CField element itself — a custom
component nested deeper won't receive invalid/message automatically; pass them down as props if you need that.
Wiring a control CField doesn't own
CLabel, CInput & co. pick up the field's id and aria-describedby by injection. A plain <input>, or a third-party
component like a date picker, can't — so the default slot hands both out for you to bind by hand:
<CField v-slot="{ id, describedBy, invalid }">
<CLabel>Date of birth</CLabel>
<VueDatePicker :uid="id" :aria-describedby="describedBy" :aria-invalid="invalid || undefined"/>
<CSupportingText validation>DD/MM/YYYY</CSupportingText>
</CField>id is the field's own id — the one CLabel points its for at — and describedBy is the space-separated list of the
ids of every CSupportingText registered in the field. Both stay reactive: a supporting text mounted or unmounted later
updates describedBy on the spot.
[!NOTE] A third-party component only ends up accessible if it forwards that id down to its real
<input>. Check its API (uid,input-id,inputProps…) instead of assuming a plainidlands on the right element.
Accessibility
The library leans on native elements, so most of the accessible behaviour comes from the browser rather than from ARIA. What follows is the contract: what you get without asking, and what stays yours.
What the library wires for you
| Behaviour | How |
|----------------------------|-----------------------------------------------------------------------------------------|
| Label ↔ control pairing | CField shares a generated id, CLabel picks it up as for. |
| Hint and error association | CSupportingText registers its id, controls expose it as aria-describedby. |
| Invalid state | aria-invalid follows native constraint validation, and clears as the user types. |
| Error announcement | CSupportingText validation becomes a role="alert" live region. |
| Group description | The <fieldset> carries the wiring once, not each item. Details |
| Radio group keyboard nav | A shared name keeps native arrow-key navigation working. Details |
| Focus on submit | CForm validate focuses the first invalid control instead of failing silently. |
Every one of these can be overridden: pass aria-describedby, aria-invalid or role explicitly and yours wins.
What stays your responsibility
Name every group with a <legend>. A <fieldset> takes its accessible name from its legend, and nothing else will
do — see the warning in Naming the group. CLabel cannot name a group: a <label for>
only points at a labelable element, and inside a group CLabel finds no id to bind, so it silently renders a label
attached to nothing.
CButton guarantees no semantics beyond the tag you choose. With as="button" (the default) you get everything
natively. With as="div" or as="span" you get an element with no role, no tabindex and no keyboard activation —
invisible to assistive technology. And disabled is inert on anything that is not a form control, so
<CButton as="a" disabled> stays focusable and clickable. Pass the ARIA yourself, or keep a real <button>.
One control per CField. The wrapper tracks a single validation message, so two controls inside the same field
overwrite each other's — the last one to fire invalid wins. Wrap each control in its own CField; use
CRadioGroup/CCheckboxGroup for a set of related items, which is handled as a single control on purpose.
Anything the browser cannot infer. aria-label on an icon-only button, aria-expanded on a disclosure trigger,
aria-current on a nav link: the library never guesses these, because it does not know what you are building.
[!NOTE] The wiring above is verified by unit and end-to-end tests asserting the rendered attributes. That is not the same as validation with a real screen reader — notably,
aria-invalidon a<fieldset>is valid but unevenly announced across assistive technologies. Test your own critical flows.
Components
| Name | Core Logic | Tag | Status |
|----------------|----------------------------------------------------------------------------------|-------------------|--------|
| Button | Polymorphic: Switches tags (a, button, etc...) while keeping logic. | CButton | Ready |
| Checkbox | Smart Toggle: Handles array state, booleans, and indeterminate natively. | CCheckbox | Ready |
| CheckboxGroup | Group: Shared v-model and opt-in name across checkboxes inside a fieldset. | CCheckboxGroup | Ready |
| Field | Wrapper: Auto-links a label and an input via a shared generated id. | CField | Ready |
| File | File Input: Reactive file selection with v-model support. | CFile | Ready |
| Form | Auto-Submit: preventDefault handling and opt-in native validation. | CForm | Ready |
| Input | Auto-ID: Auto-links to labels via useId() and full attributes inheritance. | CInput | Ready |
| Label | Context-Aware: Simple, accessible binding for any input. | CLabel | Ready |
| Radio | Selection: Minimalist wrapper for native radio input. | CRadio | Ready |
| RadioGroup | Group: Shared v-model and name across radios inside a fieldset. | CRadioGroup | Ready |
| Select | Native Wrapper: Single and multiple selection support. | CSelect | Ready |
| SupportingText | Field Text: Hint or error text wired to its input via aria-describedby. | CSupportingText | Ready |
| Textarea | Flexible Binding: Auto-ID and reactive model management. | CTextarea | Ready |
Wrapping Components
When wrapping a Charpente UI component inside your own, you must forward $attrs so that native HTML attributes (id,
class, disabled, etc.) reach the underlying element instead of landing on your wrapper's root node.
<script setup>
import { CField, CLabel, CInput, CSupportingText } from '@charpente-ui/vue';
defineOptions({
inheritAttrs: false
});
defineProps({
label: String,
error: String
});
const model = defineModel();
</script>
<template>
<CField>
<CLabel>{{ label }}</CLabel>
<CInput v-bind="$attrs" v-model="model"/>
<CSupportingText v-if="error">{{ error }}</CSupportingText>
</CField>
</template>Why this matters: Without inheritAttrs: false, Vue applies fallthrough attributes to the wrapper's root element
instead of the inner component. Adding v-bind="$attrs" on the Charpente component ensures attributes like id,
class, required, or disabled pass all the way through to the native HTML element. And since CField works by
provide/inject, the label, input and error stay linked across your wrapper's boundary — accessibility included.
This pattern works the same way for all Charpente components (CInput, CCheckbox, CRadio, etc.).
