Skip to content

Card ​

Display content in a card with a header, body and footer.

Demo ​

Card Title

Lorem ipsum dolor sit amet consectetur adipisicing elit. Vel officia fugit repellendus quasi nemo tempora quos possimus aliquid earum voluptates iste doloremque tempore laudantium, placeat nihil quia quis perferendis voluptatem?

This requires the following types to be installed:

This requires the following theme to be installed:

Component ​

Card.vue
vue
<script lang="ts">
import type { ComponentConfig } from '@/ui/utils/utils'
import theme from '@/ui/theme/card'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

type Card = ComponentConfig<typeof theme>

export interface CardProps {
  as?: any
  variant?: Card['variants']['variant']
  class?: any
  ui?: Card['slots']
}

export interface CardSlots {
  header: (props?: object) => any
  default: (props?: object) => any
  footer: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = defineProps<CardProps>()
const slots = defineSlots<CardSlots>()

const ui = computed(() => tv(theme)({
  variant: props.variant,
}))
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div v-if="!!slots.header" :class="ui.header({ class: props.ui?.header })">
      <slot name="header" />
    </div>

    <div v-if="!!slots.default" :class="ui.body({ class: props.ui?.body })">
      <slot />
    </div>

    <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
      <slot name="footer" />
    </div>
  </Primitive>
</template>
Card.vue
vue
<script lang="ts">
import type { ComponentConfig } from '@/UI/Utils/utils'
import theme from '@/UI/Theme/card'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

type Card = ComponentConfig<typeof theme>

export interface CardProps {
  as?: any
  variant?: Card['variants']['variant']
  class?: any
  ui?: Card['slots']
}

export interface CardSlots {
  header: (props?: object) => any
  default: (props?: object) => any
  footer: (props?: object) => any
}
</script>

<script setup lang="ts">
const props = defineProps<CardProps>()
const slots = defineSlots<CardSlots>()

const ui = computed(() => tv(theme)({
  variant: props.variant,
}))
</script>

<template>
  <Primitive :as="as" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div v-if="!!slots.header" :class="ui.header({ class: props.ui?.header })">
      <slot name="header" />
    </div>

    <div v-if="!!slots.default" :class="ui.body({ class: props.ui?.body })">
      <slot />
    </div>

    <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
      <slot name="footer" />
    </div>
  </Primitive>
</template>

Theme ​

card.ts
ts
export default {
  slots: {
    root: '',
    header: '',
    body: '',
    footer: '',
  },
  variants: {
    variant: {
      solid: {
        root: '',
      },
      outline: {
        root: '',
      },
      soft: {
        root: '',
      },
      subtle: {
        root: '',
      },
    },
  },
  defaultVariants: {
    variant: 'outline',
  } as const,
}
View Nuxt UI theme
card.ts
ts
export default {
  slots: {
    root: 'rounded-lg',
    header: 'p-4 sm:px-6',
    body: 'p-4 sm:p-6',
    footer: 'p-4 sm:px-6',
  },
  variants: {
    variant: {
      solid: {
        root: 'bg-inverted text-inverted',
      },
      outline: {
        root: 'bg-default ring ring-default divide-y divide-default',
      },
      soft: {
        root: 'bg-elevated/50 divide-y divide-default',
      },
      subtle: {
        root: 'bg-elevated/50 ring ring-default divide-y divide-default',
      },
    },
  },
  defaultVariants: {
    variant: 'outline',
  } as const,
}

Test ​

To test this component, you can use the following test file:

Card.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Card from '@/ui/components/Card.vue'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('card', () => {
  it.each<[string, RenderOptions<typeof Card>]>([
    // Props
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'rounded-xl' } }],
    ['with ui', { props: { ui: { body: 'font-bold' } } }],
    // Slots
    ['with default slot', { slots: { default: () => 'Default slot' } }],
    ['with header slot', { slots: { header: () => 'Header slot' } }],
    ['with footer slot', { slots: { footer: () => 'Footer slot' } }],
  ])('renders %s correctly', (name, options) => {
    render(Card, {
      attrs: {
        'data-testid': 'card',
      },
      ...options,
    })

    expect(screen.getByTestId('card')).toMatchSnapshot()
  })
})
Card.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Card from '@/UI/Components/Card.vue'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('card', () => {
  it.each<[string, RenderOptions<typeof Card>]>([
    // Props
    ['with as', { props: { as: 'section' } }],
    ['with class', { props: { class: 'rounded-xl' } }],
    ['with ui', { props: { ui: { body: 'font-bold' } } }],
    // Slots
    ['with default slot', { slots: { default: () => 'Default slot' } }],
    ['with header slot', { slots: { header: () => 'Header slot' } }],
    ['with footer slot', { slots: { footer: () => 'Footer slot' } }],
  ])('renders %s correctly', (name, options) => {
    render(Card, {
      attrs: {
        'data-testid': 'card',
      },
      ...options,
    })

    expect(screen.getByTestId('card')).toMatchSnapshot()
  })
})

Contributors ​

barbapapazes

Changelog ​

c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
7d577 - feat: add toast component (#77) on 1/3/2025
84a38 - feat: add card component (#12) on 12/14/2024