Skip to content

Card

A versatile and feature-rich card container component with purposeful variants, color themes, elevation control, and interactive states. Perfect for grouping related content, creating structured layouts, and building modern UI patterns. Built with accessibility in mind and fully customizable through CSS custom properties.

Features

  • 🎨 4 Variants: solid, flat, glass, frost
  • 🌈 6 Color Themes: primary, secondary, info, success, warning, error
  • 📏 5 Padding Sizes: none, sm, md, lg, xl
  • 📊 6 Elevation Levels: 0-5 for precise shadow control
  • 🖼️ 5 Slots: media, header, content, footer, actions
  • 📱 Horizontal orientation for side-by-side media layouts
  • 🎯 Interactive States: interactive, disabled, loading
  • Fully Accessible: WCAG 2.1 Level AA compliant with keyboard navigation
  • 🔧 Customizable: CSS custom properties for complete control
  • Custom Events: Emits activate with event details (trigger, originalEvent)

Source Code

View Source Code
ts
import { defineComponent, handle, html, onMount, onSlotChange, ref, signal, watch } from '@vielzeug/craftit';

import type { ElevationLevel, PaddingSize, ThemeColor } from '../../types';

import { frostVariantMixin, reducedMotionMixin, surfaceMixins } from '../../styles';

const INTERACTIVE_DESCENDANT_SELECTOR =
  'button, a[href], input, select, textarea, summary, [role="button"], [role="link"], [contenteditable=""], [contenteditable="true"]';

function slotHasMeaningfulContent(slot: HTMLSlotElement | null | undefined): boolean {
  if (!slot) return false;

  return slot.assignedNodes({ flatten: true }).some((node) => {
    if (node.nodeType === Node.ELEMENT_NODE) return true;

    if (node.nodeType === Node.TEXT_NODE) return !!node.textContent?.trim();

    return false;
  });
}

function isNestedInteractiveTarget(host: HTMLElement, event: Event): boolean {
  for (const node of event.composedPath()) {
    if (!(node instanceof HTMLElement)) continue;

    if (node === host) return false;

    if (node.matches(INTERACTIVE_DESCENDANT_SELECTOR) || !!node.closest(INTERACTIVE_DESCENDANT_SELECTOR)) {
      return true;
    }
  }

  return false;
}

import componentStyles from './card.css?inline';

/** Card component properties */

export type BitCardEvents = {
  activate: { originalEvent: MouseEvent | KeyboardEvent; trigger: 'pointer' | 'keyboard' };
};

export type BitCardProps = {
  /** Theme color */
  color?: ThemeColor;
  /** Disable interaction */
  disabled?: boolean;
  /** Shadow elevation level (0-5) */
  elevation?: `${ElevationLevel}`;
  /** Make the card interactive (role=button, keyboard nav, emits activate) */
  interactive?: boolean;
  /** Show a loading progress bar */
  loading?: boolean;
  /** Card orientation */
  orientation?: 'horizontal';
  /** Internal padding size */
  padding?: PaddingSize;
  /** Visual style variant */
  variant?: 'solid' | 'flat' | 'glass' | 'frost';
};

/**
 * A versatile card container with semantic slots for media, header, body, footer, and actions.
 *
 * @element bit-card
 *
 * @attr {string} variant - Visual variant: 'solid' | 'flat' | 'glass' | 'frost'
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string} padding - Internal padding: 'none' | 'sm' | 'md' | 'lg' | 'xl'
 * @attr {string} elevation - Shadow elevation: '0' | '1' | '2' | '3' | '4' | '5'
 * @attr {string} orientation - Card layout: 'horizontal'
 * @attr {boolean} interactive - Enable pointer/keyboard activation
 * @attr {boolean} disabled - Disable card interaction
 * @attr {boolean} loading - Show loading progress bar
 *
 * @fires activate - Emitted when an interactive card is activated
 *
 * @slot media - Media content displayed at top/left
 * @slot header - Card header (title, subtitle)
 * @slot - Main card content
 * @slot footer - Card footer content
 * @slot actions - Action buttons or links
 *
 * @cssprop --card-bg - Background color
 * @cssprop --card-color - Text color
 * @cssprop --card-border - Border width
 * @cssprop --card-border-color - Border color
 * @cssprop --card-radius - Border radius
 * @cssprop --card-padding - Internal padding
 * @cssprop --card-shadow - Box shadow
 * @cssprop --card-hover-shadow - Shadow on hover
 *
 * @example
 * ```html
 * <bit-card elevation="2"><h3 slot="header">Title</h3><p>Content</p></bit-card>
 * <bit-card interactive color="primary"><h3 slot="header">Click me</h3></bit-card>
 * <bit-card variant="frost" color="secondary">Frosted card</bit-card>
 * ```
 */
export const CARD_TAG = defineComponent<BitCardProps, BitCardEvents>({
  props: {
    color: { default: undefined },
    disabled: { default: false, type: Boolean },
    elevation: { default: undefined },
    interactive: { default: false, type: Boolean },
    loading: { default: false, type: Boolean },
    orientation: { default: undefined },
    padding: { default: undefined },
    variant: { default: undefined },
  },
  setup({ emit, host, props }) {
    const mediaSlot = ref<HTMLSlotElement>();
    const headerSlot = ref<HTMLSlotElement>();
    const contentSlot = ref<HTMLSlotElement>();
    const footerSlot = ref<HTMLSlotElement>();
    const actionsSlot = ref<HTMLSlotElement>();
    const hasMedia = signal(false);
    const hasHeader = signal(false);
    const hasContent = signal(false);
    const hasFooter = signal(false);
    const hasActions = signal(false);

    function updateSlotState() {
      hasMedia.value = slotHasMeaningfulContent(mediaSlot.value);
      hasHeader.value = slotHasMeaningfulContent(headerSlot.value);
      hasContent.value = slotHasMeaningfulContent(contentSlot.value);
      hasFooter.value = slotHasMeaningfulContent(footerSlot.value);
      hasActions.value = slotHasMeaningfulContent(actionsSlot.value);
    }
    onMount(() => {
      onSlotChange('media', updateSlotState);
      onSlotChange('header', updateSlotState);
      onSlotChange('default', updateSlotState);
      onSlotChange('footer', updateSlotState);
      onSlotChange('actions', updateSlotState);
    });
    watch(
      [props.interactive, props.disabled, props.loading, props.variant, props.color, props.padding, props.orientation],
      () => {
        if (props.interactive.value) {
          host.setAttribute('role', 'button');
          host.setAttribute('tabindex', props.disabled.value ? '-1' : '0');
          host.setAttribute('aria-disabled', String(props.disabled.value));
        } else {
          host.removeAttribute('role');
          host.removeAttribute('tabindex');
          host.removeAttribute('aria-disabled');
        }

        host.setAttribute('aria-busy', props.loading.value ? 'true' : 'false');
      },
      { immediate: true },
    );

    const handleClick = (e: MouseEvent) => {
      if (!props.interactive.value || props.disabled.value) return;

      if (isNestedInteractiveTarget(host, e)) return;

      emit('activate', { originalEvent: e, trigger: 'pointer' });
    };
    const handleKeydown = (e: KeyboardEvent) => {
      if (!props.interactive.value || props.disabled.value) return;

      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        emit('activate', { originalEvent: e, trigger: 'keyboard' });
      }
    };

    handle(host, 'click', handleClick);
    handle(host, 'keydown', handleKeydown);

    return html`
      <div class="card" part="card">
        <div class="loading-bar" part="loading-bar"></div>
        <div class="card-media" part="media" ?hidden="${() => !hasMedia.value}">
          <slot ref=${mediaSlot} name="media"></slot>
        </div>
        <div class="card-body" part="body">
          <div class="card-header" part="header" ?hidden="${() => !hasHeader.value}">
            <slot ref=${headerSlot} name="header"></slot>
          </div>
          <div class="card-content" part="content" ?hidden="${() => !hasContent.value}">
            <slot ref=${contentSlot}></slot>
          </div>
          <div class="card-footer" part="footer" ?hidden="${() => !hasFooter.value}">
            <slot ref=${footerSlot} name="footer"></slot>
          </div>
          <div class="card-actions" part="actions" ?hidden="${() => !hasActions.value}">
            <slot ref=${actionsSlot} name="actions"></slot>
          </div>
        </div>
      </div>
    `;
  },
  styles: [...surfaceMixins, frostVariantMixin('.card'), reducedMotionMixin, componentStyles],
  tag: 'bit-card',
});

Basic Usage

html
<bit-card>
  <img slot="media" src="/hero.jpg" alt="Card hero image" />
  <bit-text slot="header" variant="heading" size="md">Card Title</bit-text>
  <bit-text>This is the card content. It can contain any HTML elements.</bit-text>
  <bit-text slot="footer" size="sm" variant="caption">Additional information</bit-text>
  <div slot="actions">
    <bit-button size="sm">Primary</bit-button>
    <bit-button size="sm" variant="ghost">Secondary</bit-button>
  </div>
</bit-card>

<script type="module">
  import '@vielzeug/buildit/card';
  import '@vielzeug/buildit/button';
  import '@vielzeug/buildit/text';
</script>

Visual Options

Variants

Four named variants cover the full range from solid to translucent:

  • Default (no variant) - Canvas background with gentle border. Picks up color as a tinted backdrop.
  • solid - Filled with the theme color; best for prominent cards with a color attribute.
  • flat - Subtle backdrop tint with a semi-transparent border; low visual weight.
  • glass - Glassmorphism with backdrop blur and inset shadow; great for overlays.
  • frost - Frosted glass with stronger blur and color-tinted transparency.

Elevation Control

Use the elevation prop (0-5) to control shadow depth. Works with any variant.

html
<!-- No shadow -->
<bit-card elevation="0">Flat appearance</bit-card>

<!-- High elevation -->
<bit-card elevation="4">High shadow</bit-card>
PreviewCode
RTL

Frost Variant

Modern frost effect with backdrop blur that adapts based on color:

  • Without color: Subtle canvas-based frost overlay
  • With color: Frosted glass effect with colored tint

Best Used With

Frost variant works best when placed over colorful backgrounds or images to showcase the blur and transparency effects.

PreviewCode
RTL

Padding Sizes

Control the internal spacing of the card.

PreviewCode
RTL

Color Themes

Apply semantic color themes to cards for different contexts.

PreviewCode
RTL

Elevation Levels

Control shadow depth with explicit elevation levels (0-5).

PreviewCode
RTL

Orientation

Set orientation="horizontal" for a side-by-side media + content layout. The vertical layout is the default and requires no attribute.

PreviewCode
RTL

Media & Actions Slots

Media Slot

Add images, videos, or custom content at the top of the card (or left side in horizontal orientation).

PreviewCode
RTL

Actions Slot

Separate slot for action buttons with automatic layout.

PreviewCode
RTL

States

Disabled State

Prevent interaction and show visual feedback.

PreviewCode
RTL

Loading State

Show an animated loading indicator while content is being fetched.

PreviewCode
RTL

Interactive Cards

Set interactive to enable hover/active states, keyboard activation (Enter/Space), and typed activate events.

PreviewCode
RTL

Practical Examples

User Profile Card

PreviewCode
RTL

Product Card

PreviewCode
RTL

Status Card

PreviewCode
RTL

Stats Card

PreviewCode
RTL

Horizontal Product List

Perfect for compact layouts and list views:

PreviewCode
RTL

API Reference

Attributes

AttributeTypeDefaultDescription
variant'solid' | 'flat' | 'glass' | 'frost'Visual style variant
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Color theme for the card
padding'none' | 'sm' | 'md' | 'lg' | 'xl'Internal padding size
elevation'0' | '1' | '2' | '3' | '4' | '5'Shadow elevation level (0=none, 5=maximum)
orientation'horizontal'Side-by-side media + content layout
interactivebooleanfalseEnable hover/active states and activation
disabledbooleanfalseDisable card interaction
loadingbooleanfalseShow animated loading bar at the top

Slots

SlotDescription
(default)Main content area of the card
mediaMedia section (images/video) at the top
headerHeader section below media
footerFooter section at the bottom of the card
actionsAction buttons section (typically in footer)

Events

EventDetailDescription
clickNative browser click (always available)
activate{ trigger: 'pointer' | 'keyboard', originalEvent: MouseEvent | KeyboardEvent }Emitted when an interactive card is activated

CSS Custom Properties

PropertyDefaultDescription
--card-bgvar(--color-canvas)Background color
--card-colorvar(--color-contrast-900)Text color
--card-bordervar(--border)Border width
--card-border-colorvar(--color-contrast-300)Border color
--card-radiusvar(--rounded-lg)Border radius
--card-paddingvar(--size-4)Internal padding
--card-shadowvar(--shadow-sm)Box shadow
--card-hover-shadowvar(--shadow-md)Hover state shadow

Customization

Custom Styling

html
<bit-card
  style="
    --card-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    --card-color: white;
    --card-radius: var(--rounded-2xl);
    --card-padding: var(--size-8);
  ">
  <bit-text slot="header" variant="heading" size="md">Custom Styled Card</bit-text>
  <bit-text>This card has custom colors and spacing.</bit-text>
</bit-card>

Custom Shadow

html
<bit-card
  style="
    --card-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
    --card-hover-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
  "
  interactive>
  <bit-text slot="header" variant="heading" size="md">Enhanced Shadow</bit-text>
  <bit-text>Hover to see the custom shadow effect.</bit-text>
</bit-card>

Accessibility

The card component follows WCAG 2.1 Level AA standards.

bit-card

Keyboard Navigation

  • Enter / Space activate the card when interactive is set.
  • Tab moves focus to the card.
  • Disabled cards have tabindex="-1" and cannot receive focus.

Screen Readers

  • role="button" is applied when interactive is set; aria-disabled reflects the disabled state.
  • aria-busy reflects the loading state.
  • Proper content hierarchy with semantic slots for screen reader users.

Semantic Structure

  • Uses semantic HTML for proper content organization.
  • Compliant with WCAG 2.1 Level AA color contrast requirements.

Best Practices

  1. Use semantic headings in the header slot to maintain proper document structure.
  2. Add meaningful alt text for images in the media slot.
  3. Make a card interactive only when the whole card acts as a single action.
  4. If you need inner actions, place buttons/links in the actions slot; nested interactive elements do not trigger card activation.
  5. Maintain color contrast when using custom --card-bg overrides.
  6. Use disabled instead of removing interactive cards from the DOM.
  7. Use the loading state to provide visual feedback during async operations.