Timace

Social proof

4.9Joined by 2,000 teams
components/ui/social-proof.tsx
import type { ComponentProps, ReactNode } from "react";

const initials = (name: string) =>
  name
    .split(/\s+/)
    .map((part) => part[0])
    .slice(0, 2)
    .join("")
    .toUpperCase();

const face =
  "relative -ml-(--tui-social-proof-overlap) flex h-(--tui-social-proof-avatar-size) shrink-0 items-center justify-center overflow-hidden rounded-full leading-none font-semibold transition-[margin] duration-(--tui-social-proof-duration) ease-out first:ml-0 group-hover:-ml-[calc(var(--tui-social-proof-overlap)_*_0.35)] group-hover:first:ml-0 [box-shadow:0_0_0_var(--tui-social-proof-ring-width)_var(--tui-social-proof-ring)]";

export interface SocialProofProps extends Omit<ComponentProps<"div">, "children"> {
  /** A few of the people. A picture is drawn if one is given, their initials if not. */
  people: { name: string; src?: string }[];
  /** How many more there are, in a filled capsule at the end of the row: "+2k". */
  count?: ReactNode;
  /** How many stars out of five. Leave it out for no stars. */
  stars?: number;
  /** The score itself, written beside the stars: "4.9". */
  rating?: ReactNode;
  /** What to say beside them. */
  children: ReactNode;
}

/**
 * A few faces and a line: the smallest piece of proof, for under a headline
 * or beside a button. The faces overlap, and spread apart under the pointer
 * so each can be seen.
 */
export function SocialProof({ people, count, stars, rating, children, className, ...props }: SocialProofProps) {
  const shown = people.slice(0, 5);
  return (
    <div data-slot="social-proof" {...props} className={["group inline-flex items-center gap-(--tui-social-proof-gap) font-(family-name:--tui-font) text-(length:--tui-social-proof-font-size) text-(--tui-social-proof-fg)", className].filter(Boolean).join(" ")}>
      <span aria-hidden className="flex shrink-0">
        {shown.map((person, i) => (
          <span key={person.name} data-slot="social-proof-avatar" style={{ zIndex: shown.length - i }} className={`${face} w-(--tui-social-proof-avatar-size) bg-(--tui-social-proof-avatar-bg) text-[calc(var(--tui-social-proof-avatar-size)_*_0.34)]`}>
            {person.src ? <img src={person.src} alt="" className="size-full object-cover" /> : initials(person.name)}
          </span>
        ))}
        {count && (
          <span data-slot="social-proof-count" className={`${face} min-w-(--tui-social-proof-avatar-size) bg-(--tui-social-proof-count-bg) px-[0.7em] text-[calc(var(--tui-social-proof-avatar-size)_*_0.32)] text-(--tui-social-proof-count-fg) tabular-nums`}>
            {count}
          </span>
        )}
      </span>
      <span className="flex min-w-0 flex-col gap-1.5 leading-none">
        {stars !== undefined && (
          <span className="flex items-center gap-1.5">
            <span role="img" aria-label={`${stars} out of 5`} className="flex gap-0.5 text-(--tui-social-proof-star)">
              {[0, 1, 2, 3, 4].map((star) => (
                <svg key={star} aria-hidden viewBox="0 0 16 16" fill="currentColor" className={`size-(--tui-social-proof-star-size) ${star < stars ? "" : "opacity-20"}`}>
                  <path d="M8 1.300l2.050 4.250 4.650.6-3.400 3.250.85 4.600L8 11.750 3.850 14l.85-4.600L1.300 6.150l4.650-.6z" />
                </svg>
              ))}
            </span>
            {rating && <span className="font-semibold tabular-nums">{rating}</span>}
          </span>
        )}
        <span className="text-(--tui-social-proof-muted)">{children}</span>
      </span>
    </div>
  );
}
styles/timace-ui.css
:root {
  color-scheme: light;
  --tui-bg: #ffffff;
  --tui-fg: #0a0a0a;
  --tui-muted: #f5f5f5;
  --tui-muted-fg: #737373;
  --tui-border: #e5e5e5;
  --tui-primary: #171717;
  --tui-primary-fg: #fafafa;
  --tui-ring: #a3a3a3;
  --tui-success: #16a34a;
  --tui-warning: #d97706;
  --tui-danger: #dc2626;
  --tui-radius: 8px;
  --tui-border-width: 1px;
  --tui-space: 4px;
  --tui-shadow: none;
  --tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --tui-text: 14px;
  --tui-duration: 150ms;

  /* Social proof */
  --tui-social-proof-avatar-size: calc(var(--tui-space) * 9);
  --tui-social-proof-overlap: calc(var(--tui-space) * 2.5);
  --tui-social-proof-ring-width: 2px;
  --tui-social-proof-gap: calc(var(--tui-space) * 3.5);
  --tui-social-proof-star-size: 13px;
  --tui-social-proof-font-size: calc(var(--tui-text) - 1px);
  --tui-social-proof-fg: var(--tui-fg);
  --tui-social-proof-muted: var(--tui-muted-fg);
  --tui-social-proof-star: var(--tui-fg);
  --tui-social-proof-avatar-bg: color-mix(in oklab, var(--tui-fg) 9%, var(--tui-bg));
  --tui-social-proof-ring: var(--tui-bg);
  --tui-social-proof-count-bg: var(--tui-primary);
  --tui-social-proof-count-fg: var(--tui-primary-fg);
  --tui-social-proof-duration: 360ms;
}

.dark,
[data-theme="dark"] {
  color-scheme: dark;
  --tui-bg: #0a0a0a;
  --tui-fg: #fafafa;
  --tui-muted: #1c1c1c;
  --tui-muted-fg: #a3a3a3;
  --tui-border: #2a2a2a;
  --tui-primary: #fafafa;
  --tui-primary-fg: #171717;
  --tui-ring: #737373;
  --tui-success: #22c55e;
  --tui-warning: #f59e0b;
  --tui-danger: #ef4444;
}

Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.