Vue3 Descriptions Component Implementation

Overview

The Descriptions component provides a structured way to display key-value pairs in a tabular format. It consists of two parts: the main Descriptions container and individual DescriptionsItem elements.

API Specifications

Descriptions Props

Property Description Type Default
title Title displayed at the top of the description list string undefined
extra Extra actions area positioned at the top right string undefined
bordered Whether to display borders around items boolean false
vertical Whether to use vertical layout boolean false
size Size of the description list 'default' 'middle'
column Number of columns per row, supports responsive objects number Responsive
labelStyle Custom style for labels, overridden by individual items CSSProperties {}
contentStyle Custom style for content, overridden by individual items CSSProperties {}

Responsive Configuration

Name Description Type Default
xs Screen width < 576px number undefined
sm Screen width ≥ 576px number undefined
md Screen width ≥ 768px number undefined
lg Screen width ≥ 992px number undefined
xl Screen width ≥ 1200px number undefined
xxl Screen width ≥ 1600px number undefined

DescriptionsItem Props

Property Description Type Default
label Label text for the item string undefined
span Number of columns this item occupies number undefined
labelStyle Custom style for this item's label CSSProperties {}
contentStyle Custom style for this item's content CSSProperties {}

Slots

Descriptions Slots

Name Description Type
title Custom title content v-slot:title
extra Custom extra actions area v-slot:extra

DescriptionsItem Slots

Name Description Type
label Custom label content v-slot:label
default Custom content v-slot:default

Componant Implementation

Descriptions.vue

<script setup lang="ts">
import { computed, nextTick, ref, watch, onMounted } from 'vue'
import type { CSSProperties } from 'vue'
import { useEventListener, useMutationObserver, useSlotsExist } from 'components/utils'

export interface Responsive {
  xs?: number
  sm?: number
  md?: number
  lg?: number
  xl?: number
  xxl?: number
}

export interface Props {
  title?: string
  extra?: string
  bordered?: boolean
  vertical?: boolean
  size?: 'default' | 'middle' | 'small'
  column?: number | Responsive
  labelStyle?: CSSProperties
  contentStyle?: CSSProperties
}

const props = withDefaults(defineProps<Props>(), {
  title: undefined,
  extra: undefined,
  bordered: false,
  vertical: false,
  size: 'default',
  column: () => ({ xs: 1, sm: 2, md: 3 }),
  labelStyle: () => ({}),
  contentStyle: () => ({})
})

const defaultSlotsRef = ref()
const defaultSlots = ref(true)
const stopObservation = ref(true)
const children = ref<any[]>()
const tdCols = ref()
const thVerticalCols = ref()
const tdVerticalCols = ref()
const trBorderedRows = ref()
const thVerticalBorderedRows = ref()
const tdVerticalBorderedRows = ref()
const groupItems = ref<any[]>([])
const viewportWidth = ref(window.innerWidth)
const slotsExist = useSlotsExist(['title', 'extra'])

const showHeader = computed(() => {
  return slotsExist.title || slotsExist.extra || props.title || props.extra
})

const responsiveColumn = computed(() => {
  if (typeof props.column === 'object') {
    if (viewportWidth.value >= 1600 && props.column.xxl !== undefined) {
      return props.column.xxl
    }
    if (viewportWidth.value >= 1200 && props.column.xl !== undefined) {
      return props.column.xl
    }
    if (viewportWidth.value >= 992 && props.column.lg !== undefined) {
      return props.column.lg
    }
    if (viewportWidth.value >= 768 && props.column.md !== undefined) {
      return props.column.md
    }
    if (viewportWidth.value >= 576 && props.column.sm !== undefined) {
      return props.column.sm
    }
    if (viewportWidth.value < 576 && props.column.xs !== undefined) {
      return props.column.xs
    }
    return 1
  }
  return props.column
})

watch(
  () => [props.bordered, props.vertical, responsiveColumn.value, props.labelStyle, props.contentStyle],
  () => {
    if (!stopObservation.value) {
      stopObservation.value = true
    }
    refreshDefaultSlots()
  },
  {
    deep: true
  }
)

useEventListener(window, 'resize', getViewportWidth)

useMutationObserver(
  defaultSlotsRef,
  (MutationRecord: MutationRecord[]) => {
    if (!stopObservation.value) {
      stopObservation.value = true
      const mutation = MutationRecord.some((mutation: any) => mutation.type === 'childList')
      if (mutation) {
        refreshDefaultSlots()
      }
    }
  },
  { subtree: true, childList: true, attributes: true }
)

onMounted(() => {
  getGroupItems()
})

function getViewportWidth() {
  viewportWidth.value = window.innerWidth
}

async function refreshDefaultSlots() {
  defaultSlots.value = !defaultSlots.value
  await nextTick()
  getGroupItems()
}

function getTotalSpan(group: any): number {
  return group.reduce((accumulator: number, currentValue: any) => accumulator + currentValue.span, 0)
}

async function getGroupItems() {
  children.value = Array.from(defaultSlotsRef.value.children).filter((element: any) => {
    return element.className === (props.bordered ? 'descriptions-item-bordered' : 'descriptions-item')
  })

  if (groupItems.value.length) {
    groupItems.value = []
    await nextTick()
  }

  if (children.value && children.value.length) {
    const len = children.value.length
    let group: any[] = []
    for (let n = 0; n < len; n++) {
      const item = {
        span: Math.min(children.value[n].dataset.span ?? 1, responsiveColumn.value),
        element: children.value[n]
      }
      if (getTotalSpan(group) < responsiveColumn.value) {
        item.span = Math.min(item.span, responsiveColumn.value - getTotalSpan(group))
        group.push(item)
      } else {
        groupItems.value.push(group)
        group = [item]
      }
    }

    if (!props.vertical && !children.value[len - 1].dataset.span && getTotalSpan(group) < responsiveColumn.value) {
      const groupLen = group.length
      group[groupLen - 1].span = group[groupLen - 1].span + responsiveColumn.value - getTotalSpan(group)
    }
    groupItems.value.push(group)
    await nextTick()
    updateDescriptions()
  } else {
    stopObservation.value = false
  }
}

async function updateDescriptions() {
  if (props.bordered) {
    groupItems.value.forEach((items: any, index: number) => {
      items.forEach((item: any) => {
        const itemChildren: any[] = Array.from(item.element.children)
        const th = itemChildren[0]
        setStyle(th, props.labelStyle)
        const td = itemChildren[1]
        setStyle(td, props.contentStyle)

        if (props.vertical) {
          th.colSpan = item.span
          td.colSpan = item.span
          thVerticalBorderedRows.value[index].appendChild(th)
          tdVerticalBorderedRows.value[index].appendChild(td)
        } else {
          th.colSpan = 1
          td.colSpan = item.span * 2 - 1
          trBorderedRows.value[index].appendChild(th)
          trBorderedRows.value[index].appendChild(td)
        }
      })
    })
  } else {
    (children.value as any[]).forEach((element: any, index: number) => {
      const elementChildren: any[] = Array.from(element.children)
      const label = elementChildren[0]
      setStyle(label, props.labelStyle)
      const content = elementChildren[1]
      setStyle(content, props.contentStyle)

      if (props.vertical) {
        thVerticalCols.value[index].appendChild(element.firstChild)
        tdVerticalCols.value[index].appendChild(element.lastChild)
      } else {
        tdCols.value[index].appendChild(element)
      }
    })
  }
  await nextTick()
  stopObservation.value = false
}

function setStyle(element: any, styles: any) {
  if (JSON.stringify(styles) !== '{}') {
    Object.keys(styles).forEach((key: string) => {
      if (!element.style[key]) {
        element.style[key] = styles[key]
      }
    })
  }
}
</script>

<template>
  <div class="descriptions-wrap" :class="`descriptions-${size}`">
    <div class="descriptions-header" v-if="showHeader">
      <div class="descriptions-title">
        <slot name="title">{{ title }}</slot>
      </div>
      <div class="descriptions-extra">
        <slot name="extra">{{ extra }}</slot>
      </div>
    </div>
    <div v-if="!vertical" class="descriptions-view" :class="{ 'descriptions-bordered': bordered }">
      <table>
        <tbody v-if="!bordered">
          <tr v-for="(items, row) in groupItems" :key="row">
            <td
              ref="tdCols"
              class="descriptions-item-td"
              :colspan="item.span"
              v-for="(item, col) in items"
              :key="col"
            ></td>
          </tr>
        </tbody>
        <tbody v-else>
          <tr ref="trBorderedRows" class="descriptions-bordered-tr" v-for="row of groupItems.length" :key="row"></tr>
        </tbody>
      </table>
    </div>
    <div v-else class="descriptions-view" :class="{ 'descriptions-bordered': bordered }">
      <table>
        <tbody v-if="!bordered">
          <template v-for="(items, row) in groupItems" :key="row">
            <tr>
              <th class="descriptions-item-th" :colspan="item.span" v-for="(item, col) in items" :key="col">
                <div ref="thVerticalCols" class="descriptions-item"></div>
              </th>
            </tr>
            <tr>
              <td class="descriptions-item-td" :colspan="item.span" v-for="(item, col) in items" :key="col">
                <div ref="tdVerticalCols" class="descriptions-item"></div>
              </td>
            </tr>
          </template>
        </tbody>
        <tbody v-else>
          <template v-for="row in groupItems.length" :key="row">
            <tr ref="thVerticalBorderedRows" class="descriptions-bordered-tr"></tr>
            <tr ref="tdVerticalBorderedRows" class="descriptions-bordered-tr"></tr>
          </template>
        </tbody>
      </table>
    </div>
    <div ref="defaultSlotsRef" v-show="false">
      <slot v-if="defaultSlots"></slot>
      <slot v-else></slot>
    </div>
  </div>
</template>

<style lang="less" scoped>
.descriptions-wrap {
  font-size: 14px;
  color: rgba(0, 0, 0, 0.88);
  line-height: 1.5714285714285714;
  .descriptions-header {
    display: flex;
    align-items: center;
    margin-bottom: 20px;
    .descriptions-title {
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
      flex: auto;
      font-weight: 600;
      font-size: 16px;
      color: rgba(0, 0, 0, 0.88);
      line-height: 1.5714285714285714;
    }
    .descriptions-extra {
      margin-left: auto;
      color: rgba(0, 0, 0, 0.88);
      font-size: 14px;
    }
  }
  .descriptions-view {
    width: 100%;
    border-radius: 8px;
    table {
      width: 100%;
      table-layout: fixed;
      display: table;
      border-collapse: separate;
      margin: 0;
      tr {
        border: none;
        background: transparent;
      }
      .descriptions-item-th {
        padding: 0;
        border: none;
        padding-bottom: 16px;
        vertical-align: top;
        background: transparent;
      }
      .descriptions-item-td {
        padding: 0;
        border: none;
        padding-bottom: 16px;
        vertical-align: top;
      }
      .descriptions-item {
        display: flex;
      }
    }
  }
  .descriptions-bordered {
    border: 1px solid rgba(5, 5, 5, 0.06);
    table {
      table-layout: auto;
      border-collapse: collapse;
      display: table;
      margin: 0;
      .descriptions-bordered-tr {
        border-bottom: 1px solid rgba(5, 5, 5, 0.06);
        &:last-child {
          border-bottom: none;
        }
        :deep(.descriptions-label-th) {
          border: none;
          color: rgba(0, 0, 0, 0.88);
          font-weight: normal;
          font-size: 14px;
          line-height: 1.5714285714285714;
          text-align: start;
          background-color: rgba(0, 0, 0, 0.02);
          padding: 16px 24px;
          border-right: 1px solid rgba(5, 5, 5, 0.06);
          &:last-child {
            border-right: none;
          }
        }
        :deep(.descriptions-content-td) {
          border: none;
          display: table-cell;
          flex: 1;
          padding: 16px 24px;
          border-right: 1px solid rgba(5, 5, 5, 0.06);
          color: rgba(0, 0, 0, 0.88);
          font-size: 14px;
          line-height: 1.5714285714285714;
          word-break: break-word;
          overflow-wrap: break-word;
          &:last-child {
            border-right: none;
          }
        }
      }
    }
  }
}

.descriptions-middle {
  .descriptions-view {
    .descriptions-item-td {
      padding-bottom: 12px !important;
    }
  }
  .descriptions-bordered {
    :deep(.descriptions-label-th) {
      padding: 12px 24px !important;
    }
    :deep(.descriptions-content-td) {
      padding: 12px 24px !important;
    }
  }
}

.descriptions-small {
  .descriptions-view {
    .descriptions-item-td {
      padding-bottom: 8px !important;
    }
  }
  .descriptions-bordered {
    :deep(.descriptions-label-th) {
      padding: 8px 16px !important;
    }
    :deep(.descriptions-content-td) {
      padding: 8px 16px !important;
    }
  }
}
</style>

DescriptionsItem.vue

<script setup lang="ts">
import type { CSSProperties } from 'vue'

interface Props {
  label?: string
  span?: number
  labelStyle?: CSSProperties
  contentStyle?: CSSProperties
}

withDefaults(defineProps<Props>(), {
  label: undefined,
  span: undefined,
  labelStyle: () => ({}),
  contentStyle: () => ({})
})
</script>

<template>
  <div class="descriptions-item" :data-span="span">
    <span class="descriptions-label" :style="labelStyle">
      <slot name="label">{{ label }}</slot>
    </span>
    <span class="descriptions-content" :style="contentStyle">
      <slot></slot>
    </span>
  </div>
  <tr class="descriptions-item-bordered" :data-span="span">
    <th class="descriptions-label-th" :style="labelStyle">
      <slot name="label">{{ label }}</slot>
    </th>
    <td class="descriptions-content-td" :style="contentStyle">
      <slot></slot>
    </td>
  </tr>
</template>

<style lang="less" scoped>
descriptions-item {
  display: flex;
  .descriptions-label {
    display: inline-flex;
    align-items: baseline;
    color: rgba(0, 0, 0, 0.88);
    font-weight: normal;
    font-size: 14px;
    line-height: 1.5714285714285714;
    text-align: start;
    &::after {
      content: ':';
      position: relative;
      top: -0.5px;
      margin-inline: 2px 8px;
    }
  }
  .descriptions-content {
    display: inline-flex;
    align-items: baseline;
    flex: 1;
    color: rgba(0, 0, 0, 0.88);
    font-size: 14px;
    line-height: 1.5714285714285714;
    word-break: break-word;
    overflow-wrap: break-word;
  }
}
</style>

Usage Example

<script setup lang="ts">
import Descriptions from './Descriptions.vue'
import DescriptionsItem from './DescriptionsItem.vue'
import { ref, reactive } from 'vue'

const size = ref('default')
const options = [
  {
    label: 'default',
    value: 'default'
  },
  {
    label: 'middle',
    value: 'middle'
  },
  {
    label: 'small',
    value: 'small'
  }
]

const state = reactive({
  title: 'User Info',
  extra: 'extra',
  bordered: false,
  vertical: false,
  size: 'default',
  column: {
    xs: 1,
    sm: 2,
    md: 3,
    lg: 3,
    xl: 3,
    xxl: 3
  },
  labelStyle: {
    fontSize: '14px',
    color: '#FF6900',
    fontWeight: 600
  },
  contentStyle: {
    fontSize: '14px',
    color: '#1677FF',
    fontWeight: 400
  }
})
</script>

<template>
  <div>
    <Descriptions title="User Info">
      <template #extra>
        <a href="#" @click="onClick">more</a>
      </template>
      <DescriptionsItem label="UserName">Zhou Maomao</DescriptionsItem>
      <DescriptionsItem label="Telephone">1810000000</DescriptionsItem>
      <DescriptionsItem label="Live">Hangzhou, Zhejiang</DescriptionsItem>
      <DescriptionsItem label="Remark">empty</DescriptionsItem>
      <DescriptionsItem label="Address">
        No. 18, Wantang Road, Xihu District, Hangzhou, Zhejiang, China
      </DescriptionsItem>
    </Descriptions>
    
    <Descriptions title="User Info" bordered>
      <DescriptionsItem label="Product">Cloud Database</DescriptionsItem>
      <DescriptionsItem label="Billing Mode">Prepaid</DescriptionsItem>
      <DescriptionsItem label="Automatic Renewal">YES</DescriptionsItem>
      <DescriptionsItem label="Order time">2018-04-24 18:00:00</DescriptionsItem>
      <DescriptionsItem label="Usage Time" :span="2">2030-04-24 18:00:00</DescriptionsItem>
      <DescriptionsItem label="Status" :span="3">
        <Badge status="processing" ripple text="Running" />
      </DescriptionsItem>
      <DescriptionsItem label="Negotiated Amount">$80.00</DescriptionsItem>
      <DescriptionsItem label="Discount">$20.00</DescriptionsItem>
      <DescriptionsItem label="Official Receipts">$60.00</DescriptionsItem>
      <DescriptionsItem label="Config Info">
        Data disk type: MongoDB
        <br />
        Database version: 3.4
        <br />
        Package: dds.mongo.mid
        <br />
        Storage space: 10 GB
        <br />
        Replication factor: 3
        <br />
        Region: East China 1
        <br />
      </DescriptionsItem>
    </Descriptions>
  </div>
</template>

Tags: vue3 descriptions Component UI Table

Posted on Sun, 12 Jul 2026 16:36:35 +0000 by jurdygeorge