bottega

cards

Kanban Task Card

A draggable-ready kanban task card. Shows one or more tag chips at the top-left, a three-dot options button (keyboard-accessible with focus-visible ring), a task description paragraph, and a compact stats row with due-date, comment count, and attachment count icons. A row of overlapping circular viewer avatars with a '+N' overflow count closes the card. On hover the box-shadow deepens and a dashed border in --border fades in — all pure CSS transitions, no JS required. Server-renderable with no hooks or state.

Ported from Yaya12085 (MIT)

DesignUI/UX

Design a new landing page layout for the upcoming product launch campaign.

Feb 24
18
7

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/kanban-task-card

Usage

Usagetsx
<KanbanTaskCard />

Props

PropTypeDefaultDescription
titlestringDesign a new landing page layout for the upcoming product launch campaign.Card task title / description text.
tagsKanbanTaskCardTag[][{ label: "Design" }, { label: "UI/UX" }]Tag chips shown at the top-left of the card.
dueDatestringFeb 24Due date label displayed in the stats row (e.g. "Feb 24").
commentsCountnumber18Number of comments shown in the stats row.
attachmentsCountnumber7Number of file attachments shown in the stats row.
viewerAvatarCountnumber3Number of viewer avatar circles to render (decorative).
viewerCountnumber20Extra viewer count shown after the avatar circles (e.g. 20 → "+20").

Source

kanban-task-card.tsxtsx
import React from "react";
import styles from "./kanban-task-card.module.css";

export interface KanbanTaskCardTag {
  label: string;
}

export interface KanbanTaskCardProps {
  /** Card task title / description text. */
  title?: string;
  /** Tag chips shown at the top-left of the card. */
  tags?: KanbanTaskCardTag[];
  /** Due date label displayed in the stats row (e.g. "Feb 24"). */
  dueDate?: string;
  /** Number of comments shown in the stats row. */
  commentsCount?: number;
  /** Number of file attachments shown in the stats row. */
  attachmentsCount?: number;
  /** Number of viewer avatar circles to render (decorative). */
  viewerAvatarCount?: number;
  /** Extra viewer count shown after the avatar circles (e.g. 20 → "+20"). */
  viewerCount?: number;
}

/** Three-dot horizontal ellipsis SVG. */
function ThreeDotIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 41.915 41.916"
      xmlns="http://www.w3.org/2000/svg"
      aria-hidden="true"
      focusable="false"
    >
      <path d="M11.214,20.956c0,3.091-2.509,5.589-5.607,5.589C2.51,26.544,0,24.046,0,20.956c0-3.082,2.511-5.585,5.607-5.585C8.705,15.371,11.214,17.874,11.214,20.956z" />
      <path d="M26.564,20.956c0,3.091-2.509,5.589-5.606,5.589c-3.097,0-5.607-2.498-5.607-5.589c0-3.082,2.511-5.585,5.607-5.585C24.056,15.371,26.564,17.874,26.564,20.956z" />
      <path d="M41.915,20.956c0,3.091-2.509,5.589-5.607,5.589c-3.097,0-5.606-2.498-5.606-5.589c0-3.082,2.511-5.585,5.606-5.585C39.406,15.371,41.915,17.874,41.915,20.956z" />
    </svg>
  );
}

/** Clock / deadline icon. */
function ClockIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      aria-hidden="true"
      focusable="false"
    >
      <path strokeLinecap="round" strokeWidth="2" stroke="currentColor" d="M12 8V12L15 15" />
      <circle strokeWidth="2" stroke="currentColor" r="9" cy="12" cx="12" />
    </svg>
  );
}

/** Chat bubble / comments icon. */
function CommentIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      aria-hidden="true"
      focusable="false"
    >
      <path
        strokeLinejoin="round"
        strokeLinecap="round"
        strokeWidth="1.5"
        stroke="currentColor"
        d="M16 10H16.01M12 10H12.01M8 10H8.01M3 10C3 4.64706 5.11765 3 12 3C18.8824 3 21 4.64706 21 10C21 15.3529 18.8824 17 12 17C11.6592 17 11.3301 16.996 11.0124 16.9876L7 21V16.4939C4.0328 15.6692 3 13.7383 3 10Z"
      />
    </svg>
  );
}

/** Attachment / pin icon. */
function AttachmentIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      xmlns="http://www.w3.org/2000/svg"
      viewBox="-2.5 0 32 32"
      fill="currentColor"
      aria-hidden="true"
      focusable="false"
    >
      <path d="M0 10.284l0.505 0.36c0.089 0.064 0.92 0.621 2.604 0.621 0.27 0 0.55-0.015 0.836-0.044 3.752 4.346 6.411 7.472 7.060 8.299-1.227 2.735-1.42 5.808-0.537 8.686l0.256 0.834 7.63-7.631 8.309 8.309 0.742-0.742-8.309-8.309 7.631-7.631-0.834-0.255c-2.829-0.868-5.986-0.672-8.686 0.537-0.825-0.648-3.942-3.3-8.28-7.044 0.11-0.669 0.23-2.183-0.575-3.441l-0.352-0.549-8.001 8.001zM1.729 10.039l6.032-6.033c0.385 1.122 0.090 2.319 0.086 2.334l-0.080 0.314 0.245 0.214c7.409 6.398 8.631 7.39 8.992 7.546l-0.002 0.006 0.195 0.058 0.185-0.087c2.257-1.079 4.903-1.378 7.343-0.836l-13.482 13.481c-0.55-2.47-0.262-5.045 0.837-7.342l0.104-0.218-0.098-0.221-0.031 0.013c-0.322-0.632-1.831-2.38-7.498-8.944l-0.185-0.215-0.282 0.038c-0.338 0.045-0.668 0.069-0.981 0.069-0.595 0-1.053-0.083-1.38-0.176z" />
    </svg>
  );
}

/** Single-user avatar SVG. */
function UserIcon() {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      aria-hidden="true"
      focusable="false"
    >
      <path
        strokeWidth="2"
        stroke="currentColor"
        d="M17 8C17 10.7614 14.7614 13 12 13C9.23858 13 7 10.7614 7 8C7 5.23858 9.23858 3 12 3C14.7614 3 17 5.23858 17 8Z"
      />
      <path
        strokeLinecap="round"
        strokeWidth="2"
        stroke="currentColor"
        d="M3 21C3.95728 17.9237 6.41998 17 12 17C17.58 17 20.0427 17.9237 21 21"
      />
    </svg>
  );
}

/**
 * Kanban Task Card
 *
 * A draggable-ready task surface card. Shows tag chips, a three-dot options
 * menu, a task description, a stats row (due date / comments / attachments),
 * and a row of overlapping viewer-avatar circles. On hover the card shadow
 * deepens and a dashed border appears — all via CSS, no JS required.
 *
 * Port of https://uiverse.io/Yaya12085/breezy-eagle-39 (MIT).
 */
export function KanbanTaskCard({
  title = "Design a new landing page layout for the upcoming product launch campaign.",
  tags = [{ label: "Design" }, { label: "UI/UX" }],
  dueDate = "Feb 24",
  commentsCount = 18,
  attachmentsCount = 7,
  viewerAvatarCount = 3,
  viewerCount = 20,
}: KanbanTaskCardProps) {
  return (
    // ponytail: draggable is structural affordance only — no drag JS needed to render
    <div className={styles.task} draggable={true} role="article" aria-label={title}>
      {/* Header: tag chips + options button */}
      <div className={styles.tags}>
        <div className={styles.tagList} role="group" aria-label="Tags">
          {tags.map((tag) => (
            <span key={tag.label} className={styles.tag}>
              {tag.label}
            </span>
          ))}
        </div>
        <button className={styles.options} aria-label="Task options" type="button">
          <ThreeDotIcon className={styles.optionsSvg} />
        </button>
      </div>

      {/* Task description */}
      <p className={styles.description}>{title}</p>

      {/* Stats row */}
      <div className={styles.stats} aria-label="Task stats">
        <div className={styles.statGroup}>
          <div className={styles.statItem}>
            <ClockIcon className={styles.statIcon} />
            <span>{dueDate}</span>
          </div>
          <div className={styles.statItem}>
            <CommentIcon className={styles.statIcon} />
            <span>{commentsCount}</span>
          </div>
          <div className={styles.statItem}>
            <AttachmentIcon className={styles.statIcon} />
            <span>{attachmentsCount}</span>
          </div>
        </div>

        {/* Viewer avatars */}
        <div className={styles.viewer} aria-label={`${viewerAvatarCount + viewerCount} viewers`}>
          {Array.from({ length: viewerAvatarCount }).map((_, i) => (
            <span key={i} className={styles.viewerAvatar} aria-hidden="true">
              <UserIcon />
            </span>
          ))}
          <span className={styles.viewerAvatar} aria-hidden="true">
            +{viewerCount}
          </span>
        </div>
      </div>
    </div>
  );
}

export default KanbanTaskCard;

Dependencies

  • @bottega/tokens