@eldrajs/ui

Accessible Vue 3 core components for Eldra storefronts: buttons, forms, selects, cards, overlays and navigation, themed through CSS design tokens.
Install
npm install @eldrajs/ui
Latest Version:0.2.7
Vue:3
License:MIT
Last Updated:Oct 27, 2026
Links: GitHub  ·  npm
Maintainer: nokkvi

@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 :root with 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 @theme block 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, the eldra-focus ring, 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 the font shorthand, and read their weight as var(--tw-font-weight, <token>), the declaration Tailwind writes for its own --text-*--font-weight. So a font-medium/font-semibold beside one wins whatever order the two compile in, and a tabular-nums beside one is no longer reset. A base-layer @apply font-* sets --tw-font-weight as 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 of tailwind.css plus 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.css without 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.css sets every h1–h6 to the surrounding size and weight, every ul/ol to no bullets and no indent, every a to the surrounding colour with no underline, every p and heading to no margin, and every button to 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.css carries 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. Use style.css when the page is built from this package; use components.css when the page is the site's own. src/styles/__tests__/componentsCss.browser.spec.ts renders every component family under both, the overlays open, and requires identical pixels.

    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. The 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.

    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: … } or button { 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.

  1. 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. :root rather than a wrapper, for the popup panels: Select, MultiSelect and SearchBar render theirs through a <Teleport> to body (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.

  2. 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
    Accordion none — the top divider is a stock border-t, no per-component variable
    AccordionItem --eldra-accordion-title-line (default 1.4) — the label's line ratio, no token of its own
    Avatar --eldra-avatar-initials-size-{sm,md,lg,xl} (defaults 0.76rem/0.95rem/1.33rem/2.28rem, "38% of diameter" — no token of its own), --eldra-avatar-initials-tracking (default 0.02em), --eldra-avatar-icon-size-{sm,md,lg,xl} (defaults 0.857rem/1.071rem/1.5rem/2.571rem, the spec's own lg number scaled proportionally to the other three diameters)
    AvatarGroup none — the overlap and stack order are plain Tailwind, no per-component variable
    Badge --eldra-badge-line-height (default 1) — the badge text's line ratio, no token of its own
    Breadcrumb none — the collapse threshold reads --container-tablet (Container's own 48rem gutter breakpoint), and the link/ellipsis boxes reuse target-min/eldra-link-radius/radius-sm, no per-component variable of its own
    Button --eldra-button-radius (default var(--eldra-radius-md)), --eldra-button-line-height (default 1.2), --eldra-button-font-size-lg (default 1.0625rem, the one button size with no type token of its own)
    ButtonGroup none — reads only the shared tokens from layer 1
    Carousel --eldra-carousel-per-view-base/-md/-lg (bound as an inline style on the track by carouselPerViewStyle, resolved into --eldra-carousel-per-view per container-query breakpoint by the eldra-carousel-track utility, no default of its own — a slide with none set stays full width) and --eldra-carousel-gap (default var(--eldra-space-4)), both read by the eldra-carousel-slide utility's width formula; --eldra-carousel-dot-size (default 0.5rem) and --eldra-carousel-dot-ring (default 1.5px), read by eldra-carousel-dot; --eldra-carousel-bleed (default 0px) — 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, each max()-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 through classes.track, never a px-*/-mx-*/scroll-px-* utility of your own — those replace the reservation on that axis instead of composing with it)
    Checkbox --eldra-checkbox-radius (default var(--eldra-radius-sm)), --eldra-checkbox-border-width (default 1.5px), --eldra-checkbox-border-width-invalid (default 2px)
    CheckboxGroup none — reads only the shared tokens from layer 1
    Chip none — the selected/hover fills are color-mix() over shared --eldra-color-* tokens, no per-component variable
    ChipGroup none — reads only the shared tokens from layer 1
    Container none — the gutter widths (--eldra-gutter-{mobile,tablet,desktop}) are shared tokens, also read by Section; the three finite width values likewise read the shared --eldra-container-{narrow,content,wide} tokens directly, through Container's own eldra-container-{narrow,content,wide} utilities rather than Tailwind's max-w-narrow/-content/-wide — see the Deviations entry below
    ContentCard --eldra-content-card-title-size (default 1.25rem) and --eldra-content-card-title-line (default 1.3) — the title's own size and line ratio, no token of its own; --eldra-content-card-excerpt-size (default 0.9375rem) and --eldra-content-card-excerpt-line (default 1.5) — the excerpt's own size and line ratio, shared by FeatureCard's body (text-content-card-excerpt in tailwind.css), since both read the same spec number
    CurrencyInput UnitInput's exactly (it renders a UnitInput with isCurrency always on) — see the UnitInput row
    Dialog --eldra-dialog-title-size (default 1.25rem), --eldra-dialog-title-line (default 1.3) — the title's own size/line, no token of its own; --eldra-dialog-width (default 32rem), --eldra-dialog-width-sm (default 24rem) — the panel's own width, always capped at 100vw - 2rem inside the utility itself; --eldra-dialog-max-height (default calc(100vh - 4rem))
    Drawer --eldra-drawer-title-size (default 1.25rem), --eldra-drawer-title-line (default 1.3) — the title's own size/line, no token of its own (the same numbers as Dialog's, but its own variable pair); --eldra-drawer-width (default 28rem) — 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 (default 4rem) — the header row's least height, with --eldra-drawer-header-padding (default var(--eldra-space-4)) — its top and bottom padding, 0px to centre the row's content in exactly that height, such as a site header's; --eldra-drawer-gutter (default 1.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 with viewport-fit=cover
    EditorPlaceholder --eldra-editor-placeholder-border-width (default 1.5px) — the dashed boundary's width, distinct from EmptyState's own stock border (1px)
    EmptyState --eldra-empty-state-title-size (default 1.25rem) and --eldra-empty-state-title-line (default 1.3) — the title's own size and line ratio, no token of its own
    FeatureCard Shares ContentCard's --eldra-content-card-excerpt-size/-line for its own body text (text-content-card-excerpt); its title reads text-h4 directly, no variable of its own
    FieldWrapper --eldra-field-note-line-height (default 1.45) — the line the help and error text share
    FilterPanel --eldra-filter-subheading-tracking (default 0.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 (default 1px) and --eldra-filter-swatch-ring-width (default 1.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 (defaults 2px / 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 row
    FormLayout --eldra-form-summary-radius (default var(--eldra-radius-md)) — the status summary box's corner radius
    Icon none — reads only the shared tokens from layer 1
    Image --eldra-image-hatch-gap (default 0.75rem) — the live "No image" placeholder's diagonal-line repeat distance
    Input --eldra-input-radius (default var(--eldra-radius-md)), --eldra-control-font-size (default 0.9375rem), --eldra-control-font-size-mobile (default 1rem), --eldra-control-line-height (default 1.5rem), --eldra-field-border-width (default 1px)
    Lightbox none 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 as AvatarGroup/ButtonGroup
    Link --eldra-link-radius (default 2px) — the focus ring's corner radius on every variant
    LoadMore none — the status/meter/button sizes all reuse shared tokens (text-body-sm, control-h, text-button-md), no per-component variable
    LogoItem --eldra-logo-image-max-height (default 2.5rem), --eldra-logo-image-max-width (default 9rem) — the logo image's contain box; --eldra-logo-wordmark-size (default 1.25rem) — the wordmark fallback's font size (its weight, line-height and letter-spacing reuse h2/h3 tokens directly, see text-logo-wordmark in tailwind.css)
    NavMenu --eldra-nav-menu-offset (default var(--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 (defaults 14rem/24rem, capped at 100vw - 2rem) — a dropdown panel's width; --eldra-nav-menu-column-min (default 12rem) — 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 (default 3.5rem) — every top-level row in the drawer, link, accordion row or section heading; --eldra-nav-menu-drawer-indent (default var(--eldra-space-4)) — how far a panel's links sit in from their row. The drawer itself and the badges are Drawer and Badge, each restyled through its own row
    MultiSelect everything Select reads, plus --eldra-select-pill-line (the "+N" pill's line box) and --eldra-checkbox-radius/--eldra-checkbox-border-width, shared with Checkbox so a consumer restyles both at once
    Pagination none — every size (the 2.5rem page link/control-h previous-next, the target-touch compact arrows, the 0.9375rem text-control compact status) reuses a shared token or a shared arbitrary spacing multiple, no per-component variable
    Popover --eldra-z-popover (default 30), shared with the other popover panels — no per-component variable of its own: it has no fixed size to clamp, unlike Select's panel
    Price --eldra-price-current-sm-size (default 0.9375rem), --eldra-price-current-lg-size (default 1.5rem) — only sm/lg need one: md's current price inherits the surrounding text, and compareAt/from/unit scale off whichever size the root sets (0.9em, a literal ratio the spec itself gives, and a fixed 0.8125rem), so neither needs a variable of its own
    ProductCard none of its own — it composes Image/Badge/StockBadge/Price/Button, each restyled through its own row above
    QuantityStepper --eldra-stepper-radius (default var(--eldra-radius-md))
    RadioGroup --eldra-radio-card-border-width (default 1px) and --eldra-radio-card-radius (default radius-md) for the card boundary; --eldra-checkbox-border-width/-invalid for the radio circle itself, shared with Checkbox
    RangeSlider --eldra-range-thumb-size (default 1.25rem), --eldra-range-thumb-border-width (default 1.5px), --eldra-range-thumb-halo (default 0.25rem, the hover/dragging ring drawn outside the thumb) and --eldra-range-track-height (default 0.375rem), read by the eldra-range-thumb/-track/-gutter utilities — 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 it
    Rating none — the half-star overlay (eldra-rating-half) is a fixed clip-path, no per-component variable
    SearchBar --eldra-search-panel-max-height (default 32rem, clamped to 70vh), --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 left below the field, bottom left above it)
    SearchModal --eldra-search-modal-width (default 40rem), --eldra-search-modal-max-height (default 40rem) — 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 (default 1.0625rem), --eldra-search-modal-foot-size/-line (defaults 0.75rem/1.4), --eldra-search-modal-kbd-size (default 0.6875rem) — none with a token of their own; plus everything SearchBar's reused SearchResultsPanel reads (--eldra-search-text-line, --eldra-search-empty-line, --eldra-control-line-height)
    Section none — the vertical rhythm (--eldra-section-{sm,md,lg}) and the gutters it shares with Container are shared tokens
    Select --eldra-select-panel-max-height (default 20rem), --eldra-select-panel-max-width (default 22rem, clamped to 90vw), --eldra-z-popover (default 30), --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)
    SiteForm none of its own: it composes FormLayout, FieldWrapper, Input, Textarea, Select, Checkbox and Button, each restyled through its own row; its classes reach their parts (see Site forms)
    Skeleton none — width sets an inline style directly (see the Deviations entry), no CSS variable involved
    StockBadge --eldra-stock-status-line (default 1.4) — the status line text's line ratio, no token of its own
    Switch --eldra-switch-radius (default var(--eldra-radius-full)), --eldra-switch-track-border-width (default 1.5px), --eldra-switch-thumb-offset (default 0.1875rem, the thumb's rest inset from the track's start edge)
    Tab none — reads only the shared tokens from layer 1, plus VariantPicker's text-variant-pill type style (no per-component variable of its own; see that component's row)
    TabPanel none — reads only the shared tokens from layer 1
    TablerIcon none — reads only the shared tokens from layer 1, the same as Icon
    Tabs none — reads only the shared tokens from layer 1; the eldra-scrollbar-hide utility 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 all calc(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 through classes.list only if it is wider than that reach — a px-*/-mx-*/scroll-px-* utility of your own replaces the reservation on that axis rather than composing with it, the way --eldra-carousel-bleed does for the carousel

Related Packages

@eldrajs/vue

Vue 3 components for Eldra storefronts: the RichText renderer for CMS content, w...

1,112 0
@3df-spa/ui

Vue 3 component library — TypeScript strict, Tailwind CSS v4, accessible, zero-r...

1,276
tailwind-config-viewer

View your Tailwind CSS config file...visually!

19,524,150 2,211
@addf/addf-vue-base-components

https://docs.github.com/en/get-started/writing-on-github/getting-started-with-wr...

2,166 0
@alexochihua/exos-library-components

Componentes base Vue 3 y Tailwind

14,674

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
Pre-releases (1)
Version Released License
0.0.0-stage