Split view
A list on the left, the selected item in full on the right. For an inbox, tickets, orders, applicants: anything the reader scans before reading. Under lg the detail opens in a slideover.
Today
Yesterday
Mariam Keita
mariam@example.com
Today at 14:32
Renewal for another year
Hello, we would be happy to renew for another year on the same terms. Could you send over the new agreement when you have a moment?
We are away the last week of the month, so any time before that works for the signature.
import { Avatar } from '@/components/avatar'
import { SplitView, SplitViewGroup, SplitViewItem } from '@/components/split-view'
import { useState } from 'react'
export default function Example({ messages }) {
let [selectedId, setSelectedId] = useState(messages[0].id)
let [open, setOpen] = useState(false)
let selected = messages.find((message) => message.id === selectedId) ?? messages[0]
function select(message) {
setSelectedId(message.id)
if (!window.matchMedia('(min-width: 64rem)').matches) setOpen(true)
}
return (
<SplitView
ratio="3/4"
open={open}
onClose={() => setOpen(false)}
list={
<SplitViewGroup label="Today">
{messages.map((message) => (
<SplitViewItem key={message.id} size="md" selected={message.id === selectedId} onClick={() => select(message)}>
<Avatar initials={message.initials} className="mt-0.5 size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-black" />
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span className="truncate font-medium text-zinc-950 dark:text-white">{message.from}</span>
<span className="shrink-0 text-xs/6 text-zinc-500 tabular-nums dark:text-zinc-400">{message.time}</span>
</span>
<span className="block truncate text-zinc-500 dark:text-zinc-400">{message.subject}</span>
</span>
</SplitViewItem>
))}
</SplitViewGroup>
}
detail={<MessageDetail message={selected} />}
/>
)
}<script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import SplitView from '@/components/split-view.vue'
import SplitViewGroup from '@/components/split-view-group.vue'
import SplitViewItem from '@/components/split-view-item.vue'
import { computed, ref } from 'vue'
const props = defineProps<{ messages: Message[] }>()
const selectedId = ref(props.messages[0].id)
const open = ref(false)
const selected = computed(() => props.messages.find((message) => message.id === selectedId.value) ?? props.messages[0])
function select(message: Message) {
selectedId.value = message.id
if (!window.matchMedia('(min-width: 64rem)').matches) open.value = true
}
</script>
<template>
<SplitView ratio="3/4" :open="open" @close="open = false">
<template #list>
<SplitViewGroup label="Today">
<SplitViewItem v-for="message in messages" :key="message.id" size="md" :selected="message.id === selectedId" @click="select(message)">
<Avatar :initials="message.initials" class="mt-0.5 size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-black" />
<span class="min-w-0 flex-1">
<span class="flex items-baseline justify-between gap-3">
<span class="truncate font-medium text-zinc-950 dark:text-white">{{ message.from }}</span>
<span class="shrink-0 text-xs/6 text-zinc-500 tabular-nums dark:text-zinc-400">{{ message.time }}</span>
</span>
<span class="block truncate text-zinc-500 dark:text-zinc-400">{{ message.subject }}</span>
</span>
</SplitViewItem>
</SplitViewGroup>
</template>
<template #detail>
<MessageDetail :message="selected" />
</template>
</SplitView>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import clsx from 'clsx'
import type React from 'react'
import { useEffect } from 'react'
import { Slideover } from './slideover'
const ratios = {
'1/2': 'lg:grid-cols-[1fr_2fr]',
'3/4': 'lg:grid-cols-[3fr_4fr]',
}
export function SplitView({
ratio = '1/2',
list,
detail,
open,
onClose,
className,
...props
}: {
ratio?: keyof typeof ratios
list: React.ReactNode
detail: React.ReactNode
open: boolean
onClose: () => void
} & React.ComponentPropsWithoutRef<'div'>) {
useEffect(() => {
if (!open) return
let desktop = window.matchMedia('(min-width: 64rem)')
let closeOnDesktop = () => desktop.matches && onClose()
closeOnDesktop()
desktop.addEventListener('change', closeOnDesktop)
return () => desktop.removeEventListener('change', closeOnDesktop)
}, [open, onClose])
return (
<>
<div {...props} className={clsx(className, 'lg:grid lg:gap-8', ratios[ratio])}>
<div className="min-w-0">{list}</div>
<div className="min-w-0 max-lg:hidden lg:border-l lg:border-zinc-950/5 lg:pl-8 dark:lg:border-white/5">
{detail}
</div>
</div>
<Slideover open={open} onClose={onClose} size="2xl">
{detail}
</Slideover>
</>
)
}
export function SplitViewGroup({
label,
className,
children,
...props
}: { label?: React.ReactNode } & React.ComponentPropsWithoutRef<'div'>) {
return (
<div {...props} className={clsx(className, 'not-first:mt-4')}>
{label && <p className="px-2 text-sm/6 font-medium text-zinc-500 sm:text-xs/6 dark:text-zinc-400">{label}</p>}
<ol role="list" className={clsx('space-y-0.5', label && 'mt-1')}>
{children}
</ol>
</div>
)
}
const sizes = {
sm: 'items-center gap-3 py-2 text-sm/6 sm:py-1.5 sm:text-xs/6',
md: 'items-start gap-3 py-2.5 text-base/6 sm:py-2 sm:text-sm/6',
}
export function SplitViewItem({
selected = false,
size = 'sm',
className,
...props
}: { selected?: boolean; size?: keyof typeof sizes } & Omit<React.ComponentPropsWithoutRef<'button'>, 'type'>) {
return (
<li>
<button
type="button"
aria-current={selected ? 'true' : undefined}
{...props}
className={clsx(
className,
'flex w-full cursor-pointer rounded-lg px-2 text-left focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500',
sizes[size],
selected ? 'bg-zinc-950/5 dark:bg-white/10' : 'hover:bg-zinc-950/2.5 dark:hover:bg-white/5'
)}
/>
</li>
)
}Also copy Slideover.
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import Slideover from './slideover.vue'
const ratios = {
'1/2': 'lg:grid-cols-[1fr_2fr]',
'3/4': 'lg:grid-cols-[3fr_4fr]',
}
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
ratio?: keyof typeof ratios
open?: boolean
}>(),
{ ratio: '1/2', open: false }
)
const emit = defineEmits<{ close: [] }>()
let desktop: MediaQueryList | undefined
function closeOnDesktop() {
if (desktop?.matches) emit('close')
}
function stopListening() {
desktop?.removeEventListener('change', closeOnDesktop)
}
watch(
() => props.open,
(open) => {
if (typeof window === 'undefined') return
stopListening()
if (!open) return
desktop = window.matchMedia('(min-width: 64rem)')
desktop.addEventListener('change', closeOnDesktop)
closeOnDesktop()
},
{ immediate: true }
)
onBeforeUnmount(stopListening)
</script>
<template>
<div v-bind="$attrs" class="lg:grid lg:gap-8" :class="ratios[ratio]">
<div class="min-w-0"><slot name="list" /></div>
<div class="min-w-0 max-lg:hidden lg:border-l lg:border-zinc-950/5 lg:pl-8 dark:lg:border-white/5">
<slot name="detail" />
</div>
</div>
<Slideover :open="open" size="2xl" @close="emit('close')">
<slot name="detail" />
</Slideover>
</template>Also copy Slideover.
Component API
| Prop | Default | Description |
|---|---|---|
SplitViewrenders a <div> element | ||
list | - | The rows, usually `SplitViewGroup`s. |
detail | - | The selected row in full. Rendered in the right column, and in the slideover. |
ratio | '1/2' | Width of the list against the detail. `1/2` on a wide page, `3/4` when the rows carry more or the page less. |
open | - | Under `lg`, whether the detail is open in its slideover. |
onClose | - | Called when that slideover is dismissed, and when the window grows past `lg` while it is open. |
SplitViewGrouprenders a <div> element | ||
label | - | The heading above a group of rows: a day, a status, a folder. Optional. |
SplitViewItemrenders a <button> element | ||
selected | false | Highlights the row and sets `aria-current`. |
size | 'sm' | `sm` for one line, `md` for two lines with an avatar or an icon. |
| Prop | Default | Description |
|---|---|---|
SplitViewrenders a <div> element | ||
#list | - | The rows, usually `SplitViewGroup`s. |
#detail | - | The selected row in full. Rendered in the right column, and in the slideover. |
ratio | '1/2' | Width of the list against the detail. `1/2` on a wide page, `3/4` when the rows carry more or the page less. |
open | false | Under `lg`, whether the detail is open in its slideover. |
@close | - | Emitted when that slideover is dismissed. |
SplitViewGrouprenders a <div> element | ||
label | - | The heading above a group of rows: a day, a status, a folder. Optional. |
SplitViewItemrenders a <button> element | ||
selected | false | Highlights the row and sets `aria-current`. |
size | 'sm' | `sm` for one line, `md` for two lines with an avatar or an icon. |
Examples
Basic example
Keep a row selected at all times, the first one by default, so the detail column is never empty. Rows are plain buttons: give them any content, and let one cell truncate. Narrow the window to see the slideover take over:
Today
Yesterday
Mariam Keita
mariam@example.com
Today at 14:32
Renewal for another year
Hello, we would be happy to renew for another year on the same terms. Could you send over the new agreement when you have a moment?
We are away the last week of the month, so any time before that works for the signature.
import { Avatar } from '@/components/avatar'
import { SplitView, SplitViewGroup, SplitViewItem } from '@/components/split-view'
import { useState } from 'react'
export default function Example({ messages }) {
let [selectedId, setSelectedId] = useState(messages[0].id)
let [open, setOpen] = useState(false)
let selected = messages.find((message) => message.id === selectedId) ?? messages[0]
function select(message) {
setSelectedId(message.id)
if (!window.matchMedia('(min-width: 64rem)').matches) setOpen(true)
}
return (
<SplitView
ratio="3/4"
open={open}
onClose={() => setOpen(false)}
list={
<SplitViewGroup label="Today">
{messages.map((message) => (
<SplitViewItem key={message.id} size="md" selected={message.id === selectedId} onClick={() => select(message)}>
<Avatar initials={message.initials} className="mt-0.5 size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-black" />
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span className="truncate font-medium text-zinc-950 dark:text-white">{message.from}</span>
<span className="shrink-0 text-xs/6 text-zinc-500 tabular-nums dark:text-zinc-400">{message.time}</span>
</span>
<span className="block truncate text-zinc-500 dark:text-zinc-400">{message.subject}</span>
</span>
</SplitViewItem>
))}
</SplitViewGroup>
}
detail={<MessageDetail message={selected} />}
/>
)
}<script setup lang="ts">
import Avatar from '@/components/avatar.vue'
import SplitView from '@/components/split-view.vue'
import SplitViewGroup from '@/components/split-view-group.vue'
import SplitViewItem from '@/components/split-view-item.vue'
import { computed, ref } from 'vue'
const props = defineProps<{ messages: Message[] }>()
const selectedId = ref(props.messages[0].id)
const open = ref(false)
const selected = computed(() => props.messages.find((message) => message.id === selectedId.value) ?? props.messages[0])
function select(message: Message) {
selectedId.value = message.id
if (!window.matchMedia('(min-width: 64rem)').matches) open.value = true
}
</script>
<template>
<SplitView ratio="3/4" :open="open" @close="open = false">
<template #list>
<SplitViewGroup label="Today">
<SplitViewItem v-for="message in messages" :key="message.id" size="md" :selected="message.id === selectedId" @click="select(message)">
<Avatar :initials="message.initials" class="mt-0.5 size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-black" />
<span class="min-w-0 flex-1">
<span class="flex items-baseline justify-between gap-3">
<span class="truncate font-medium text-zinc-950 dark:text-white">{{ message.from }}</span>
<span class="shrink-0 text-xs/6 text-zinc-500 tabular-nums dark:text-zinc-400">{{ message.time }}</span>
</span>
<span class="block truncate text-zinc-500 dark:text-zinc-400">{{ message.subject }}</span>
</span>
</SplitViewItem>
</SplitViewGroup>
</template>
<template #detail>
<MessageDetail :message="selected" />
</template>
</SplitView>
</template>One-line rows
The default size fits one line: a status, a title, a time. Let the title truncate and keep the details for the right column:
Open
Done
Heating stopped working
Reported by Kofi Mensah, today at 09:48
- Status
- Open
- Reference
- REQ-1048
- Location
- Unit 4B
- Assigned to
- Unassigned
<SplitView
ratio="3/4"
open={open}
onClose={() => setOpen(false)}
list={
<SplitViewGroup label="Open">
{requests.map((request) => (
<SplitViewItem key={request.id} selected={request.id === selectedId} onClick={() => select(request)}>
<StatusDot color={request.done ? 'green' : 'amber'} size="md" />
<span className="min-w-0 flex-1 truncate font-medium text-zinc-950 dark:text-white">{request.title}</span>
<span className="shrink-0 text-zinc-500 tabular-nums dark:text-zinc-400">{request.time}</span>
</SplitViewItem>
))}
</SplitViewGroup>
}
detail={<RequestDetail request={selected} />}
/><SplitView ratio="3/4" :open="open" @close="open = false">
<template #list>
<SplitViewGroup label="Open">
<SplitViewItem v-for="request in requests" :key="request.id" :selected="request.id === selectedId" @click="select(request)">
<StatusDot :color="request.done ? 'green' : 'amber'" size="md" />
<span class="min-w-0 flex-1 truncate font-medium text-zinc-950 dark:text-white">{{ request.title }}</span>
<span class="shrink-0 text-zinc-500 tabular-nums dark:text-zinc-400">{{ request.time }}</span>
</SplitViewItem>
</SplitViewGroup>
</template>
<template #detail>
<RequestDetail :request="selected" />
</template>
</SplitView>