@lovince/ui-vue
Accessible, unstyled Vue 3 components for Lovince UI. Every component is a
state machine from @lovince/ui-headless; its browser
effects run through @lovince/ui-dom. This package subscribes to
the machine, feeds props in, and renders what connect() returns. It holds no
behaviour of its own.
Status:
0.5.0, experimental. The API may change before1.0.
bun add @lovince/ui-vue
Requires Vue 3.5+ (for SSR-stable useId). No CSS ships with the package. Style
it with classes and the data-* state attributes, or use
@lovince/themes for tokens.
Every component export carries the Lv prefix (LvButton, LvDialogRoot), so
names never clash with your own components or native elements.
Components
| Group | Component | Parts |
|---|---|---|
| Primitives | Text, Heading, Label, Link, Code, Kbd, Box, AspectRatio, VisuallyHidden, Separator | |
| Icon | LvIcon, from the separate @lovince/ui-vue/icon entry | |
| Disclosure | Disclosure | LvDisclosureRoot, LvDisclosureTrigger, LvDisclosureContent |
| Accordion | LvAccordionRoot, LvAccordionItem, LvAccordionHeader, LvAccordionTrigger, LvAccordionContent | |
| Tabs | LvTabsRoot, LvTabsList, LvTabsTrigger, LvTabsContent | |
| Overlays | Dialog | LvDialogRoot, LvDialogTrigger, LvDialogPortal, LvDialogOverlay, LvDialogContent, LvDialogTitle, LvDialogDescription, LvDialogClose |
| Lightbox | LvLightboxRoot, LvLightboxTrigger, LvLightboxPortal, LvLightboxOverlay, LvLightboxContent, LvLightboxTitle, LvLightboxItem, LvLightboxPrevious, LvLightboxNext, LvLightboxCounter, LvLightboxClose | |
| Popover | LvPopoverRoot, LvPopoverTrigger, LvPopoverAnchor, LvPopoverPortal, LvPopoverContent, LvPopoverClose | |
| Tooltip | LvTooltipRoot, LvTooltipTrigger, LvTooltipPortal, LvTooltipContent | |
| Hover card | LvHoverCardRoot, LvHoverCardTrigger, LvHoverCardPortal, LvHoverCardContent | |
| Menu | LvDropdownMenuRoot, LvDropdownMenuTrigger, LvDropdownMenuContent, LvDropdownMenuItem, LvDropdownMenuCheckboxItem, LvDropdownMenuRadioGroup, LvDropdownMenuRadioItem, LvDropdownMenuItemIndicator, LvDropdownMenuLabel, LvDropdownMenuGroup, LvDropdownMenuSeparator, LvContextMenuTrigger | |
| Navigation menu | LvNavigationMenuRoot, LvNavigationMenuList, LvNavigationMenuItem, LvNavigationMenuTrigger, LvNavigationMenuContent, LvNavigationMenuLink | |
| Toast | LvToastViewport, LvToastRoot, LvToastTitle, LvToastDescription, LvToastAction, LvToastClose, plus toast() | |
| Choice | Checkbox, Switch | LvCheckboxRoot, LvCheckboxIndicator, LvSwitchRoot, LvSwitchThumb |
| Radio group | LvRadioGroupRoot, LvRadioGroupItem, LvRadioGroupIndicator | |
| Toggle, toggle group | LvToggleRoot, LvToggleGroupRoot, LvToggleGroupItem | |
| Select | LvSelectRoot, LvSelectTrigger, LvSelectValue, LvSelectContent, LvSelectItem, LvSelectItemText, LvSelectItemIndicator, LvSelectGroup, LvSelectLabel, LvSelectSeparator | |
| Combobox | LvComboboxRoot, LvComboboxAnchor, LvComboboxInput, LvComboboxTrigger, LvComboboxClear, LvComboboxContent, LvComboboxItem, LvComboboxItemIndicator, LvComboboxEmpty | |
| Command palette | LvCommandRoot, LvCommandInput, LvCommandList, LvCommandGroup, LvCommandItem, LvCommandEmpty, LvCommandSeparator | |
| Emoji picker | LvEmojiPickerRoot, LvEmojiPickerSearch, LvEmojiPickerList, LvEmojiPickerGroup, LvEmojiPickerItem, LvEmojiPickerNav… | |
| Input | Slider | LvSliderRoot, LvSliderTrack, LvSliderRange, LvSliderThumb |
| Angle Slider | LvAngleSliderRoot, LvAngleSliderTrack, LvAngleSliderMarkers, LvAngleSliderIndicator, LvAngleSliderThumb, LvAngleSliderValue | |
| Number field | LvNumberFieldRoot, LvNumberFieldInput, LvNumberFieldIncrement, LvNumberFieldDecrement | |
| PIN input | LvPinInputRoot, LvPinInputInput | |
| Calendar | LvCalendarRoot, LvCalendarHeading, LvCalendarPrev, LvCalendarNext, LvCalendarGrid, LvCalendarHeadCell, LvCalendarCell, LvCalendarDay | |
| Forms | Field, Input, Textarea, Form, TagsInput, FileUpload, TimeField, PhoneInput | LvFieldRoot, LvFieldLabel, LvTagsInputRoot, LvTagsInputInput, LvTagsInputItem, LvTagsInputRemove, LvFileUploadRoot, LvFileUploadInput, LvFileUploadItem, LvFileUploadRemove, LvTimeFieldRoot, LvTimeFieldInput, LvPhoneInputRoot, LvPhoneInputInput, LvPhoneInputCountrySelect |
| Clock Picker | LvClockPickerRoot, LvClockPickerViewTrigger, LvClockPickerMeridiem, LvClockPickerDial, LvClockPickerHand | |
| Layout | ScrollArea | LvScrollAreaRoot, LvScrollAreaViewport, LvScrollAreaScrollbar, LvScrollAreaThumb |
| Splitter | LvSplitterGroup, LvSplitterPanel, LvSplitterHandle | |
| Tree | LvTreeRoot, LvTreeItem, LvTreeGroup | |
| Carousel | LvCarouselRoot, LvCarouselViewport, LvCarouselSlide, LvCarouselPrevious, LvCarouselNext, LvCarouselIndicators, LvCarouselIndicator | |
| Sortable | LvSortableRoot, LvSortableItem, LvSortableHandle | |
| Section | LvSectionRoot, LvSectionHeader, LvSectionEyebrow, LvSectionTitle, LvSectionDescription, LvSectionContent | |
| Display | Avatar, Progress | LvAvatarRoot, LvAvatarImage, LvAvatarFallback, LvProgressRoot, LvProgressIndicator |
| Gauge, Countdown | LvGauge, LvCountdownRoot, LvCountdownSegment | |
| Table | LvTableContainer, LvTableRoot, LvTableCaption, LvTableHeader, LvTableBody, LvTableFooter, LvTableRow, LvTableHead, LvTableCell, plus LvDataTable |
Primitives
The primitives provide semantic HTML and stable styling hooks without imposing
CSS. LvText and LvHeading expose a data-text-role matching the typography
roles in @lovince/tokens; heading level controls semantics independently from
visual size.
<script setup lang="ts">
import { LvAspectRatio, LvCode, LvHeading, LvKbd, LvLabel, LvLink, LvSeparator, LvText, LvVisuallyHidden } from '@lovince/ui-vue'
</script>
<template>
<LvHeading :level="1" size="display-md">Workspace</LvHeading>
<LvText variant="body-md">Manage projects and members.</LvText>
<LvLabel for="query">Search</LvLabel>
<LvLink href="/guide">Read the guide</LvLink>
<LvCode>bun test</LvCode>
<LvKbd>Ctrl+K</LvKbd>
<LvAspectRatio :ratio="16 / 9">
<img src="/product.webp" alt="Product detail" />
</LvAspectRatio>
<LvVisuallyHidden>Search projects</LvVisuallyHidden>
<LvSeparator :decorative="false" />
</template>
LvBox, LvAspectRatio, LvText, LvHeading, LvLabel, LvLink, LvCode, LvKbd, and
LvVisuallyHidden support as and asChild. LvAspectRatio defaults to a 1:1
frame; set ratio to a positive finite number such as 16 / 9 to size its
content region. LvLink defaults to an anchor and leaves navigation attributes
such as href, target, and rel under the caller's control. LvCode and
LvKbd default to their corresponding semantic elements. When LvHeading
renders a non-heading element, it keeps its semantic level with
role="heading" and aria-level. LvVisuallyHidden applies the minimal inline
clipping styles needed to remain available to assistive technology. A
non-decorative LvSeparator exposes separator semantics and its orientation;
decorative separators are hidden from the accessibility tree.
Button
LvButton is the one part that names a design decision: variant
(solid, subtle, outline, ghost, danger) and size (sm, md, lg,
icon) become data-variant and data-size, and your stylesheet decides what
each of them looks like. What the component owns is the markup a hand-written
button gets wrong.
<script setup lang="ts">
import { LvButton } from '@lovince/ui-vue'
</script>
<template>
<LvButton @click="save">Save</LvButton>
<LvButton type="submit" variant="solid" size="lg" :loading="saving">Save</LvButton>
<LvButton as="a" href="/settings" variant="ghost" size="sm">Settings</LvButton>
<LvButton :disabled="!valid" variant="outline">Publish</LvButton>
</template>
typedefaults tobutton, so a button inside a form never submits by accident; ask forsubmitorresetexplicitly.disabledsets the native attribute when the element is a realbutton, andaria-disabledwhen it is not, since an anchor cannot be disabled.loadingsetsaria-busyand keeps the control in the tab order — a request started from the keyboard stays visible to the keyboard — while swallowing clicks so the handler cannot fire twice.- Both states also appear as
data-disabledanddata-loading, and neither runs theonClickit was given. WithasChildthe element is yours, click handling included; the state attributes still arrive.
Icon
LvIcon draws a glyph from @lovince/icons on the registry's shared 24-unit
canvas. It is its own entry because the icon registry is an optional peer:
importing @lovince/ui-vue loads no icon data, and an app that brings its own
icons needs nothing extra.
<script setup lang="ts">
import { LvIcon } from '@lovince/ui-vue/icon'
</script>
<template>
<LvIcon name="arrowRight" />
<LvIcon name="success" :size="16" :stroke-width="1.5" label="Completed" />
</template>
An icon is decorative — aria-hidden="true" — unless it is given a label,
which makes it role="img" with that accessible name. absoluteStrokeWidth
keeps the rendered stroke thickness constant as size grows; fill takes 0 to
1 for a partial wash; rotate and flip turn about the canvas centre. Size and
stroke fall back to the registry's own defaults, and an unknown name renders an
empty box rather than breaking layout.
Forms
LvFieldRoot provides a stable input id and wires the label, description, and
error into LvInput or LvTextarea. Description and error copy can be passed as
props or named slots. Set invalid to expose an error slot, or pass error to
mark the field invalid automatically. required and disabled are propagated
to the field control; each control can override them.
<script setup lang="ts">
import { LvFieldLabel, LvFieldRoot, LvForm, LvInput, LvTextarea } from '@lovince/ui-vue'
</script>
<template>
<LvForm @submit.prevent="save">
<LvFieldRoot required description="Use your work email.">
<LvFieldLabel>Email</LvFieldLabel>
<LvInput type="email" name="email" autocomplete="email" />
</LvFieldRoot>
<LvFieldRoot :invalid="Boolean(message)" :error="message">
<LvFieldLabel>Project summary</LvFieldLabel>
<LvTextarea name="summary" />
</LvFieldRoot>
<button type="submit">Save</button>
</LvForm>
</template>
LvInput and LvTextarea support modelValue / defaultValue and emit
update:modelValue. LvTextarea uses content-based field sizing where the
browser supports it and retains a native rows fallback. LvForm is a native
form wrapper: browser constraint validation and submit events remain in
control. Set submitting to expose aria-busy and data-submitting, or
noValidate when validation is handled elsewhere.
TagsInput and FileUpload expose their current values and rejected entries
through the root's default slot. Their pure rules and rejection reasons live in
@lovince/ui-headless.
<script setup lang="ts">
import {
LvFileUploadInput,
LvFileUploadItem,
LvFileUploadRemove,
LvFileUploadRoot,
LvTagsInputInput,
LvTagsInputItem,
LvTagsInputRemove,
LvTagsInputRoot,
} from '@lovince/ui-vue'
</script>
<LvTagsInputRoot v-model="emails" :max="5" :validate="isEmail" name="emails">
<LvTagsInputItem v-for="(email, index) in emails" :key="email" :index="index">
{{ email }} <LvTagsInputRemove />
</LvTagsInputItem>
<LvTagsInputInput label="Add email address" />
</LvTagsInputRoot>
<LvFileUploadRoot v-model="files" accept="image/*,.pdf" :max-files="4" :max-size="5000000">
<LvFileUploadInput />
<LvFileUploadItem v-for="(file, index) in files" :key="file.name" :index="index">
<template #default="{ file, size }">
{{ file.name }} ({{ size }}) <LvFileUploadRemove />
</template>
</LvFileUploadItem>
</LvFileUploadRoot>
Tags are created with Enter or a configured separator; pasted separator-delimited
text is split, and duplicate, invalid, and over-limit values emit rejected.
File selection and dropping share the same type, per-file size, count, and total
size checks. LvFileUploadRoot emits accepted File[] values and rejected files
with their reason; LvFileUploadItem also exposes kind, extension, and localized
size to its slot.
TimeField accepts human-entered times and stores normalized HH:mm values.
It validates optional min and max bounds on blur or Enter, and exposes
invalid when parsing or range validation fails. PhoneInput formats a
national number on blur, recognizes supported international prefixes, and
emits E.164 values. It includes a country selector and accepts an optional
custom country list.
<LvTimeFieldRoot v-model="appointment" min="09:00" max="17:00" required name="appointment">
<LvTimeFieldInput label="Appointment time" />
</LvTimeFieldRoot>
<LvPhoneInputRoot v-model="phone" country="KE" name="phone">
<LvPhoneInputCountrySelect />
<LvPhoneInputInput label="Mobile number" />
</LvPhoneInputRoot>
ScrollArea keeps scrolling native while exposing custom, keyboard-operable
scrollbar parts. Size the root from its container, then render the viewport and
the scrollbar/ thumb parts inside it. The root slot reports current overflow
metrics; style the track and thumb with their data-scroll-area-* attributes.
The viewport keeps its native scroll behavior and uses scrollbar-width: none
where that property is supported.
<LvScrollAreaRoot class="results">
<LvScrollAreaViewport>
<div v-for="result in results" :key="result.id">{{ result.title }}</div>
</LvScrollAreaViewport>
<LvScrollAreaScrollbar>
<LvScrollAreaThumb />
</LvScrollAreaScrollbar>
</LvScrollAreaRoot>
Composables: useDataTable, useVirtualList, useInView, useScrollEdges,
useActiveSection, useClipboard, plus the building blocks below.
The package is sideEffects: false. An app importing only LvDialogRoot ships
no menu, select or table code, and a test enforces this.
Shared conventions
asandasChild. Every part renders a sensible element and takesasto change it. WithasChild, the part renders your single child instead, merging its attributes and handlers in.- Controlled or not. Leave
v-modeloff and the component owns its state. Bind it and your value is the source of truth. Every accepted change is emitted, and setting a model toundefinedclears it. - Your ids are respected. Give any part an
idand the ARIA references elsewhere follow it. - Exit animations play. Closing content stays mounted, with
data-state="closed", until its CSS animation ends. With no animation, or reduced motion, it unmounts at once. - Hydration-safe portals. Overlays teleport only after mount, so server and client renders always agree.
- Cancellable dismissal. Overlays emit
escapeKeyDownandpointerDownOutside(Dialog alsofocusOutside) before closing. Callevent.preventDefault()to keep them open.
Lovince-specific behaviour
Two behaviours differ from the Doitrix components these were ported from:
- Disclosure content and tab panels stay in the DOM with
hiddenwhile closed, so find-in-page andaria-controlskeep working. - Tabs select the first enabled tab when no value is given, including during server rendering.
Modal dialogs also make the rest of the page inert, not just focus-trapped.
Building blocks
Every component in this package is built the same way:
const machine = useMachine(dialogMachine, initialContext, {
controlled: { open: () => props.open }, // a v-model, synced in
onChange: (next, previous) => emit(…), // accepted changes, never echoes of props
beforeSend: dismiss.filter, // e.g. cancellable dismissal
})
const api = computed(() => connectDialog(machine.snapshot.value, machine.send))
// h(Primitive, normalizeProps(api.value.contentProps), slots)
useMachinestarts effects on mount (none on the server), stops them on unmount, keeps a controlled value the source of truth, and provides thequerythatconnectreads the page through. Components never touch the DOM themselves.normalizePropsturns the engine's neutral props (onKeyDown) into Vue's (onKeydown). A React adapter would ship its own, against the same machines.useListEnvreads a list's items from the DOM, in visual order, for machines that navigate lists.Primitive,Slot,Portal,usePresence,useIdWatchhandle rendering concerns:as/asChild, hydration-safe portals, exit animations, consumer ids.
State attributes
| Attribute | Values |
|---|---|
data-state | open / closed, checked / unchecked / indeterminate, active / inactive |
data-disabled, data-readonly | present when set |
data-orientation | horizontal / vertical |
data-side, data-align | where floating content was placed after collision handling |
Floating content carries data-anchor-hidden while its anchor is scrolled out
of view, and exposes --lovince-floating-available-height,
--lovince-floating-available-width and --lovince-anchor-width. Disclosure
content exposes --lovince-collapsible-height and -width for height animations.
Known limitations
- Positioning is the engine's own (see
@lovince/ui-headless): it respects scrolling containers and transformed ancestors, and hides content whose anchor has scrolled away (data-anchor-hidden). Arrows are not built in. - See
@lovince/ui-domforinertand scroll-lock browser caveats.
License
MIT