@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, Radio/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
The components come prebuilt: an app never compiles this package's styles and needs no Tailwind
of its own to use them. Every class a component renders is eldra:-prefixed (eldra:px-4,
eldra:hover:bg-primary), compiled with Tailwind's prefix(eldra), and every theme variable it
reads is --eldra-*. Nothing in the package's CSS uses a name an app's own Tailwind build uses, so
the two never restyle each other's markup.
Five CSS entries ship. Every one starts from the same --eldra-* variables, so none is a different
theme, only a different delivery shape.
-
@eldrajs/ui/style.css— the tokens, Tailwind's preflight and every component's styles, for a page built from this package:@import '@eldrajs/ui/style.css'; -
@eldrajs/ui/components.css—style.csswithout Tailwind's preflight, for a site that has its own stylesheet (or its own Tailwind) and adds components 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. -
@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— for an app that runs Tailwind v4 and wants its own markup on the same design tokens:tokens.css, then a@themeblock mapping Tailwind's namespaces onto those variables (--color-primary: var(--eldra-color-primary),--radius-md,--font-heading,--spacing-*,--shadow-*,--ease-*) and the package's custom utilities (control heights, theeldra-focusring, the type styles, motion and layer utilities). It builds none of the components; import the prebuilt ones beside it.theme.css(the@themeblock) andutilities.css(the utilities) ship on their own too.@import 'tailwindcss'; @import '@eldrajs/ui/tailwind.css'; @import '@eldrajs/ui/components.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.
Overriding a component's classes
The components' styles sit in @layer components, below Tailwind's @layer utilities, and both
style.css and components.css declare the standard order (properties, theme, base, components, utilities), so it holds whichever stylesheet loads first. A class you put on a component, with
class (its outermost element) or a part of classes (an element inside it), is not prefixed and
is added to the component's own classes; it wins by layer, whatever its specificity. Nothing is
merged or removed at runtime: the components bind their classes the plain Vue way, one class per
concern on each element.
<Button class="px-8">Checkout</Button>
<!-- renders class="eldra:control-h eldra:px-4.5 … px-8": px-8 wins -->
- It wins in every state: a plain
bg-red-600on aButtonreplaces its hover and active fills too, because a utilities-layer rule beats any components-layer one. Write the states you want (bg-red-600 hover:bg-red-700). - Your app's own Tailwind must generate the class (it is in your source, so it does). Without a
Tailwind of your own, any CSS of yours that is unlayered or in a layer declared after
componentswins the same way.
The data-eldra-ui marker
Every component's outermost element carries data-eldra-ui, and so does every element a component
renders outside it: a Select or MultiSelect panel, a SearchBar results panel, a Popover
panel, a Tooltip bubble and the Toaster region, each teleported out of its component.
components.css's reset reaches exactly those elements and the data-part elements inside them,
:where([data-eldra-ui], [data-eldra-ui] [data-part]), at zero specificity. data-part alone 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 put data-part on 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 own data-part that the site
passes into a component's slot (an Ark UI widget inside a Dialog, say) is a data-part element
inside a marked root, so it gets the reset as well.
A site's unlayered element rules beat the package's layered ones regardless of specificity: a
global a { color: … } or button { font: … } restyles those elements inside the components too.
Scope such rules (.prose a) or put them in a layer.
Theming a subtree
provideEldraScope(name), called in the setup() of a wrapper or an app root, makes every
component below it render data-eldra-scope="<name>" on its root and on every part it teleports
(panels, bubbles, dialogs, toasts), so the --eldra-* variables can be set for those components
alone, teleported parts included:
[data-eldra-scope='studio'] {
--eldra-color-primary: #0b5cad;
}
Every element holding slot content you passed in also carries data-eldra-slot (a Button's
label, a Dialog's title, body and footer, a Popover panel…), whenever the slot has content and
never for a fallback, as a hook for your own CSS.
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-border-width(default1px; the height stays the same, and an attachedButtonGroupoverlaps by this width),--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 at the dialog's padded width (the viewport less2rem) inside the utility itself;--eldra-dialog-max-height(defaultnone: a tall panel grows and the dialog scrolls like a page; set it to cap the panel and scroll the body inside it instead); theeldra-dialog-fullscreenutility pins the panel to the viewport edges with the body scrolling between header and footer, for a consumer to put under a viewport variant (classes.panel: 'max-md:eldra-dialog-fullscreen');--eldra-dialog-background(defaultvar(--color-background)),--eldra-dialog-border(defaultvar(--color-border)),--eldra-dialog-text(defaultvar(--color-text), also the close button's hover and press tint),--eldra-dialog-muted(defaultvar(--color-muted), the description) and--eldra-dialog-backdrop(defaultvar(--color-overlay)) — the chrome's own colours, read by the dialog's parts only, so setting them on the<dialog>(class) recolours it without re-theming the slotted content the way redefining--eldra-color-*there wouldDrawer--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=cover;--eldra-drawer-slide(default2rem),--eldra-drawer-enter-duration(default400ms),--eldra-drawer-exit-duration(default260ms) — the panel's slide and fade in and out;--eldra-drawer-swipe-threshold(default24px) — how far a side swipe travels before the panel follows; withmobileSheet,--eldra-drawer-sheet-gap(default3rem),--eldra-drawer-sheet-radius(defaultvar(--eldra-radius-xl)) and--eldra-drawer-handle-color(defaultvar(--eldra-color-border-strong))EditorPlaceholder--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/ButtonGroup