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