@eldrajs/ui
@eldrajs/ui
Accessible Vue 3 core components for Eldra storefronts, themed through CSS design tokens.
The components are built to
eldra-starter-spec/01-core-components.md (WCAG
2.2 AA). Plan 1 — foundations, actions and forms (Button/ButtonGroup, Link, Input,
Textarea, FieldWrapper, FormLayout, Checkbox/CheckboxGroup, RadioGroup, Switch,
Select/MultiSelect, QuantityStepper, VariantPicker, SearchBar) — is complete, plus
UnitInput and CurrencyInput, which the spec has no equivalent of; display, commerce, layout, overlays, navigation
and feedback components land in the two sub-projects that follow, in the same delivery order the
spec's "Components and delivery order" section lays out. What this package ships that the spec does
not name is listed under
Additions beyond the spec, and where it departs from a property table
it is recorded under Deviations — neither is left for a reader to find.
Install
pnpm add @eldrajs/ui vue
Peer: vue ^3.5. The floor is 3.5, not 3.4: every control's ids come from useId(), which Vue
added in 3.5, so on 3.4 the install succeeds and the first control to mount throws
useId is not a function. vee-validate ^4.12 is an optional peer — only the ./vee-validate
entry needs it, and nothing else in the package imports it.
Styles
Four CSS entries ship; pick the one that matches how the consumer builds CSS. Every one of them
starts from the same --eldra-* variables, so nothing here is a different theme, only a different
delivery shape.
-
@eldrajs/ui/tokens.css— the--eldra-*variables only (colours, radii, spacing, type, motion, z-index, and the one opacity —--eldra-revalidating-opacity), declared on:rootwith the spec's defaults, plus a@media (prefers-reduced-motion: reduce)rule that zeroes every duration variable. -
@eldrajs/ui/tailwind.css—tokens.css, then a Tailwind v4@themeblock mapping its own namespaces onto those variables (--color-primary: var(--eldra-color-primary),--radius-md,--font-heading,--spacing-*,--shadow-*,--ease-*) plus the package's custom utilities (control heights, theeldra-focusring, the type styles, motion and layer utilities) and a@source "../dist"so a consumer's own Tailwind build scans the package's compiled components. For a project that already runs Tailwind v4,main.css(or equivalent) becomes:@import 'tailwindcss'; @import '@eldrajs/ui/tailwind.css';The type styles (
text-body,text-h2, …) set longhands, never thefontshorthand, and read their weight asvar(--tw-font-weight, <token>), the declaration Tailwind writes for its own--text-*--font-weight. So afont-medium/font-semiboldbeside one wins whatever order the two compile in, and atabular-numsbeside one is no longer reset. A base-layer@apply font-*sets--tw-font-weightas well, so it reaches a type style on the same element. Line height and tracking are plain longhands on purpose: the starter's own base rule (h1–h4@apply leading-[1.1] tracking-tight) would otherwise override every heading's type style. -
@eldrajs/ui/style.css— the package's own compiled build oftailwind.cssplus every component's utilities, for a consumer with no Tailwind build of their own:@import '@eldrajs/ui/style.css';This is also what a project whose own Tailwind build cannot share this package's utility prefix (a
ui:-prefixed theme, for example) reaches for. -
@eldrajs/ui/components.css—style.csswithout Tailwind's preflight, for a site that has its own stylesheet and adds a few components (a header menu, a cart drawer) to it:@import '@eldrajs/ui/components.css';Preflight is a reset for the whole page:
style.csssets everyh1–h6to the surrounding size and weight, everyul/olto no bullets and no indent, everyato the surrounding colour with no underline, everypand heading to no margin, and everybuttonto no border or fill. It sits in@layer base, so a site's own rules still win where they exist, but anything the site left to the browser — rich text from the CMS, most often — loses its defaults.components.csscarries the same theme and the same utilities, byte for byte, and narrows the reset to the elements the components render themselves, so the site's own elements keep the browser's defaults and anything passed into a component's slot keeps the site's styling. Usestyle.csswhen the page is built from this package; usecomponents.csswhen the page is the site's own.src/styles/__tests__/componentsCss.browser.spec.tsrenders every component family under both, the overlays open, and requires identical pixels.The
data-eldra-uimarker. Every component's outermost element carriesdata-eldra-ui, and so does every element a component renders outside it: aSelectorMultiSelectpanel, aSearchBarresults panel, aPopoverpanel, aTooltipbubble and theToasterregion, each teleported out of its component. The reset reaches exactly those elements and thedata-partelements inside them,:where([data-eldra-ui], [data-eldra-ui] [data-part]), at zero specificity.data-partalone is not enough, because it is not this package's name: a site's own<h2 data-part="title">, or any Ark UI or Zag component (which putdata-parton every element they render), keeps the browser's defaults. The attribute is public and stable, so a site can use it as a hook too (a:not([data-eldra-ui], [data-eldra-ui] *)keeps a site's global link rule out of the components). One case stays inside the scope: an element carrying its owndata-partthat the site passes into a component's slot (an Ark UI widget inside aDialog, say) is adata-partelement inside a marked root, so it gets the reset as well.Either way, the package's rules are in cascade layers and a site's unlayered rules beat them regardless of specificity: a global
a { color: … }orbutton { font: … }in the site's own CSS restyles those elements inside the components too. Scope such rules (.prose a) or put the site's stylesheet in a layer declared before the package's.
Fonts
The type tokens' defaults are --eldra-font-heading: "Bricolage Grotesque", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif and --eldra-font-body: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif. The package does not load either
font — it only names them — so a consumer either loads Bricolage Grotesque and Instrument Sans
themselves (self-hosted, or a Google Fonts <link>/@import) or overrides --eldra-font-heading
and --eldra-font-body with their own stack. Until one or the other happens, text renders in the
fallback system stack, which is legible but not the spec's intended look.
Customisation
Every component supports all five of these; none hard-codes anything a store might want to change.
-
Tokens. Every colour, radius, height, spacing step, font, duration, easing and z-index a component uses resolves to a
--eldra-*variable from Styles above — set one on any ancestor (typically:root) and every component reading it follows.:rootrather than a wrapper, for the popup panels:Select,MultiSelectandSearchBarrender theirs through a<Teleport>tobody(see Layering), so a variable set on a<div>around the control is not an ancestor of the panel any more and is not inherited by it. -
Per-component CSS variables, declared on the component's own root with a token-derived default, for the handful of values that are not shared design tokens — a size with no dedicated token, a component-specific corner radius. Each one is documented on the component's own Storybook docs page; the variables currently declared:
Component CSS variables Accordionnone — the top divider is a stock border-t, no per-component variableAccordionItem--eldra-accordion-title-line(default1.4) — the label's line ratio, no token of its ownAvatar--eldra-avatar-initials-size-{sm,md,lg,xl}(defaults0.76rem/0.95rem/1.33rem/2.28rem, "38% of diameter" — no token of its own),--eldra-avatar-initials-tracking(default0.02em),--eldra-avatar-icon-size-{sm,md,lg,xl}(defaults0.857rem/1.071rem/1.5rem/2.571rem, the spec's own lg number scaled proportionally to the other three diameters)AvatarGroupnone — the overlap and stack order are plain Tailwind, no per-component variable Badge--eldra-badge-line-height(default1) — the badge text's line ratio, no token of its ownBreadcrumbnone — the collapse threshold reads --container-tablet(Container's own 48rem gutter breakpoint), and the link/ellipsis boxes reusetarget-min/eldra-link-radius/radius-sm, no per-component variable of its ownButton--eldra-button-radius(defaultvar(--eldra-radius-md)),--eldra-button-line-height(default1.2),--eldra-button-font-size-lg(default1.0625rem, the one button size with no type token of its own)ButtonGroupnone — reads only the shared tokens from layer 1 Carousel--eldra-carousel-per-view-base/-md/-lg(bound as an inline style on the track bycarouselPerViewStyle, resolved into--eldra-carousel-per-viewper container-query breakpoint by theeldra-carousel-trackutility, no default of its own — a slide with none set stays full width) and--eldra-carousel-gap(defaultvar(--eldra-space-4)), both read by theeldra-carousel-slideutility's width formula;--eldra-carousel-dot-size(default0.5rem) and--eldra-carousel-dot-ring(default1.5px), read byeldra-carousel-dot;--eldra-carousel-bleed(default0px) — the inline gutter the track bleeds out to, as its own padding, the negative margin that cancels that padding and its scroll padding at once, eachmax()-ed with the focus ring's reach so a bleed never costs the ring the room the track reserves for it (set it per breakpoint throughclasses.track, never apx-*/-mx-*/scroll-px-*utility of your own — those replace the reservation on that axis instead of composing with it)Checkbox--eldra-checkbox-radius(defaultvar(--eldra-radius-sm)),--eldra-checkbox-border-width(default1.5px),--eldra-checkbox-border-width-invalid(default2px)CheckboxGroupnone — reads only the shared tokens from layer 1 Chipnone — the selected/hover fills are color-mix()over shared--eldra-color-*tokens, no per-component variableChipGroupnone — reads only the shared tokens from layer 1 Containernone — the gutter widths ( --eldra-gutter-{mobile,tablet,desktop}) are shared tokens, also read bySection; the three finitewidthvalues likewise read the shared--eldra-container-{narrow,content,wide}tokens directly, throughContainer's owneldra-container-{narrow,content,wide}utilities rather than Tailwind'smax-w-narrow/-content/-wide— see the Deviations entry belowContentCard--eldra-content-card-title-size(default1.25rem) and--eldra-content-card-title-line(default1.3) — the title's own size and line ratio, no token of its own;--eldra-content-card-excerpt-size(default0.9375rem) and--eldra-content-card-excerpt-line(default1.5) — the excerpt's own size and line ratio, shared byFeatureCard'sbody(text-content-card-excerptintailwind.css), since both read the same spec numberCurrencyInputUnitInput's exactly (it renders aUnitInputwithisCurrencyalways on) — see theUnitInputrowDialog--eldra-dialog-title-size(default1.25rem),--eldra-dialog-title-line(default1.3) — the title's own size/line, no token of its own;--eldra-dialog-width(default32rem),--eldra-dialog-width-sm(default24rem) — the panel's own width, always capped at100vw - 2reminside the utility itself;--eldra-dialog-max-height(defaultcalc(100vh - 4rem))Drawer--eldra-drawer-title-size(default1.25rem),--eldra-drawer-title-line(default1.3) — the title's own size/line, no token of its own (the same numbers asDialog's, but its own variable pair);--eldra-drawer-width(default28rem) — the panel's own maximum width, capped at the viewport inside the utility itself, which also carries the full-screen-below-a-48rem-viewport media query;--eldra-drawer-header-height(default4rem) — the header row's least height, with--eldra-drawer-header-padding(defaultvar(--eldra-space-4)) — its top and bottom padding,0pxto centre the row's content in exactly that height, such as a site header's;--eldra-drawer-gutter(default1.5rem) — the header's, body's and footer's side padding. The device's safe areas (env(safe-area-inset-*)) are added around both, so a full-screen drawer clears a notch and the home indicator on a page withviewport-fit=coverEditorPlaceholder--eldra-editor-placeholder-border-width(default1.5px) — the dashed boundary's width, distinct fromEmptyState's own stockborder(1px)EmptyState--eldra-empty-state-title-size(default1.25rem) and--eldra-empty-state-title-line(default1.3) — the title's own size and line ratio, no token of its ownFeatureCardShares ContentCard's--eldra-content-card-excerpt-size/-linefor its ownbodytext (text-content-card-excerpt); itstitlereadstext-h4directly, no variable of its ownFieldWrapper--eldra-field-note-line-height(default1.45) — the line the help and error text shareFilterPanel--eldra-filter-subheading-tracking(default0.06em, a size-system sub-heading's letter spacing — the overline token's own 0.12em pushes "SOCKS (EU)" onto two lines in a 15rem sidebar),--eldra-filter-swatch-edge-width(default1px) and--eldra-filter-swatch-ring-width(default1.5px) for a colour swatch's edge and the ring it gains when picked, and--eldra-filter-histogram-bar-min/--eldra-filter-histogram-bar-radius(defaults2px/1px) for one histogram bar's minimum height and its two top corners. Everything else is a shared token or comes from the components it composes (Checkbox,Switch,Chip,Badge,Input,Button,CurrencyInput,RangeSlider), each restyled through its own rowFormLayout--eldra-form-summary-radius(defaultvar(--eldra-radius-md)) — the status summary box's corner radiusIconnone — reads only the shared tokens from layer 1 Image--eldra-image-hatch-gap(default0.75rem) — the live "No image" placeholder's diagonal-line repeat distanceInput--eldra-input-radius(defaultvar(--eldra-radius-md)),--eldra-control-font-size(default0.9375rem),--eldra-control-font-size-mobile(default1rem),--eldra-control-line-height(default1.5rem),--eldra-field-border-width(default1px)Lightboxnone of its own — the entrance keyframe ( eldra-lightbox-in) and every size are either shared tokens or literal component-specific classes (px-16,size-11/max-md:size-11), the same "no dedicated variable" shape asAvatarGroup/ButtonGroupLink--eldra-link-radius(default2px) — the focus ring's corner radius on every variantLoadMorenone — the status/meter/button sizes all reuse shared tokens ( text-body-sm,control-h,text-button-md), no per-component variableLogoItem--eldra-logo-image-max-height(default2.5rem),--eldra-logo-image-max-width(default9rem) — the logo image's contain box;--eldra-logo-wordmark-size(default1.25rem) — the wordmark fallback's font size (its weight, line-height and letter-spacing reuseh2/h3tokens directly, seetext-logo-wordmarkintailwind.css)NavMenu--eldra-nav-menu-offset(defaultvar(--eldra-space-2)) — the gap between the bar and a panel, drawn as the panel's own top padding so a hovering pointer never leaves the menu;--eldra-nav-menu-dropdown-min/-max(defaults14rem/24rem, capped at100vw - 2rem) — a dropdown panel's width;--eldra-nav-menu-column-min(default12rem) — the narrowest a mega panel's column gets before the grid wraps (classes.columns: 'grid-cols-5'fixes the count instead).--eldra-nav-menu-drawer-row-height(default3.5rem) — every top-level row in the drawer, link, accordion row or section heading;--eldra-nav-menu-drawer-indent(defaultvar(--eldra-space-4)) — how far a panel's links sit in from their row. The drawer itself and the badges areDrawerandBadge, each restyled through its own rowMultiSelecteverything Selectreads, plus--eldra-select-pill-line(the "+N" pill's line box) and--eldra-checkbox-radius/--eldra-checkbox-border-width, shared withCheckboxso a consumer restyles both at oncePaginationnone — every size (the 2.5rem page link/ control-hprevious-next, thetarget-touchcompact arrows, the 0.9375remtext-controlcompact status) reuses a shared token or a shared arbitrary spacing multiple, no per-component variablePopover--eldra-z-popover(default30), shared with the other popover panels — no per-component variable of its own: it has no fixed size to clamp, unlikeSelect's panelPrice--eldra-price-current-sm-size(default0.9375rem),--eldra-price-current-lg-size(default1.5rem) — onlysm/lgneed one:md's current price inherits the surrounding text, andcompareAt/from/unitscale off whichever size the root sets (0.9em, a literal ratio the spec itself gives, and a fixed0.8125rem), so neither needs a variable of its ownProductCardnone of its own — it composes Image/Badge/StockBadge/Price/Button, each restyled through its own row aboveQuantityStepper--eldra-stepper-radius(defaultvar(--eldra-radius-md))RadioGroup--eldra-radio-card-border-width(default1px) and--eldra-radio-card-radius(defaultradius-md) for the card boundary;--eldra-checkbox-border-width/-invalidfor the radio circle itself, shared withCheckboxRangeSlider--eldra-range-thumb-size(default1.25rem),--eldra-range-thumb-border-width(default1.5px),--eldra-range-thumb-halo(default0.25rem, the hover/dragging ring drawn outside the thumb) and--eldra-range-track-height(default0.375rem), read by theeldra-range-thumb/-track/-gutterutilities — the gutter that keeps a thumb, its focus ring and its pointer target inside the control is the larger of the ring's reach and half the target,calc()-ed from those variables (and switched on the same 48rem container edge the target is), so enlarging any of them moves the reservation with itRatingnone — the half-star overlay ( eldra-rating-half) is a fixedclip-path, no per-component variableSearchBar--eldra-search-panel-max-height(default32rem, clamped to70vh),--eldra-search-text-line,--eldra-search-empty-line,--eldra-search-kbd-line,--eldra-input-radius,--eldra-field-border-width,--eldra-z-popover, and--eldra-popover-origin(set by the panel itself from the placement it resolved to:top leftbelow the field,bottom leftabove it)SearchModal--eldra-search-modal-width(default40rem),--eldra-search-modal-max-height(default40rem) — both clamped to the viewport inside their own utility, which also carries the full-screen-below-a-48rem-viewport media query;--eldra-search-modal-field-size(default1.0625rem),--eldra-search-modal-foot-size/-line(defaults0.75rem/1.4),--eldra-search-modal-kbd-size(default0.6875rem) — none with a token of their own; plus everythingSearchBar's reusedSearchResultsPanelreads (--eldra-search-text-line,--eldra-search-empty-line,--eldra-control-line-height)Sectionnone — the vertical rhythm ( --eldra-section-{sm,md,lg}) and the gutters it shares withContainerare shared tokensSelect--eldra-select-panel-max-height(default20rem),--eldra-select-panel-max-width(default22rem, clamped to90vw),--eldra-z-popover(default30),--eldra-field-radius,--eldra-field-border-width,--eldra-select-group-tracking,--eldra-select-option-line,--eldra-select-swatch-edge, the--eldra-select-match-*trio, and--eldra-popover-origin(top left/bottom left, set by the panel from the placement it resolved to)SiteFormnone of its own: it composes FormLayout,FieldWrapper,Input,Textarea,Select,CheckboxandButton, each restyled through its own row; itsclassesreach their parts (see Site forms)Skeletonnone — widthsets an inline style directly (see the Deviations entry), no CSS variable involvedStockBadge--eldra-stock-status-line(default1.4) — the status line text's line ratio, no token of its ownSwitch--eldra-switch-radius(defaultvar(--eldra-radius-full)),--eldra-switch-track-border-width(default1.5px),--eldra-switch-thumb-offset(default0.1875rem, the thumb's rest inset from the track's start edge)Tabnone — reads only the shared tokens from layer 1, plus VariantPicker'stext-variant-pilltype style (no per-component variable of its own; see that component's row)TabPanelnone — reads only the shared tokens from layer 1 TablerIconnone — reads only the shared tokens from layer 1, the same as IconTabsnone — reads only the shared tokens from layer 1; the eldra-scrollbar-hideutility the tab list uses has no variable of its own. The pills list's own padding, the negative margin that cancels it and its scroll padding are allcalc(var(--eldra-focus-offset) + var(--eldra-focus-width))— the focus ring's own reach, so raising either ring token widens the room the scrolling list keeps for a focused pill's ring instead of letting it clip (the underline variant reserves nothing: its tabs ring inside their own box, per the design spec, and padding that list vertically would push its hairline off the tabs). Set a gutter throughclasses.listonly if it is wider than that reach — apx-*/-mx-*/scroll-px-*utility of your own replaces the reservation on that axis rather than composing with it, the way--eldra-carousel-bleeddoes for the carousel
Related Packages
Vue 3 components for Eldra storefronts: the RichText renderer for CMS content, w...
Vue 3 component library — TypeScript strict, Tailwind CSS v4, accessible, zero-r...
https://docs.github.com/en/get-started/writing-on-github/getting-started-with-wr...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.2.7 | ^3.5.0 | MIT | |
| 0.2.6 | ^3.5.0 | MIT | |
| 0.2.5 | ^3.5.0 | MIT | |
| 0.2.4 | ^3.5.0 | MIT | |
| 0.2.3 | ^3.5.0 | MIT | |
| 0.2.2 | ^3.5.0 | MIT | |
| 0.2.1 | ^3.5.0 | MIT | |
| 0.2.0 | ^3.5.0 | MIT | |
| 0.1.2 | ^3.5.0 | MIT | |
| 0.1.1 | ^3.5.0 | MIT | |
| 0.0.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |