3 works
- Summer Pockets夏日口袋
Key / 株式会社ビジュアルアーツ
- CLANNAD
Key / 株式会社ビジュアルアーツ
- ATRI -My Dear Moments-亚托莉 -我挚爱的时光-
Frontwing Co., Ltd. / 枕 / ANIPLEX.EXE / iMel / Syawase Works / JAST USA
<script setup lang="ts">
import { DataList, Image, Stack, Text, Tooltip } from '@hina-ui/vue'
import { dataListDemo } from '../../data-list'
const items = dataListDemo('en')
</script>
<template>
<DataList
:items="items.slice(0, 3)"
item-key="id"
item-title="title"
item-description="subtitle"
:media-ratio="3 / 4"
layout-toggle
grid-min="10rem"
label="Visual novels"
class="max-w-2xl"
>
<template #header><Text size="sm" tone="muted">3 works</Text></template>
<template #media="{ item }">
<Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
</template>
<template #meta="{ item }">
<Stack gap="xs" class="min-w-0 w-full">
<Tooltip :content="item.developer">
<Text size="xs" tone="muted" truncate class="w-fit max-w-full">{{ item.developer }}</Text>
</Tooltip>
<Text as="time" :datetime="item.released" size="xs" tone="muted">{{ item.released }}</Text>
</Stack>
</template>
</DataList>
</template>
Usage
DataList provides media, title, description, metadata, and action regions. The same content adapts between horizontal rows and vertical cards, with pagination, placeholders, and empty states managed together. Rows retain list semantics without implicit selection or click behavior. Use DataTable when you need headers and aligned columns.
Pass items, a unique stable itemKey, and itemTitle for a basic list. Title and description fields also accept functions.
<DataList :items="items" item-key="id" item-title="name" item-description="description" />
Add #media, #title, #description, #meta, and #actions as needed. Each receives { item, index, key, layout } with the full item type. With pagination, index includes the page offset. The default slot replaces the entire item while retaining the list container, pagination, and state handling.
Examples use a snapshot of public works from Hikarinagi, retrieved on 2026-09-18, including names, developers, release dates, and covers. Detail links lead to the original entries. Pagination, filtering, and remote pagination use 1,000 distinct items; virtual scrolling uses 5,000. Layout examples show a small selection. Covers use Image, actions use Button, and truncated text uses Tooltip.
Examples
Item structure and layout
Enable layout-toggle for the built-in layout control, or use v-model:layout and the state slot’s setLayout. Switching layouts preserves item nodes that remain within the rendered range.
List media sits before the text. Actions appear after the text in wide containers and below it in narrow containers. Cards place media above the text and actions at the bottom. Titles and descriptions wrap without a line limit; metadata wraps too. Use Text in the corresponding slot when truncation is needed.
mediaRatio sets the media aspect ratio for both layouts. Without it, lists use a square and cards use 16:10. gridMin sets the preferred minimum card width, falling back to one column in narrower containers; gridGap controls spacing. This example customizes titles with Link and actions with Toggle.
3 works
<script setup lang="ts">
import { reactive } from 'vue'
import { Bookmark } from '@lucide/vue'
import { DataList, Image, Link, Stack, Text, Toggle, Tooltip } from '@hina-ui/vue'
import { dataListDemo } from '../../data-list'
const items = dataListDemo('en')
const saved = reactive<Record<number, boolean>>({})
</script>
<template>
<DataList
:items="items.slice(3, 6)"
item-key="id"
item-description="subtitle"
layout="grid"
layout-toggle
grid-min="10rem"
label="Custom items"
class="max-w-2xl"
>
<template #header><Text size="sm" tone="muted">3 works</Text></template>
<template #title="{ item }">
<Link :href="item.url" target="_blank" rel="noopener noreferrer" tone="neutral">
{{ item.title }}
</Link>
</template>
<template #media="{ item }">
<Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
</template>
<template #meta="{ item }">
<Stack gap="xs" class="min-w-0 w-full">
<Tooltip :content="item.developer">
<Text size="xs" tone="muted" truncate class="w-fit max-w-full">{{ item.developer }}</Text>
</Tooltip>
<Text as="time" :datetime="item.released" size="xs" tone="muted">{{ item.released }}</Text>
</Stack>
</template>
<template #actions="{ item }">
<Toggle v-model="saved[item.id]" size="sm" :label="`Mark ${item.title}`" :tooltip="false">
<template #icon="{ pressed }">
<Bookmark :class="pressed ? 'fill-current' : undefined" />
</template>
{{ saved[item.id] ? 'Marked' : 'Mark' }}
</Toggle>
</template>
</DataList>
</template>
Local pagination
Enable pagination and pass the full array. DataList slices the current page and renders Pagination. Control the page and page size with v-model:page and v-model:page-size. If removing items leaves the page out of bounds, it returns to the last valid page. Default pagination hides when there is only one page or no data; a custom #pagination slot remains visible.
#footer shares the bottom row with pagination and wraps when space is limited. Changing pages resets only the list’s own scroll position, preserving ancestor scroll positions.
State slots expose setPage and setPageSize, respecting loading and page boundaries. Changing page size returns to page one. The example uses Select.
1000 items
- Summer Pockets夏日口袋
2018-06-29
- CLANNAD
2004-04-28
- ATRI -My Dear Moments-亚托莉 -我挚爱的时光-
2020-06-19
- 終のステラ星之终途
2022-09-30
- planetarian ~ちいさなほしのゆめ~星之梦
2004-11-29
- STEINS;GATE命运石之门
2009-10-15
- Summer Pockets REFLECTION BLUE
2020-06-26
- Harmonia -ハルモニア-
2016-09-23
- LOOPERS -ルーパーズ-时廻者
2021-05-28
- STEINS;GATE 0命运石之门 0
2015-12-10
<script setup lang="ts">
import { ref } from 'vue'
import { DataList, Select, Text } from '@hina-ui/vue'
import { dataListDemo } from '../../data-list'
const items = dataListDemo('en')
const page = ref(1)
const pageSize = ref(10)
const sizes = [10, 20, 50].map(value => ({ value, label: `${value} per page` }))
</script>
<template>
<DataList
v-model:page="page"
v-model:page-size="pageSize"
:items="items"
item-key="id"
item-title="title"
item-description="subtitle"
pagination
class="max-w-2xl"
>
<template #header="{ pageSize: size, setPageSize }">
<Text size="sm" tone="muted">{{ items.length }} items</Text>
<Select
:model-value="size"
:options="sizes"
size="sm"
class="w-36 max-w-full"
aria-label="Items per page"
@update:model-value="value => setPageSize(Number(value))"
/>
</template>
<template #meta="{ item }">
<Text size="xs" tone="muted">{{ item.released }}</Text>
</template>
</DataList>
</template>
Filtering and sorting
Compute filtered and sorted items outside the component, and reset the page when the query changes. This example combines SearchInput and Select.
- 戦国†恋姫BRAVE弐 ~戦乱の九州、島津編~战国恋姬BRAVE二 ~战乱的九州、岛津篇~
2026-08-28
- ユリアリス
2026-08-16
- 両腕が折れたのでHなお世話をされる話
2026-07-31
- ネモフィリア-We pass each other-
2026-07-31
- ぼっちな魔王と俺の塔
2026-07-31
- お隣の天使様にいつの間にか駄目人間にされていた件 Memorial Vacation
2026-07-23
- 年上彼女2
2026-06-26
- as:9-nine- ARTEISIA
2026-06-26
- マガルミナ
2026-06-26
- Relirium - レリリウム - 遺跡と出逢いと冒険と
2026-05-29
1000 results
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Button, DataList, Empty, SearchInput, Select, Text } from '@hina-ui/vue'
import { dataListDemo } from '../../data-list'
const items = dataListDemo('en')
const query = ref('')
const order = ref<string | number | null>('release')
const page = ref(1)
const options = [
{ value: 'release', label: 'Newest first' },
{ value: 'title', label: 'Title' },
]
const filtered = computed(() =>
items
.filter(item =>
`${item.title} ${item.originalTitle} ${item.developer}`
.toLocaleLowerCase()
.includes(query.value.trim().toLocaleLowerCase()),
)
.toSorted((a, b) =>
order.value === 'release'
? b.released.localeCompare(a.released)
: a.title.localeCompare(b.title),
),
)
watch([query, order], () => {
page.value = 1
})
</script>
<template>
<DataList
v-model:page="page"
:items="filtered"
item-key="id"
item-title="title"
item-description="subtitle"
pagination
:page-size="10"
label="Search results"
class="max-w-2xl"
>
<template #header>
<SearchInput
v-model="query"
placeholder="Title or developer"
aria-label="Search"
class="w-full @lg/hn-data-list:min-w-0 @lg/hn-data-list:flex-1"
/>
<Select
v-model="order"
:options="options"
aria-label="Sort order"
class="w-full @lg/hn-data-list:w-44"
/>
</template>
<template #meta="{ item }">
<Text size="xs" tone="muted">{{ item.released }}</Text>
</template>
<template #empty>
<Empty title="No matching works" size="sm" :icon="false">
<template #actions>
<Button variant="outline" tone="neutral" size="sm" @click="query = ''">
Clear search
</Button>
</template>
</Empty>
</template>
<template #footer="{ total }">
<Text size="xs" tone="muted">{{ total }} results</Text>
</template>
</DataList>
</template>
Remote pagination
With manual and pagination, items contains the server’s current page and is never sliced again. Provide total for full pagination. If it is unknown, omit it and provide hasNextPage; navigation shows previous, next, and the current page.
This example fetches static JSON page snapshots and works on static hosting. It does not query the live catalog or insert an artificial delay. Switch controls whether the total is known. Existing items remain during requests, stale requests are canceled, and failures show an Empty state with a retry action.
<script setup lang="ts">
import { onMounted, onScopeDispose, ref, watch } from 'vue'
import { useRuntimeConfig } from '#app'
import { Button, DataList, Empty, Switch, Text } from '@hina-ui/vue'
import type { DataListDemoItem } from '../../data-list'
const baseURL = useRuntimeConfig().app.baseURL
const page = ref(1)
const rows = ref<DataListDemoItem[]>([])
const total = ref<number>()
const loading = ref(true)
const knownTotal = ref(true)
const hasNextPage = ref(false)
const failed = ref(false)
let controller: AbortController | undefined
async function load() {
controller?.abort()
const request = new AbortController()
controller = request
loading.value = true
failed.value = false
try {
const response = await fetch(`${baseURL}demo/data-list/page-${page.value}.json`, {
signal: request.signal,
})
if (!response.ok) throw new Error(String(response.status))
const data = (await response.json()) as {
items: Omit<DataListDemoItem, 'subtitle'>[]
total: number
hasNextPage: boolean
}
if (request.signal.aborted) return
rows.value = data.items.map(item => ({
...item,
title: item.originalTitle,
subtitle: item.title === item.originalTitle ? '' : item.title,
}))
total.value = data.total
hasNextPage.value = data.hasNextPage
} catch {
if (!request.signal.aborted) {
rows.value = []
failed.value = true
}
} finally {
if (!request.signal.aborted) loading.value = false
}
}
onMounted(load)
watch(page, load)
onScopeDispose(() => controller?.abort())
</script>
<template>
<DataList
v-model:page="page"
:items="rows"
item-key="id"
item-title="title"
item-description="subtitle"
pagination
manual
:page-size="10"
:total="knownTotal ? total : undefined"
:has-next-page="hasNextPage"
:loading="loading"
:min-height="400"
class="max-w-2xl"
label="Remote items"
>
<template #header>
<Switch v-model="knownTotal" :disabled="loading">Known total</Switch>
<Button variant="ghost" tone="neutral" size="sm" :disabled="loading" @click="load">
Refresh
</Button>
</template>
<template #meta="{ item }">
<Text size="xs" tone="muted">{{ item.released }}</Text>
</template>
<template #empty>
<Empty :title="failed ? 'Loading failed' : 'No data'" size="sm">
<template v-if="failed" #actions><Button size="sm" @click="load">Retry</Button></template>
</Empty>
</template>
</DataList>
</template>
Loading and empty states
Initial loading uses Skeleton matching the item structure. placeholderCount controls the count. Only configured content regions get placeholders. Virtualized lists default to 3 placeholders; ordinary paginated lists default to the page size. When replacing whole items, use #placeholder="{ index, layout }" for matching placeholders without fabricating business data.
When items already exist, loading preserves them, blocks item interaction and pagination, and displays a delayed LoadingOverlay. #loading replaces the entire initial loading region and the refresh indicator. Use #empty or emptyText to customize the empty state.
minHeight reserves a minimum body height. Auto-height lists preserve the previous height when items are cleared during loading. height fixes the body height and uses ScrollArea for overflow.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { DataList, Image, SegmentedControl, Stack, Text } from '@hina-ui/vue'
import { dataListDemo } from '../../data-list'
const items = dataListDemo('en')
const state = ref('initial')
const options = [
{ value: 'initial', label: 'Initial' },
{ value: 'ready', label: 'Ready' },
{ value: 'refresh', label: 'Refresh' },
{ value: 'empty', label: 'Empty' },
]
const rows = computed(() =>
state.value === 'initial' || state.value === 'empty' ? [] : items.slice(0, 2),
)
</script>
<template>
<Stack class="w-full max-w-2xl">
<SegmentedControl v-model="state" :options="options" size="sm" aria-label="List state" />
<DataList
:items="rows"
item-key="id"
item-title="title"
item-description="subtitle"
:loading="state === 'initial' || state === 'refresh'"
:placeholder-count="2"
:media-ratio="3 / 4"
:height="280"
label="Items"
>
<template #media="{ item }">
<Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
</template>
<template #meta="{ item }">
<Text size="xs" tone="muted">{{ item.released }}</Text>
</template>
</DataList>
</Stack>
</template>
Custom pagination
#pagination receives the same state and actions as the header and footer, allowing additional Pagination options.
<DataList :items="items" item-key="id" item-title="name" pagination>
<template #pagination="{ page, pageSize, total, loading, setPage, setPageSize }">
<Pagination
:model-value="page"
:page-size="pageSize"
:total="total ?? 0"
:pending="loading"
:page-size-options="[10, 20, 50]"
show-info
@change="value => value.pageSize === pageSize ? setPage(value.page) : setPageSize(value.pageSize)"
/>
</template>
</DataList>
Virtual scrolling
virtualize supports both layouts using the same windowing foundation as VirtualList. Grids virtualize entire rows using the tallest item in each row. Column count follows the container width. estimateSize estimates one list row or a full grid row; overscan retains extra rows before and after the visible range. Row heights are measured after rendering.
height sets the viewport height and defaults to 320px when virtualized. Percentage heights require a parent with a defined height. With pagination, only the current page is virtualized. Keep persistent item state outside the component, keyed by ID.
This example uses 5,000 distinct real entries, supports both layouts, and reports the visible range. Only the current window and overscan rows are rendered.
- Summer Pockets夏日口袋
2018-06-29
- CLANNAD
2004-04-28
- ATRI -My Dear Moments-亚托莉 -我挚爱的时光-
2020-06-19
- 終のステラ星之终途
2022-09-30
- planetarian ~ちいさなほしのゆめ~星之梦
2004-11-29
Visible: 1–4 / 5000
<script setup lang="ts">
import { ref } from 'vue'
import { DataList, Image, Text, type DataListLayout } from '@hina-ui/vue'
import { dataListVirtualDemo } from '../../data-list-virtual'
const items = dataListVirtualDemo('en')
const layout = ref<DataListLayout>('list')
const range = ref({ startIndex: -1, endIndex: -1 })
</script>
<template>
<DataList
v-model:layout="layout"
:items="items"
item-key="id"
item-title="title"
item-description="subtitle"
layout-toggle
:media-ratio="layout === 'grid' ? 16 / 10 : 3 / 4"
grid-min="12rem"
:height="400"
:virtualize="{ estimateSize: layout === 'grid' ? 280 : 128, overscan: 1 }"
body-class="border-line rounded-lg border"
content-class="p-4"
label="Virtualized items"
class="max-w-2xl"
@range-change="range = $event"
>
<template #media="{ item }">
<Image :src="item.cover.src" alt="" fit="cover" class="size-full" />
</template>
<template #meta="{ item }">
<Text size="xs" tone="muted">{{ item.released }}</Text>
</template>
<template #footer>
<Text size="xs" tone="muted">
Visible: {{ range.startIndex + 1 }}–{{ range.endIndex + 1 }} / {{ items.length }}
</Text>
</template>
</DataList>
</template>
SSR
Lists, cards, pagination, and initial skeletons render on the server. Virtualized views render a readable initial window, then measure the viewport and rows after mounting. A client-only wrapper is unnecessary.
Use initialColumns to estimate a virtual grid’s server column count; the default is 1. Server and client initial values must agree. CSS and container width determine the actual columns. Items beyond the initial virtual window render as the user scrolls.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly T[] | Required | Full array, or the current page in manual mode |
itemKey | Key field or (item, index) => string | number | Required | Unique, stable key |
itemTitle / itemDescription | Text field or (item, index) => string | number | null | undefined | — | Title and description; corresponding slots take precedence |
mediaRatio | number | — | Media aspect ratio |
layoutToggle | boolean | false | Show the layout control |
gridMin | string | '14rem' | Preferred minimum grid column width |
gridGap | 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Grid spacing |
size | 'sm' | 'md' | 'lg' | 'md' | List media size, item spacing, and card padding |
divided | boolean | true | List dividers |
pagination | boolean | false | Show pagination |
manual | boolean | false | Do not slice the current page |
total | number | — | Remote total; local mode uses array length |
hasNextPage | boolean | false | Whether another unknown-total page exists |
loading | boolean | false | Loading state; block content interaction and paging |
placeholderCount | number | 3 or page size | Initial skeleton count |
emptyText | string | Locale | Default empty text |
label | string | — | Accessible list and scroll region name |
virtualize | boolean | DataListVirtualOptions | false | Virtualize lists or grids |
height | number | string | Auto; virtual: 320 | Fixed body height |
minHeight | number | string | 160 | Minimum body height without a fixed height |
class | string | — | Root classes |
bodyClass | string | — | Body frame, including loading and empty states |
contentClass | string | — | Item list container in every layout and rendering mode |
itemClass | string | ((item, index) => string | undefined) | — | Individual item classes |
DataListVirtualOptions includes estimateSize (112px for lists, 280px for grids), overscan (3 rows), and initialColumns (1). Other attributes, including dir and style, forward to the root.
Models
| Model | Type | Default |
|---|---|---|
layout | 'list' | 'grid' | 'list' |
page | number | 1 |
pageSize | number | 10 |
Slots
| Slot | Parameters | Description |
|---|---|---|
media / title / description / meta / actions | DataListItemSlot<T> | Structured item regions |
default | DataListItemSlot<T> | Replace the entire item |
placeholder | { index, layout } | One initial skeleton |
header / footer | DataListState<T> | Above the list / beside pagination in the footer |
pagination | DataListState<T> | Replace pagination |
empty | DataListState<T> | Non-loading empty state |
loading | DataListState<T> | Entire initial loading region / refresh indicator |
DataListState<T> includes current-page items, page, pageSize, total, pageCount, layout, loading, refreshing, hasPreviousPage, hasNextPage, setPage(page), setPageSize(size), and setLayout(layout). Unknown totals leave total and pageCount undefined.
Events and instance
| Name | Parameters / type | Description |
|---|---|---|
pageChange | { page, pageSize } | Paging actions or boundary corrections |
rangeChange | { startIndex, endIndex } | Visible virtual range, including page offset; -1 when unavailable |
viewport | HTMLElement | undefined | Scroll container when height or virtualization is enabled |
scrollToIndex(index, options?) | align?: 'start' | 'center' | 'end' | 'auto'; behavior?: ScrollBehavior | Scroll to an item within the current page, using its global index |
scrollToIndex scrolls only the internal viewport when one exists. Without an internal viewport, this explicit call scrolls ancestors to bring the target item into view.
Direct model changes do not emit pageChange again; watch the models for remote requests. Public data types are exported from the package root, including DataListProps<T>, DataListItemSlot<T>, DataListState<T>, DataListVirtualOptions, and DataListExpose.