oriUI

List

A vertical list of rows for settings panels, action panels and navigation. Each row reads prepend → label → append: an icon, a label with an optional subtitle, and at the end short meta text such as a shortcut, a chevron into a sub-panel, or a control of your own.

A list is not a menu. A menu is a transient popup of actions with role="menu" and roving arrow keys. A list is part of the page, reached with Tab, and a row can hold a control — a switch, a segmented control — in a static row, which role="menu" forbids.

The examples are organized by layer: the class reference is the standalone @oriui/css layer, and the Framework API is the @oriui/vue components, OriList and OriListItem. Every example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte / plain-HTML usage), Vue; HTML is the default.

When to use

ControlPick it when
ListRows that stay on the page: a settings panel, a panel of actions, a navigation column. Rows may hold controls.
MenuA transient popup opened from a trigger; arrow keys move through it, one action is chosen and it closes.
TabsEntries that switch panels of content in place.
AccordionSections that open and close to reveal their own content.
TableRecords compared across columns: a leaderboard, an invoice, a list of files with sizes and dates.

Classes

A list is a block class plus single-class token utilities — one class repoints one token. A row is a ori-list__row inside an ori-list__item; its parts are the __start, __main and __end cells. The Vue props in Framework API map 1:1 to these.

ClassTypeDescription
ori-listBlockRequired base class, on a ul with role=list. A flex column with a 2px gap and no marker, margin or padding. Bakes the primary accent and the md radius at zero specificity.
ori-list_dividedModifierNo gap; a 1px hairline in the outline color between rows.
ori-color_*Colorprimary · secondary · success · warning · danger · info — the accent of the current row: its tint, its bar and the focus ring. Put it on the ul.
ori-size-radius_*Radiusnone · xs · sm · md · lg · xl · full — the corner radius of a row. Put it on the ul.
ori-list__itemPartThe li that wraps one row.
ori-list__rowPartOne row: a button, an a or a div. A flex row with a 0.75em gap and a 2.5em minimum height. Only a button or a link gets the hover tint and the pointer cursor.
ori-list__prepend · ori-list__appendPartThe leading and the trailing cell; neither shrinks. The prepended cell holds an icon (faded to 0.8); the appended one holds meta text, a chevron or a control.
ori-list__mainPartThe text column; it takes the remaining width and holds the label and the subtitle.
ori-list__label · ori-list__subtitlePartThe label (one line, truncated with an ellipsis) / a second line under it (0.85em, faded to 0.7).
ori-list__meta · ori-list__chevronPartText at the end, such as a shortcut (0.8em, faded to 0.7) / an arrow into a sub-panel (faded to 0.6, mirrored in RTL).
aria-current · disabled · aria-disabledStatereal attributes, not classes — the current row gets a tint and a bar; a disabled row is dimmed to 45% with a not-allowed cursor.

À la carte: the classes above ship in @oriui/css/components/list.css, which inlines the icon styles a row needs. Import a foundation (@oriui/css/base.css or @oriui/css/tokens.css) first — the token utilities (ori-color_*, ori-size-radius_*, …) live there, not in the component file. The full bundle @oriui/css is the default and already carries both; see à-la-carte imports.

Accent primary and radius md are baked in at zero specificity, so a bare ori-list is already complete and one utility class on the <ul> repoints either. Rows inherit the font and are sized in em, so a font-size on the list (or any ancestor) scales them.

Anatomy

text
ul.ori-list                           [role="list"]; ori-list_divided adds hairlines
  li.ori-list__item                   one per row
    button | a | div .ori-list__row   the row element: what it does decides which
      span.ori-list__prepend          icon (optional, aria-hidden)
      span.ori-list__main
        span.ori-list__label          the label
        span.ori-list__subtitle       second line (optional)
      span.ori-list__append           optional
        span.ori-list__meta           a shortcut, or any short text
        svg.ori-list__chevron         arrow into a sub-panel (aria-hidden)
                                      — or a control of your own, in a static row

Basic

Rows that only show information are static: no listener, no href, so each is a plain <div>. meta puts a value or a shortcut at the end.

  • Storage2.4 of 5 GB
  • Last sync2 minutes ago
  • Version1.4.0
html
<!-- accent primary and radius md are the baked defaults; role="list" is stated on purpose, see Accessibility -->
<ul class="ori-list" role="list">
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">Storage</span></span>
            <span class="ori-list__append"><span class="ori-list__meta">2.4 of 5 GB</span></span>
        </div>
    </li>
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">Last sync</span></span>
            <span class="ori-list__append"><span class="ori-list__meta">2 minutes ago</span></span>
        </div>
    </li>
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">Version</span></span>
            <span class="ori-list__append"><span class="ori-list__meta">1.4.0</span></span>
        </div>
    </li>
</ul>
vue
<OriList>
    <OriListItem label="Storage" meta="2.4 of 5 GB" />
    <OriListItem label="Last sync" meta="2 minutes ago" />
    <OriListItem label="Version" meta="1.4.0" />
</OriList>

Icon, subtitle, meta and chevron

The four parts of a row. icon is an SVG path drawn by OriIcon before the label; subtitle is a second line under the label; meta is short text at the end; chevron is an arrow at the end that says the row opens a sub-panel. They compose — the last row uses all four.

  • Icon
  • SubtitleA second line under the label
  • MetaCtrl+S
  • Chevron
  • All fourExport the drawingCtrl+E
html
<ul class="ori-list" role="list">
    <li class="ori-list__item">
        <div class="ori-list__row">
            <!-- the icon is an OriIcon: a decorative <i> carrying an inline SVG -->
            <span class="ori-list__prepend">
                <i class="ori-icon ori-icon_inherit" aria-hidden="true">
                    <svg viewBox="0 0 24 24"><path d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z" /></svg>
                </i>
            </span>
            <span class="ori-list__main"><span class="ori-list__label">Icon</span></span>
        </div>
    </li>
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main">
                <span class="ori-list__label">Description</span>
                <span class="ori-list__subtitle">A second line under the label</span>
            </span>
        </div>
    </li>
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">Hint</span></span>
            <span class="ori-list__append"><span class="ori-list__meta">Ctrl+S</span></span>
        </div>
    </li>
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">Chevron</span></span>
            <span class="ori-list__append">
                <svg class="ori-list__chevron" viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true">
                    <path
                        d="m9 6 6 6-6 6"
                        fill="none"
                        stroke="currentcolor"
                        stroke-width="2"
                        stroke-linecap="round"
                        stroke-linejoin="round"
                    />
                </svg>
            </span>
        </div>
    </li>
    <!-- … and a row with all four: prepend, main (label + subtitle), append (meta + chevron) -->
</ul>
vue
<OriList>
    <OriListItem icon="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z" label="Icon" />
    <OriListItem label="Description" subtitle="A second line under the label" />
    <OriListItem label="Hint" meta="Ctrl+S" />
    <OriListItem label="Chevron" chevron />
    <OriListItem
        icon="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"
        label="All four"
        subtitle="Export the drawing"
        meta="Ctrl+E"
        chevron
    />
</OriList>

The chevron is drawn on the inline-end side and mirrored in RTL — see Right to left.

Divided

divided removes the gap between rows and draws a hairline between them instead — the shape of a settings panel or a stack of actions.

  • ProfileName, photo and bio
  • PrivacyWho can see your drawings
  • Storage2.4 of 5 GB used
html
<ul class="ori-list ori-list_divided" role="list">
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main">
                <span class="ori-list__label">Profile</span>
                <span class="ori-list__subtitle">Name, photo and bio</span>
            </span>
        </div>
    </li>
    <!-- … Privacy, Storage -->
</ul>
vue
<OriList divided>
    <OriListItem label="Profile" subtitle="Name, photo and bio" />
    <OriListItem label="Privacy" subtitle="Who can see your drawings" />
    <OriListItem label="Storage" subtitle="2.4 of 5 GB used" />
</OriList>

Rows that act

The row element follows what the row does. The first rule that matches wins:

You passThe row isUse it for
asthat tag or componentA router link, or any element of your own.
href<a>Navigation.
a click listener (@click)<button type="button">An action: save, export, open a sub-panel.
none of these<div>Information, or a row that holds a control of its own.

Only a static <div> row may hold a control, since a control inside a button or a link is invalid HTML — see Control in a row. Attributes and listeners go to the row element, not to the <li>.

A button row is activated with Enter or Space. Click one below; the last, disabled, is a real disabled button and is skipped by Tab.

Last activated: nothing yet

html
<ul class="ori-list ori-list_divided" role="list">
    <li class="ori-list__item">
        <!-- an action is a real <button type="button"> -->
        <button type="button" class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">New drawing</span></span>
            <span class="ori-list__append"><span class="ori-list__meta">Ctrl+N</span></span>
        </button>
    </li>
    <!-- … Save, Export (with the chevron) -->
    <li class="ori-list__item">
        <!-- the real disabled attribute drives the dimming -->
        <button type="button" class="ori-list__row" disabled>
            <span class="ori-list__main"><span class="ori-list__label">Revert to saved</span></span>
        </button>
    </li>
</ul>
vue
<script setup lang="ts">
import { ref } from 'vue'

const last = ref('')
</script>

<template>
    <OriList divided>
        <OriListItem label="New drawing" meta="Ctrl+N" @click="last = 'New drawing'" />
        <OriListItem label="Save" meta="Ctrl+S" @click="last = 'Save'" />
        <OriListItem label="Export" chevron @click="last = 'Export'" />
        <OriListItem label="Revert to saved" disabled @click="last = 'Revert to saved'" />
    </OriList>
    <p>Last activated: {{ last || 'nothing yet' }}</p>
</template>

Current

current marks the current page or the selected entry. The row is tinted and carries a bar on its inline-start edge in the accent's text tone: the tint alone is too faint to mark it (WCAG 1.4.11), so the bar carries the signal. The bar flips to the right edge in RTL. On the tint the subtitle and the meta text fade less (0.85 instead of 0.7), so they stay AA.

It renders aria-current: "page" on a link — a navigation list — and "true" on any other row. A navigation list goes in a <nav aria-label="…">, which the list does not add itself.

html
<nav aria-label="Related components">
    <ul class="ori-list" role="list">
        <li class="ori-list__item">
            <a class="ori-list__row" href="/components/menu">
                <span class="ori-list__main">
                    <span class="ori-list__label">Menu</span>
                    <span class="ori-list__subtitle">A popup of actions</span>
                </span>
            </a>
        </li>
        <li class="ori-list__item">
            <!-- state is an attribute: aria-current="page" on a link, "true" on any other row -->
            <a class="ori-list__row" href="/components/list" aria-current="page">
                <span class="ori-list__main">
                    <span class="ori-list__label">List</span>
                    <span class="ori-list__subtitle">Rows that stay on the page</span>
                </span>
            </a>
        </li>
        <!-- … Tabs -->
    </ul>
</nav>
vue
<nav aria-label="Related components">
    <OriList>
        <OriListItem label="Menu" href="/components/menu" subtitle="A popup of actions" />
        <OriListItem label="List" href="/components/list" subtitle="Rows that stay on the page" current />
        <OriListItem label="Tabs" href="/components/tabs" subtitle="Switch panels of content" />
    </OriList>
</nav>

A selection that is not navigation — a view, a panel, a tool — is a button row whose current follows the click. Here the current row reads aria-current="true".

Current: layers

html
<ul class="ori-list" role="list">
    <li class="ori-list__item">
        <button type="button" class="ori-list__row" aria-current="true">
            <span class="ori-list__main">
                <span class="ori-list__label">Layers</span>
                <span class="ori-list__subtitle">Stack, reorder and hide</span>
            </span>
        </button>
    </li>
    <li class="ori-list__item">
        <!-- no attribute at all when the row is not current: move aria-current as the selection moves -->
        <button type="button" class="ori-list__row">
            <span class="ori-list__main">
                <span class="ori-list__label">Brushes</span>
                <span class="ori-list__subtitle">Size, opacity and shape</span>
            </span>
        </button>
    </li>
    <!-- … Palette -->
</ul>
vue
<script setup lang="ts">
import { ref } from 'vue'

const views = [
    { value: 'layers', label: 'Layers', subtitle: 'Stack, reorder and hide' },
    { value: 'brushes', label: 'Brushes', subtitle: 'Size, opacity and shape' },
    { value: 'palette', label: 'Palette', subtitle: 'Swatches and recent colors' }
]
const view = ref('layers')
</script>

<template>
    <OriList>
        <OriListItem
            v-for="v in views"
            :key="v.value"
            :label="v.label"
            :subtitle="v.subtitle"
            :current="view === v.value"
            @click="view = v.value"
        />
    </OriList>
</template>

Disabled

disabled on a button row is the real disabled attribute: the row is dimmed and Tab skips it. A link row cannot be disabled natively, so it drops its href and gets aria-disabled="true" — an <a> without an href is neither followed nor focused.

disabled removes the href the row was given and swallows clicks on a link or component row before any listener runs, so a router link passed to as does not navigate either. A static row ignores disabled.

html
<ul class="ori-list" role="list">
    <li class="ori-list__item">
        <a class="ori-list__row" href="/gallery">
            <span class="ori-list__main">
                <span class="ori-list__label">Gallery</span>
                <span class="ori-list__subtitle">Your saved drawings</span>
            </span>
        </a>
    </li>
    <li class="ori-list__item">
        <!-- a link row: no href (so it is not followed or focused) and aria-disabled -->
        <a class="ori-list__row" aria-disabled="true">
            <span class="ori-list__main">
                <span class="ori-list__label">Shared with me</span>
                <span class="ori-list__subtitle">Sign in to see shared drawings</span>
            </span>
        </a>
    </li>
    <li class="ori-list__item">
        <!-- a button row: the real disabled attribute -->
        <button type="button" class="ori-list__row" disabled>
            <span class="ori-list__main"><span class="ori-list__label">Revert to saved</span></span>
        </button>
    </li>
</ul>
vue
<OriList>
    <OriListItem label="Gallery" href="/gallery" subtitle="Your saved drawings" />
    <OriListItem label="Shared with me" href="/shared" subtitle="Sign in to see shared drawings" disabled />
    <!-- a button row: the real disabled attribute -->
    <OriListItem label="Revert to saved" disabled @click="revert" />
</OriList>

Colors

The accent paints the current row: the tint, the bar and the focus ring. Put an ori-color_* utility on the list — the component has no color prop, and a class falls through to the <ul>. The bar reads the accent's AA-safe text tone rather than the raw role.

  • primary
  • Another row
  • secondary
  • Another row
  • success
  • Another row
  • warning
  • Another row
  • danger
  • Another row
  • info
  • Another row
html
<!-- swap the color: ori-color_primary → _secondary / _success / _warning / _danger / _info -->
<ul class="ori-list ori-color_success" role="list">
    <li class="ori-list__item">
        <div class="ori-list__row" aria-current="true">
            <span class="ori-list__main"><span class="ori-list__label">success</span></span>
        </div>
    </li>
    <!-- … Another row -->
</ul>
vue
<OriList class="ori-color_success">
    <OriListItem label="success" current />
    <OriListItem label="Another row" />
</OriList>
<OriList class="ori-color_danger">
    <OriListItem label="danger" current />
    <OriListItem label="Another row" />
</OriList>

Radius

The rows follow the shared ori-size-radius_* token: none to full (pill). The component has no radius prop — put the utility on the list. The bar is an inset shadow, so on a large radius it curves with the row.

  • none
  • Another row
  • lg
  • Another row
  • full
  • Another row
html
<!-- ori-size-radius_*: none · xs · sm · md · lg · xl · full -->
<ul class="ori-list ori-size-radius_full" role="list">
    …
</ul>
vue
<OriList class="ori-size-radius_none">…</OriList>
<OriList class="ori-size-radius_full">…</OriList>

Control in a row

A static row may hold a control in its appended cell (the #append slot): a switch, a segmented control, anything that is its own focus stop. The row is a <div>, so the control is not nested in a button or a link. The row's label does not name the control — give the control its own aria-label.

  • NotificationsMentions and replies
  • Theme
html
<ul class="ori-list ori-list_divided" role="list">
    <li class="ori-list__item">
        <!-- a static row: a <div>, so the control inside is valid HTML -->
        <div class="ori-list__row">
            <span class="ori-list__main">
                <span class="ori-list__label">Notifications</span>
                <span class="ori-list__subtitle">Mentions and replies</span>
            </span>
            <span class="ori-list__append">
                <label class="ori-switch ori-color_primary ori-font-size_md">
                    <input class="ori-switch__input" type="checkbox" role="switch" aria-label="Notifications" checked />
                    <span class="ori-switch__track" aria-hidden="true"><span class="ori-switch__thumb"></span></span>
                </label>
            </span>
        </div>
    </li>
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__main"><span class="ori-list__label">Theme</span></span>
            <span class="ori-list__append">
                <!-- see the Segmented control page for the full markup -->
                <div
                    class="ori-segmented-control ori-segmented-control_sm ori-font-size_sm"
                    role="radiogroup"
                    aria-label="Theme"
                >
                    …
                </div>
            </span>
        </div>
    </li>
</ul>
vue
<script setup lang="ts">
import { ref } from 'vue'

const notify = ref(true)
const theme = ref('auto')
const themes = [
    { label: 'Light', value: 'light' },
    { label: 'Dark', value: 'dark' },
    { label: 'Auto', value: 'auto' }
]
</script>

<template>
    <OriList divided>
        <OriListItem icon="…" label="Notifications" subtitle="Mentions and replies">
            <template #append>
                <OriSwitch v-model="notify" aria-label="Notifications" />
            </template>
        </OriListItem>
        <OriListItem icon="…" label="Theme">
            <template #append>
                <OriSegmentedControl v-model="theme" aria-label="Theme" size="sm" :options="themes" />
            </template>
        </OriListItem>
    </OriList>
</template>

Do not put a control in a link or a button row: that nests an interactive element in another, which is invalid HTML and breaks both keyboard and screen-reader use. If the whole row must act as well, make the control the only action.

Custom content

The slots replace the built-in cells. #prepend replaces the icon, #append replaces the meta text and the chevron, and the default slot replaces the label and the subtitle — the content you pass sits inside ori-list__main. Here a status icon before the label and a tag at the end.

  • Build 128Deployed 4 minutes agoPassed
  • Build 127Failed 1 hour agoFailed
html
<ul class="ori-list ori-list_divided" role="list">
    <li class="ori-list__item">
        <div class="ori-list__row">
            <span class="ori-list__prepend">
                <i class="ori-icon ori-icon_inherit ori-color_success" aria-hidden="true">
                    <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48…" /></svg>
                </i>
            </span>
            <span class="ori-list__main">
                <span class="ori-list__label">Build 128</span>
                <span class="ori-list__subtitle">Deployed 4 minutes ago</span>
            </span>
            <span class="ori-list__append">
                <!-- see the Tag page for the markup -->
                <span class="ori-tag ori-color_success">…</span>
            </span>
        </div>
    </li>
    <!-- … Build 127 -->
</ul>
vue
<OriList divided>
    <OriListItem label="Build 128" subtitle="Deployed 4 minutes ago">
        <template #prepend>
            <OriIcon icon="M12 2C6.48 2 2 6.48…" color="success" />
        </template>
        <template #append>
            <OriTag label="Passed" color="success" />
        </template>
    </OriListItem>
    <OriListItem label="Build 127" subtitle="Failed 1 hour ago">
        <template #prepend>
            <OriIcon icon="M12 2C6.48 2 2 6.48…" color="danger" />
        </template>
        <template #append>
            <OriTag label="Failed" color="danger" />
        </template>
    </OriListItem>
</OriList>

The icon is decorative (aria-hidden); the text that says what happened — here the tag — stays in the row, so the status never rests on color alone.

Right to left

The bar and the chevron are logical: they sit on the inline-start and inline-end edges, and a dir="rtl" mirrors them with no extra class. Rows lay out in the inline direction, so the icon, the label and the appended cell swap sides too.

  • Home
  • Projects12
html
<!-- dir="rtl" on the list (or any ancestor): the bar moves to the right edge, the chevron is mirrored -->
<ul class="ori-list" role="list" dir="rtl">
    …
</ul>
vue
<OriList dir="rtl">
    <OriListItem icon="…" label="Home" current />
    <OriListItem icon="…" label="Projects" meta="12" chevron />
</OriList>

Common patterns

A panel of an app: a file menu of actions with their shortcuts and a chevron into a sub-panel, a theme row that holds a segmented control, and a link row into another page. The first three rows are buttons, the fourth is a static row holding a control, the last is a link — one list, four kinds of row.

html
<div class="ori-surface ori-surface_elevation-lg ori-surface_bordered ori-size-radius_lg" style="padding: 0.375rem">
    <ul class="ori-list ori-list_divided" role="list">
        <li class="ori-list__item">
            <button type="button" class="ori-list__row">
                <span class="ori-list__prepend">
                    <i class="ori-icon ori-icon_inherit" aria-hidden="true">
                        <svg viewBox="0 0 24 24"><path d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z" /></svg>
                    </i>
                </span>
                <span class="ori-list__main"><span class="ori-list__label">New drawing</span></span>
                <span class="ori-list__append"><span class="ori-list__meta">Ctrl+N</span></span>
            </button>
        </li>
        <!-- … Save (Ctrl+S) -->
        <li class="ori-list__item">
            <button type="button" class="ori-list__row">
                <span class="ori-list__prepend">
                    <i class="ori-icon ori-icon_inherit" aria-hidden="true"><svg viewBox="0 0 24 24">…</svg></i>
                </span>
                <span class="ori-list__main"><span class="ori-list__label">Export</span></span>
                <span class="ori-list__append">
                    <svg class="ori-list__chevron" viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true">
                        <path
                            d="m9 6 6 6-6 6"
                            fill="none"
                            stroke="currentcolor"
                            stroke-width="2"
                            stroke-linecap="round"
                            stroke-linejoin="round"
                        />
                    </svg>
                </span>
            </button>
        </li>
        <li class="ori-list__item">
            <!-- a static row: the control lives in the appended cell and names itself -->
            <div class="ori-list__row">
                <span class="ori-list__prepend">
                    <i class="ori-icon ori-icon_inherit" aria-hidden="true"><svg viewBox="0 0 24 24">…</svg></i>
                </span>
                <span class="ori-list__main"><span class="ori-list__label">Theme</span></span>
                <span class="ori-list__append">
                    <div
                        class="ori-segmented-control ori-segmented-control_sm ori-font-size_sm"
                        role="radiogroup"
                        aria-label="Theme"
                    >
                        …
                    </div>
                </span>
            </div>
        </li>
        <li class="ori-list__item">
            <a class="ori-list__row" href="/gallery">
                <span class="ori-list__prepend">
                    <i class="ori-icon ori-icon_inherit" aria-hidden="true"><svg viewBox="0 0 24 24">…</svg></i>
                </span>
                <span class="ori-list__main">
                    <span class="ori-list__label">Gallery</span>
                    <span class="ori-list__subtitle">Your saved drawings</span>
                </span>
            </a>
        </li>
    </ul>
</div>
vue
<script setup lang="ts">
import { ref } from 'vue'

const theme = ref('auto')
const themes = [
    { label: 'Light', value: 'light' },
    { label: 'Dark', value: 'dark' },
    { label: 'Auto', value: 'auto' }
]
</script>

<template>
    <OriSurface style="padding: 0.375rem">
        <OriList divided>
            <OriListItem :icon="plusPath" label="New drawing" meta="Ctrl+N" @click="newDrawing" />
            <OriListItem :icon="savePath" label="Save" meta="Ctrl+S" @click="save" />
            <OriListItem :icon="exportPath" label="Export" chevron @click="openExport" />
            <OriListItem :icon="themePath" label="Theme">
                <template #append>
                    <OriSegmentedControl v-model="theme" aria-label="Theme" size="sm" :options="themes" />
                </template>
            </OriListItem>
            <OriListItem :icon="galleryPath" label="Gallery" subtitle="Your saved drawings" href="/gallery" />
        </OriList>
    </OriSurface>
</template>

Accessibility

The accessibility contract holds across every layer — the standalone classes and the Vue components render the same attributes and keyboard behavior.

  • The list is a <ul> with role="list" stated: Safari drops list semantics from a <ul> styled list-style: none, and the explicit role keeps it announced as a list. Each row sits in an <li>.
  • A navigation list goes in a <nav aria-label="…">. The list adds no landmark of its own, and an unlabeled <nav> is announced as just "navigation".
  • A row that acts is a real <button type="button"> or <a href>; a row that only holds content is a <div>. Its accessible name is its content: the label, the subtitle and the meta text read as one string ("New drawing Ctrl+N"). The icon and the chevron are aria-hidden.
  • current renders aria-current: "page" on a link (an <a>, or a component such as a router link) and "true" on any other row. Set it through current, not as a raw attribute.
  • The current row is not marked by tint alone: the tint is too faint (WCAG 1.4.11), so it also carries a bar on its inline-start edge in the accent's AA-safe text tone. Do not remove the bar when restyling.
  • disabled: a button row is a native disabled button (dimmed to 45%, skipped by Tab); a link row drops its href and gets aria-disabled="true" with role="link", so it is announced as an unavailable link and is neither followed nor focused. A static row ignores disabled.
  • A control in a row's appended cell needs its own accessible name (aria-label): the row's label text is not wired to it. Never put a control in a link or a button row.
  • The subtitle and the meta text are faded to 0.7 of the text color, and to 0.85 on the current row's tint, so they stay readable (AA) on both backgrounds.
  • Focus is drawn on the row: a 2 px outline in the accent's text tone, inset by 2 px so it is not clipped by the list's edge, on :focus-visible. The hover tint sits inside @media (hover: hover) and the tint transition is off under prefers-reduced-motion.
  • There is no arrow-key roving: the rows are ordinary tab stops, because a list of navigation links is not a menu in the WAI-ARIA Authoring Practices. Use a menu when you want one tab stop and arrow keys.
  • Writing direction is respected: the bar sits on the inline-start edge, the chevron is mirrored, and ori-list__prepend and ori-list__append swap sides with dir.
KeyAction
Tab / Shift+TabMoves focus to the next / previous interactive row, then on to the controls inside static rows. A disabled row is skipped.
EnterActivates a button row or follows a link row.
SpaceActivates a button row (native). A link row is activated with Enter only.

A control inside a static row keeps its own keys: a switch toggles on Space, a segmented control moves with the arrows. The list takes none of them.

Framework API

The props, attributes and slots of the Vue components. The standalone CSS layer has no component API — its surface is the classes above.

Props

OriList:

PropTypeDefaultDescription
dividedbooleanfalseNo gap between rows; a hairline between them (ori-list_divided).

OriListItem:

PropTypeDefaultDescription
asstring | object'a' with href, 'button' with @click, else 'div'The row element: a tag name or a component (a router link). Wins over href and a click listener.
chevronbooleanfalseA chevron at the end: the row opens a sub-panel. Mirrored in RTL.
currentbooleanfalseThe current page or the selected entry: aria-current ("page" on a link, "true" otherwise), the tint and the bar.
disabledbooleanfalseA button row gets disabled; a link row drops its href and gets aria-disabled. A static row ignores it.
hrefstring—Renders the row as a link.
iconstring—An SVG path drawn before the label (OriIcon).
labelstring—The row text.
metastring—Short text at the end of the row, such as a shortcut (Ctrl+S) or a value.
subtitlestring—A second line under the label.

Events & attributes

Neither component declares custom events.

OriList does not set inheritAttrs: false, so class, style, dir and aria-* (an aria-label on a list that stands alone) fall through to the <ul>.

OriListItem sets inheritAttrs: false and binds every attribute and listener to the row element, not to the <li>. class is merged with ori-list__row. A listener is also what decides the element: an @click makes the row a <button>; other listeners (@keydown, @focus) do not. An attribute you pass overrides the ones the row sets itself (type, href), except aria-current, which always follows current.

Slots

SlotDescription
prependReplaces the icon in ori-list__prepend. The cell is rendered when this slot or icon is set.
defaultReplaces the label and the subtitle inside ori-list__main. You own the markup of the text column.
appendReplaces the meta text and the chevron in ori-list__append — put a control of your own here, in a static row. The cell is rendered when this slot, meta or chevron is set.

Polymorphic (as)

as takes a tag name or a component and wins over href and a click listener. Pass a router link and give it its own navigation props; the row renders it with class="ori-list__row". An <a> and a component are treated as links: current renders aria-current="page", and disabled sets aria-disabled and swallows clicks. Any other tag is a static row, like the <div>.

vue
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>

<template>
    <nav aria-label="Main">
        <OriList>
            <OriListItem :as="RouterLink" to="/gallery" label="Gallery" />
            <OriListItem :as="RouterLink" to="/settings" label="Settings" current />
        </OriList>
    </nav>
</template>