Skip to content
  1. Page structure
  2. Divider

Divider

A line that separates two blocks of content, across the page or between items in a row.

Invoice defaults

Payment terms and footer printed on every new invoice.

Tax

VAT number and the rate applied to customers in your country.

import { Divider } from '@/components/divider'
import { Subheading } from '@/components/heading'
import { Text } from '@/components/text'

export default function Example() {
  return (
    <div>
      <Subheading>Invoice defaults</Subheading>
      <Text>Payment terms and footer printed on every new invoice.</Text>
      <Divider className="my-8" />
      <Subheading>Tax</Subheading>
      <Text>VAT number and the rate applied to customers in your country.</Text>
    </div>
  )
}
<script setup lang="ts">
import Divider from '@/components/divider.vue'
import Subheading from '@/components/subheading.vue'
import Text from '@/components/text.vue'
</script>

<template>
  <div>
    <Subheading>Invoice defaults</Subheading>
    <Text>Payment terms and footer printed on every new invoice.</Text>
    <Divider class="my-8" />
    <Subheading>Tax</Subheading>
    <Text>VAT number and the rate applied to customers in your country.</Text>
  </div>
</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.

import clsx from 'clsx'
import type React from 'react'

type DividerProps = {
  soft?: boolean
  orientation?: 'horizontal' | 'vertical'
} & React.HTMLAttributes<HTMLElement>

export function Divider({ soft = false, orientation = 'horizontal', className, children, ...props }: DividerProps) {
  let vertical = orientation === 'vertical'
  let border = soft ? 'border-zinc-950/5 dark:border-white/5' : 'border-zinc-950/10 dark:border-white/10'
  let line = vertical ? 'w-0 self-stretch border-l' : 'w-full border-t'

  if (children) {
    let segment = <span aria-hidden="true" className={clsx('flex-1', vertical ? 'border-l' : 'border-t', border)} />

    return (
      <div
        {...props}
        className={clsx(
          className,
          'flex items-center gap-3 text-sm/6 whitespace-nowrap text-zinc-500 dark:text-zinc-400',
          vertical ? 'flex-col self-stretch' : 'w-full'
        )}
      >
        {segment}
        {children}
        {segment}
      </div>
    )
  }

  return <hr role="presentation" {...props} className={clsx(className, line, border, vertical && 'h-auto border-t-0')} />
}
<script setup lang="ts">
import { computed } from 'vue'

const props = withDefaults(defineProps<{ soft?: boolean; orientation?: 'horizontal' | 'vertical' }>(), {
  soft: false,
  orientation: 'horizontal',
})

const vertical = computed(() => props.orientation === 'vertical')

const border = computed(() =>
  props.soft ? 'border-zinc-950/5 dark:border-white/5' : 'border-zinc-950/10 dark:border-white/10'
)

const line = computed(() => (vertical.value ? 'w-0 self-stretch border-l' : 'w-full border-t'))

const segment = computed(() => ['flex-1', vertical.value ? 'border-l' : 'border-t', border.value])
</script>

<template>
  <div
    v-if="$slots.default"
    :class="[
      'flex items-center gap-3 text-sm/6 whitespace-nowrap text-zinc-500 dark:text-zinc-400',
      vertical ? 'flex-col self-stretch' : 'w-full',
    ]"
  >
    <span aria-hidden="true" :class="segment" />
    <slot />
    <span aria-hidden="true" :class="segment" />
  </div>
  <hr v-else role="presentation" :class="[line, border, vertical && 'h-auto border-t-0']" />
</template>

Component API

PropDefaultDescription
Dividerrenders a <hr> element
softfalseDraws the line at half the usual contrast.
orientation'horizontal'`'vertical'` draws the line top to bottom and stretches it to the height of the row it sits in.
children-A short label centered on the line, such as “or”. The divider then renders a `div` with the label between two lines.

Usage

Try white space first. A line earns its place when two blocks sit close together but cover different topics, like two groups of settings in one form. The divider spans the full width and has no margin of its own, so set the gap around it where you use it.

Split a settings form

A my-* utility on the divider sets the same gap above and below, which keeps each group visually tied to its own title:

Invoice defaults

Payment terms and footer printed on every new invoice.

Tax

VAT number and the rate applied to customers in your country.

import { Divider } from '@/components/divider'
import { Subheading } from '@/components/heading'
import { Text } from '@/components/text'

export default function Example() {
  return (
    <div>
      <Subheading>Invoice defaults</Subheading>
      <Text>Payment terms and footer printed on every new invoice.</Text>
      <Divider className="my-8" />
      <Subheading>Tax</Subheading>
      <Text>VAT number and the rate applied to customers in your country.</Text>
    </div>
  )
}
<script setup lang="ts">
import Divider from '@/components/divider.vue'
import Subheading from '@/components/subheading.vue'
import Text from '@/components/text.vue'
</script>

<template>
  <div>
    <Subheading>Invoice defaults</Subheading>
    <Text>Payment terms and footer printed on every new invoice.</Text>
    <Divider class="my-8" />
    <Subheading>Tax</Subheading>
    <Text>VAT number and the rate applied to customers in your country.</Text>
  </div>
</template>

Variants

Lighter lines inside a card

Inside a bordered card, a full-contrast line competes with the card edge. soft halves the opacity so the rows stay grouped without a heavy grid:

Plan Scale

Next invoice Oct 1, 2026

<div className="rounded-lg border border-zinc-950/10 p-4 dark:border-white/10">
  <p className="flex justify-between text-sm/6 text-zinc-950 dark:text-white">
    <span className="text-zinc-500 dark:text-zinc-400">Plan</span> Scale
  </p>
  <Divider soft className="my-3" />
  <p className="flex justify-between text-sm/6 text-zinc-950 dark:text-white">
    <span className="text-zinc-500 dark:text-zinc-400">Next invoice</span> Oct 1, 2026
  </p>
</div>
<div class="rounded-lg border border-zinc-950/10 p-4 dark:border-white/10">
  <p class="flex justify-between text-sm/6 text-zinc-950 dark:text-white">
    <span class="text-zinc-500 dark:text-zinc-400">Plan</span> Scale
  </p>
  <Divider soft class="my-3" />
  <p class="flex justify-between text-sm/6 text-zinc-950 dark:text-white">
    <span class="text-zinc-500 dark:text-zinc-400">Next invoice</span> Oct 1, 2026
  </p>
</div>

Between items in a row

orientation set to vertical turns the line upright and stretches it to the height of the row. Here it sets the publish action apart from the two checks before it:

import { Button } from '@/components/button'
import { Divider } from '@/components/divider'

export default function Example() {
  return (
    <div className="flex items-center gap-4">
      <Button plain>Preview</Button>
      <Button plain>Send test</Button>
      <Divider orientation="vertical" className="my-1" />
      <Button>Publish</Button>
    </div>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Divider from '@/components/divider.vue'
</script>

<template>
  <div class="flex items-center gap-4">
    <Button plain>Preview</Button>
    <Button plain>Send test</Button>
    <Divider orientation="vertical" class="my-1" />
    <Button>Publish</Button>
  </div>
</template>

Two ways to the same goal

Pass a label as children to center it on the line. A short word like “or” tells people they can take one path or the other:

or
import { FingerPrintIcon } from '@heroicons/react/16/solid'
import { Button } from '@/components/button'
import { Divider } from '@/components/divider'
import { Field, Label } from '@/components/fieldset'
import { Input } from '@/components/input'

export default function Example() {
  return (
    <form className="space-y-6">
      <Button type="button" color="light" className="w-full">
        <FingerPrintIcon />
        Sign in with a passkey
      </Button>
      <Divider>or</Divider>
      <Field>
        <Label>Work email</Label>
        <Input type="email" name="email" autoComplete="email" />
      </Field>
      <Button type="submit" className="w-full">
        Email me a sign-in link
      </Button>
    </form>
  )
}
<script setup lang="ts">
import { FingerPrintIcon } from '@heroicons/vue/16/solid'
import Button from '@/components/button.vue'
import Divider from '@/components/divider.vue'
import Field from '@/components/field.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
</script>

<template>
  <form class="space-y-6">
    <Button type="button" color="light" class="w-full">
      <FingerPrintIcon />
      Sign in with a passkey
    </Button>
    <Divider>or</Divider>
    <Field>
      <Label>Work email</Label>
      <Input type="email" name="email" autocomplete="email" />
    </Field>
    <Button type="submit" class="w-full">Email me a sign-in link</Button>
  </form>
</template>

Side by side choices

The label works on a vertical line too. Between two panels in a row, the line stretches to the taller one and the label sits in the middle:

Import a CSV

Upload a file exported from your previous billing tool.

or

Add by hand

Create customers one at a time from a short form.

import { Button } from '@/components/button'
import { Divider } from '@/components/divider'
import { Subheading } from '@/components/heading'
import { Text } from '@/components/text'

export default function Example() {
  return (
    <div className="flex gap-6">
      <div className="flex-1">
        <Subheading>Import a CSV</Subheading>
        <Text>Upload a file exported from your previous billing tool.</Text>
        <Button outline className="mt-4">
          Choose file
        </Button>
      </div>
      <Divider orientation="vertical">or</Divider>
      <div className="flex-1">
        <Subheading>Add by hand</Subheading>
        <Text>Create customers one at a time from a short form.</Text>
        <Button outline className="mt-4">
          New customer
        </Button>
      </div>
    </div>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import Divider from '@/components/divider.vue'
import Subheading from '@/components/subheading.vue'
import Text from '@/components/text.vue'
</script>

<template>
  <div class="flex gap-6">
    <div class="flex-1">
      <Subheading>Import a CSV</Subheading>
      <Text>Upload a file exported from your previous billing tool.</Text>
      <Button outline class="mt-4">Choose file</Button>
    </div>
    <Divider orientation="vertical">or</Divider>
    <div class="flex-1">
      <Subheading>Add by hand</Subheading>
      <Text>Create customers one at a time from a short form.</Text>
      <Button outline class="mt-4">New customer</Button>
    </div>
  </div>
</template>

Accessibility

The rule carries role="presentation", so assistive technology skips it: the break is only visual. With a label, the two lines are hidden and only the label is read, so pick a word that makes sense on its own. When the change of topic matters to the structure of the page, mark it with a Subheading as well.