bottega

cards

Quote Testimonial Card

A social-style single quote card: a multi-line blockquote, an @username attribution, and a footer row with comment / send / share action buttons plus a stacked viewer-avatar cluster. Card surface maps to --muted (theme-robust on dark + light); text hierarchy uses color-mix against --foreground / --background; viewer dots use --primary / --primary-foreground. Pure-CSS hover scales the card to 1.03× at 200ms via a CSS transition — no JS required. Ported from uiverse.io/Yaya12085/tall-goose-27 (MIT).

Ported from Yaya12085 (MIT)

Lorem ipsum dolor sit amet, consectetur adipiscing elit. In massa ipsum, laoreet quis mollis nec, feugiat in dui. Suspendisse et enim pretium, ullamcorper enim laoreet.

@Yaya12085
+20

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/quote-testimonial-card

Usage

Usagetsx
<QuoteTestimonialCard />

Props

PropTypeDefaultDescription
quotestringLorem ipsum dolor sit amet, consectetur adipiscing elit. In massa ipsum, laoreet quis mollis nec, feugiat in dui. Suspendisse et enim pretium, ullamcorper enim laoreet.The quote body text.
usernamestring@Yaya12085Attribution handle shown below the quote, e.g. "@handle".
commentCountnumber18Number of comments.
sendCountnumber7Number of sends.
shareCountnumber2Number of shares.
viewerCountnumber20Number of viewers shown in the avatar cluster.
classNamestringExtra class names merged onto the root article.

Source

quote-testimonial-card.tsxtsx
import type React from "react";
import styles from "./quote-testimonial-card.module.css";

export type QuoteTestimonialCardProps = {
  /** The quote body text. */
  quote?: string;
  /** Attribution handle shown below the quote, e.g. "@handle". */
  username?: string;
  /** Number of comments. */
  commentCount?: number;
  /** Number of sends. */
  sendCount?: number;
  /** Number of shares. */
  shareCount?: number;
  /** Number of viewers shown in the avatar cluster. */
  viewerCount?: number;
  /** Extra class names merged onto the root article. */
  className?: string;
};

// ---- Icons (aria-hidden; colour inherits via currentColor) ----

function CommentIcon() {
  return (
    <svg
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      className={styles.icon}
    >
      <path
        stroke="currentColor"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="1.5"
        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>
  );
}

function SendIcon() {
  return (
    <svg
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      fill="currentColor"
      viewBox="-2.5 0 32 32"
      className={styles.icon}
    >
      {/* Paper-plane send icon, fills use currentColor */}
      <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>
  );
}

function ShareIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      className={styles.icon}
    >
      {/* Semi-transparent circle fills — decorative tint via currentColor */}
      <path opacity="0.1" d="M21 6C21 7.65685 19.6569 9 18 9C16.3431 9 15 7.65685 15 6C15 4.34315 16.3431 3 18 3C19.6569 3 21 4.34315 21 6Z" fill="currentColor" />
      <path opacity="0.1" d="M21 18C21 19.6569 19.6569 21 18 21C16.3431 21 15 19.6569 15 18C15 16.3431 16.3431 15 18 15C19.6569 15 21 16.3431 21 18Z" fill="currentColor" />
      <path opacity="0.1" d="M9 12C9 13.6569 7.65685 15 6 15C4.34315 15 3 13.6569 3 12C3 10.3431 4.34315 9 6 9C7.65685 9 9 10.3431 9 12Z" fill="currentColor" />
      <path d="M21 6C21 7.65685 19.6569 9 18 9C16.3431 9 15 7.65685 15 6C15 4.34315 16.3431 3 18 3C19.6569 3 21 4.34315 21 6Z" stroke="currentColor" strokeWidth="2" />
      <path d="M21 18C21 19.6569 19.6569 21 18 21C16.3431 21 15 19.6569 15 18C15 16.3431 16.3431 15 18 15C19.6569 15 21 16.3431 21 18Z" stroke="currentColor" strokeWidth="2" />
      <path d="M9 12C9 13.6569 7.65685 15 6 15C4.34315 15 3 13.6569 3 12C3 10.3431 4.34315 9 6 9C7.65685 9 9 10.3431 9 12Z" stroke="currentColor" strokeWidth="2" />
      <path d="M8.7207 10.6397L15.0001 7.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M8.70605 13.353L15 16.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function PersonIcon() {
  return (
    <svg
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      className={styles.viewerIcon}
    >
      <path stroke="currentColor" strokeWidth="2" 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 stroke="currentColor" strokeLinecap="round" strokeWidth="2" d="M3 21C3.95728 17.9237 6.41998 17 12 17C17.58 17 20.0427 17.9237 21 21" />
    </svg>
  );
}

/**
 * QuoteTestimonialCard — a single social-style quote card with an
 * @username attribution and a comment/send/share action row. Hover
 * scales the card up via a pure-CSS transition (200ms). Server component.
 *
 * Ported from uiverse.io/Yaya12085/tall-goose-27 (MIT).
 */
export function QuoteTestimonialCard({
  quote = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In massa ipsum, laoreet quis mollis nec, feugiat in dui. Suspendisse et enim pretium, ullamcorper enim laoreet.",
  username = "@Yaya12085",
  commentCount = 18,
  sendCount = 7,
  shareCount = 2,
  viewerCount = 20,
  className = "",
}: QuoteTestimonialCardProps) {
  const rootClass = [styles.card, className].filter(Boolean).join(" ");

  return (
    <article className={rootClass} aria-label={`Quote by ${username}`}>
      <div className={styles.body}>
        <blockquote className={styles.quote}>
          <p className={styles.text}>{quote}</p>
        </blockquote>
        <cite className={styles.username}>{username}</cite>

        <footer className={styles.footer}>
          <div className={styles.actions}>
            <button
              type="button"
              className={styles.actionBtn}
              aria-label={`${commentCount} comments`}
            >
              <CommentIcon />
              <span>{commentCount}</span>
            </button>
            <button
              type="button"
              className={styles.actionBtn}
              aria-label={`Send — ${sendCount}`}
            >
              <SendIcon />
              <span>{sendCount}</span>
            </button>
            <button
              type="button"
              className={styles.actionBtn}
              aria-label={`Share — ${shareCount} shares`}
            >
              <ShareIcon />
              <span>{shareCount}</span>
            </button>
          </div>

          <div className={styles.viewers} aria-label={`${viewerCount} viewers`}>
            <span className={styles.viewerDot} aria-hidden="true">
              <PersonIcon />
            </span>
            <span className={styles.viewerDot} aria-hidden="true">
              <PersonIcon />
            </span>
            <span className={styles.viewerDot} aria-hidden="true">
              <PersonIcon />
            </span>
            <span className={styles.viewerCount}>+{viewerCount}</span>
          </div>
        </footer>
      </div>
    </article>
  );
}

Dependencies

  • @bottega/tokens