Skip to content
ShineUI
Install

DataView

Cards or rows, one toggle.

<sui-data-view>DataStable

About

The same records as a card grid or a row list, with one toggle between them. Reach for it where a table is too dense and each record wants a card. It pages the records itself when asked, and shimmers while they load. A list narrower than 520px stacks each row under its title.

Usage

<sui-data-view columns="3"></sui-data-view> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-data-view"); el.items = [ { title: "Clark County Schools", subtitle: "rmills@ccsc.k12.in.us", meta: "Payment Aug 5", amount: "$18,940.45", pill: { label: "Current" } }, { title: "Riverbend Dental Group", subtitle: "billing@riverbenddental.com", meta: "Payment Aug 14", amount: "$12,480.00", pill: { label: "Overdue", severity: "critical" }, }, { title: "Derby City Events", subtitle: "m.bell@derbycityevents.com", meta: "Payment Aug 1", amount: "$14,300.00", pill: { label: "Current" } }, ]; /* SuiDataViewItem[] */ el.addEventListener("suiLayoutChange", (e) => console.log(e.detail)); </script>
import { SuiDataView } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const items = [ { title: "Clark County Schools", subtitle: "rmills@ccsc.k12.in.us", meta: "Payment Aug 5", amount: "$18,940.45", pill: { label: "Current" } }, { title: "Riverbend Dental Group", subtitle: "billing@riverbenddental.com", meta: "Payment Aug 14", amount: "$12,480.00", pill: { label: "Overdue", severity: "critical" }, }, { title: "Derby City Events", subtitle: "m.bell@derbycityevents.com", meta: "Payment Aug 1", amount: "$14,300.00", pill: { label: "Current" } }, ]; /* SuiDataViewItem[] */ return <SuiDataView columns="3" items={items} onSuiLayoutChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiDataView } from "@shineui/vue"; import "@shineui/core/shineui.css"; const items = [ { title: "Clark County Schools", subtitle: "rmills@ccsc.k12.in.us", meta: "Payment Aug 5", amount: "$18,940.45", pill: { label: "Current" } }, { title: "Riverbend Dental Group", subtitle: "billing@riverbenddental.com", meta: "Payment Aug 14", amount: "$12,480.00", pill: { label: "Overdue", severity: "critical" }, }, { title: "Derby City Events", subtitle: "m.bell@derbycityevents.com", meta: "Payment Aug 1", amount: "$14,300.00", pill: { label: "Current" } }, ]; // SuiDataViewItem[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiDataView columns="3" :items="items" @sui-layout-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiDataView } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiDataView], template: `<sui-data-view columns="3" [(layout)]="layout" [items]="items"></sui-data-view>`, }) export class Example { layout = 'grid'; items = [ { title: "Clark County Schools", subtitle: "rmills@ccsc.k12.in.us", meta: "Payment Aug 5", amount: "$18,940.45", pill: { label: "Current" } }, { title: "Riverbend Dental Group", subtitle: "billing@riverbenddental.com", meta: "Payment Aug 14", amount: "$12,480.00", pill: { label: "Overdue", severity: "critical" }, }, { title: "Derby City Events", subtitle: "m.bell@derbycityevents.com", meta: "Payment Aug 1", amount: "$14,300.00", pill: { label: "Current" } }, ]; // SuiDataViewItem[] }

Basic

items is a property; the default card takes { title, subtitle, meta, amount, pill }. The toggle is a group of two buttons with aria-pressed and shows the same records as a grid or a list. columns is a maximum: a card never goes under 200px, so a narrow frame draws fewer and a phone draws one.

<sui-data-view columns="3"></sui-data-view>
// setup(root): properties and events setup(root) { root.querySelector('sui-data-view').items = items; }

Sort control

The header slot shares the toolbar with the layout toggle, which is where a screen puts its sort select or search box.

<sui-data-view id="dv-sort" columns="3"> <sui-select slot="header" placeholder="Sort by" aria-label="Sort by"></sui-select> </sui-data-view>
// setup(root): properties and events setup(root) { root.querySelector('#dv-sort').items = items; root.querySelector('#dv-sort sui-select').options = ['Customer', 'Amount', 'Payment date']; }

Your own card

itemRender receives the record, the layout and the record index in items, so a record with no id of its own still has something to key on.

<sui-data-view id="dv-custom" columns="3" hide-toggle></sui-data-view>
// setup(root): properties and events setup(root) { const el = root.querySelector('#dv-custom'); const doc = root.ownerDocument; el.items = items; el.itemRender = (it, layout, i) => { const box = doc.createElement('div'); box.style.cssText = 'display:flex;flex-direction:column;gap:4px'; const head = doc.createElement('div'); head.style.cssText = 'font-weight:650;font-size:13px'; head.textContent = `${i + 1}. ${it.title}`; const amt = doc.createElement('div'); amt.style.cssText = "font-size:18px;font-weight:700;font-feature-settings:'tnum'"; amt.textContent = it.amount; box.append(head, amt); return box; }; }

Paging

paginator draws a sui-paginator under the records and shows pageSize of them at a time; suiPageChange reports the page and suiPageSizeChange the size, and neither bubbles. It hides while every record fits on one page. Set totalRecords and the element leaves the records alone, for a host that fetches a page at a time.

page 1 of 42 records
<div style="display:grid;gap:10px"> <code id="dv-page-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">page 1 of 42 records</code> <sui-data-view id="dv-page" paginator page-size="6" columns="3"></sui-data-view> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#dv-page'); const log = root.querySelector('#dv-page-log'); el.items = ledger; el.pageSizes = [6, 12, 24]; const show = () => { log.textContent = `page ${el.page}, ${el.pageSize} a page, of ${ledger.length} records`; }; el.addEventListener('suiPageChange', show); el.addEventListener('suiPageSizeChange', show); }

Loading and empty

loading draws one full row of shimmer cards in the grid and four bars in the list, with the toolbar in place so the frame does not jump. With no records and nothing loading, emptyMessage takes their place.

<div style="display:grid;gap:14px"> <div><sui-button id="dv-load" variant="outline" size="sm" label="Load the records"></sui-button></div> <sui-data-view id="dv-loading" columns="3" loading></sui-data-view> <sui-data-view id="dv-empty" columns="3" empty-message="No customers match that search."></sui-data-view> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#dv-loading'); root.querySelector('#dv-load').addEventListener('click', () => { el.loading = !el.loading; el.items = el.loading ? [] : items; }); }

Events

The toggle writes layout before suiLayoutChange fires, so a screen can save the choice and set it back on load. This one starts as a list.

layout: list
<div style="display:grid;gap:10px"> <code id="dv-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">layout: list</code> <sui-data-view id="dv-events" layout="list"></sui-data-view> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#dv-events'); const log = root.querySelector('#dv-log'); el.items = items; el.addEventListener('suiLayoutChange', (e) => { log.textContent = `layout: ${e.detail}`; }); }

Keyboard

KeyAction
TabMoves through the layout toggle, then any control inside a card or row, then the paginator when it is shown. The cards and rows themselves take no focus unless the item renderer puts a control in them.
Enter SpaceOn a layout button, switches to that layout and emits suiLayoutChange; the button reads aria-pressed. On a paginator control, changes the page and emits suiPageChange; a new page size emits suiPageSizeChange first.

API

<sui-data-view>

Properties

PropertyAttributeTypeDefaultDescription
columnscolumnsnumbernumber | undefined3Most columns the card grid draws. A card never goes under --sui-data-view-card-min (200px unless the page sets it), so a narrow frame draws fewer columns and a phone draws one; unset, the grid fits as many such cards as the frame holds.
emptyMessageempty-messagestringstring | undefinedMessage for zero records. Never apologizes; it is not shown while loading.
hideTogglehide-togglebooleanfalseDrops the grid and list toggle, two buttons in a group labeled "Layout", each with aria-pressed, for a view whose layout the page fixes.
itemRenderproperty only((item: SuiDataViewItem, layout: SuiDataViewLayout, index: number) => SuiRenderResult) | undefinedCustom item renderer (item, layout, index) => string | HTMLElement. Strings render as text. The index is the record's place in items, counting past the pages before it, so a record with no id of its own still has something to key on. Property, not attribute.
itemsproperty onlySuiDataViewItem[][]Records to show. The default card takes { title, subtitle, meta, amount, pill }; pass itemRender to draw your own. Property, not attribute.
layoutlayoutSuiDataViewLayout"grid" | "list"'grid'Active layout. Mutable; the toggle updates it before suiLayoutChange fires.
loadingloadingbooleanfalseDraws shimmer placeholders in place of records: one full row of cards in the grid, four rows in the list. The toolbar stays put, and the paginator while there is more than one page, so the frame does not jump. Reflected, so a page can style the wait.
pagepagenumber1Current page, 1-based. Mutable; the paginator writes it before suiPageChange fires.
pageSizepage-sizenumber12Records per page. Mutable; changing it through the paginator resets page to 1. Twelve divides by one, two, three and four, so the last row of a card grid is full at every columns a screen uses.
pageSizesproperty onlynumber[][12, 24, 48]Choices for the paginator's rows-per-page select. Property, not attribute.
paginatorpaginatorbooleanfalseDraws a sui-paginator under the records and shows one page at a time. The element slices items itself unless totalRecords says the host has already done it.
totalRecordstotal-recordsnumbernumber | undefinedRecord count behind the page, for a host that fetches one page at a time. Set it and the element draws items as they are and pages against this total instead of slicing. Leave it unset and the element slices the whole items array.

Events

EventDetailReactVueDescription
suiLayoutChangeSuiDataViewLayout"grid" | "list"onSuiLayoutChange@sui-layout-changeFires when the layout toggle changes; detail is 'grid' | 'list'. Does not bubble.
suiPageChangenumberonSuiPageChange@sui-page-changeFires when the page changes, including a page the view clamps after the record count shrinks; detail is the new page. pageSize is already written, so a host that fetches one page at a time reads both here. Does not bubble.
suiPageSizeChangenumberonSuiPageSizeChange@sui-page-size-changeFires when the paginator's rows-per-page control changes the page size; detail is the new pageSize. The view writes both props, then fires this and, when the page moved, suiPageChange. Does not bubble.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Focuses the pressed layout button, or the paginator when the toggle is hidden. The records hold no tab stop of the view's own. el.focus() on the host does the same.

Slots

SlotDescription
headerLeft side of the toolbar, beside the layout toggle: a sort select or a search box. The toolbar renders when the toggle is on or this slot is filled.

Types

SuiDataViewItem interface
titlestring
subtitle?string
meta?string
amount?stringMoney string, already formatted ("$18,940.45"); drawn with tabular numerals.
pill?SuiDataViewPill
SuiDataViewLayout type
No members.
SuiDataViewPill interface
labelstring
severity?SuiSeverityStatus from the shared ladder (positive, attention, critical, info, neutral): the pill is then the status pill sui-tag draws, its tint, ink and edge, so a record's status reads the same here as in a table. bg and color are for a pill outside the ladder.
bg?stringBackground; defaults to var(--brand-soft). A fill carries its own ink, so pass color with it: bg: 'var(--status-critical-bg)' goes with color: 'var(--status-critical-ink)'. A fill set on its own leaves the label on the default ink, which no theme guarantees is legible.
color?stringInk; defaults to var(--brand-soft-ink), the ink for the default fill.
SuiRenderResult type
No members.
SuiSeverity type
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also