Skip to content
  1. Layouts
  2. Split view

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

PropDefaultDescription
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
selectedfalseHighlights the row and sets `aria-current`.
size'sm'`sm` for one line, `md` for two lines with an avatar or an icon.
PropDefaultDescription
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.
openfalseUnder `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
selectedfalseHighlights 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>