Timace

Testimonials

In their words

Teams stay once they start

What people said after their first month.

4.9from 2,000 teams
  • We moved the whole site in a week. Nobody asked where anything had gone, which is the best review a tool can get.
    Ada KentHead of design, Northwind
  • Set up before lunch, shipping by the afternoon.
    Jo MoriFounder, Halcyon
  • The first tool our engineers and designers both open in the morning, and the only one neither has asked to replace.
    Sam ReyesEngineering lead, Parallel
  • It looks like ours now, not like a template.
    Lena DahlBrand, Lumen
  • Support answered in minutes, with the fix. I have stopped keeping a list of workarounds.
    Omar HaddadOperations, Fieldnote
  • The code we took out was the code that ran.
    Priya NairDeveloper, Quartz
  • Three launches this quarter, and none of them waited on a page.
    Theo LangMarketing, Borealis
  • I changed one colour and the whole product followed. That used to be a sprint.
    Mina OkaforDesign systems, Atlas
  • New people are useful in their first week now.
    Ravi ShahHead of product, Cinder
components/ui/testimonials.tsx
import type { ReactNode } from "react";

export interface Testimonial {
  quote: string;
  name: string;
  role: string;
  /** How many stars out of five. Defaults to five. */
  stars?: number;
  /** Lit from above, with its mark in the accent. */
  featured?: boolean;
}

export interface TestimonialsProps {
  eyebrow?: ReactNode;
  title?: ReactNode;
  description?: ReactNode;
  /** A score in a capsule under the heading, and a few words beside it. */
  rating?: { score: string; text: ReactNode };
  items: Testimonial[];
  className?: string;
}

// A mark with a little depth: a highlight along the top edge, a shade along the bottom, and a soft cast in its own colour.
const raised =
  "bg-(--tui-testimonials-accent) text-(--tui-testimonials-accent-fg) [background-image:linear-gradient(to_bottom,rgb(255_255_255_/_0.18),rgb(255_255_255_/_0)_55%,rgb(0_0_0_/_0.14))] [box-shadow:inset_0_1px_0_rgb(255_255_255_/_0.24),inset_0_-1px_0_rgb(0_0_0_/_0.2),0_1px_2px_rgb(0_0_0_/_0.2),0_6px_12px_-6px_var(--tui-testimonials-accent)]";
const plain = "bg-(--tui-testimonials-card-bg) [box-shadow:0_0_0_1px_var(--tui-testimonials-border),0_1px_2px_rgb(0_0_0_/_0.08)]";

/** How long each column takes against the set speed, and which way it goes, so no two move together. */
const PACE = [
  { factor: 1, reverse: false },
  { factor: 1.3, reverse: true },
  { factor: 0.85, reverse: false },
];

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

function Stars({ count = 5 }: { count?: number }) {
  return (
    <span role="img" aria-label={`${count} out of 5`} className="flex gap-0.5 text-(--tui-testimonials-accent)">
      {[0, 1, 2, 3, 4].map((star) => (
        <svg key={star} aria-hidden viewBox="0 0 16 16" fill="currentColor" className={`size-3.5 ${star < count ? "" : "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>
  );
}

function Card({ item }: { item: Testimonial }) {
  return (
    <figure data-slot="testimonial" data-featured={item.featured || undefined} className="m-0 flex flex-col rounded-(--tui-testimonials-radius) bg-(--tui-testimonials-shell-bg) p-1.5 [box-shadow:inset_0_0_0_1px_var(--tui-testimonials-border)]">
      <div data-slot="testimonial-panel" className="relative flex flex-col gap-4 overflow-hidden rounded-[calc(var(--tui-testimonials-radius)_-_6px)] bg-(--tui-testimonials-card-bg) p-(--tui-testimonials-padding) [box-shadow:0_0_0_1px_var(--tui-testimonials-border),0_1px_2px_rgb(0_0_0_/_0.05),0_8px_20px_-12px_rgb(0_0_0_/_0.18)]">
        {item.featured && <span aria-hidden className="pointer-events-none absolute -top-24 left-1/2 size-64 -translate-x-1/2 rounded-full bg-(--tui-testimonials-glow) blur-3xl" />}
        <span className="relative">
          <Stars count={item.stars} />
        </span>
        <blockquote className="relative m-0 text-(length:--tui-testimonials-quote-size) leading-relaxed text-pretty">{item.quote}</blockquote>
      </div>
      <figcaption className="flex items-center gap-3 px-(--tui-testimonials-padding) py-3.5">
        <span aria-hidden className={`flex size-9 shrink-0 items-center justify-center rounded-full text-[12px] font-semibold ${item.featured ? raised : plain}`}>
          {initials(item.name)}
        </span>
        <span className="flex min-w-0 flex-1 flex-col">
          <span className="truncate text-[14px] font-semibold">{item.name}</span>
          <span className="truncate text-[13px] text-(--tui-testimonials-muted)">{item.role}</span>
        </span>
        <svg aria-hidden viewBox="0 0 32 24" fill="currentColor" className="h-3.5 w-5 shrink-0 text-(--tui-testimonials-accent) opacity-30">
          <path d="M0 24V13.500C0 5.800 4.200 1.200 12 0v4.800C8.400 5.700 6.600 8 6.600 11.400H12V24zm18 0V13.500C18 5.800 22.200 1.200 30 0v4.800c-3.600.900-5.400 3.200-5.400 6.600H30V24z" />
        </svg>
      </figcaption>
    </figure>
  );
}

/** One column of the wall. Its cards are drawn twice, one set after the other, so it can come round without a seam. */
function Column({ items, pace, className }: { items: Testimonial[]; pace: (typeof PACE)[number]; className: string }) {
  return (
    <div className={className}>
      <div
        data-slot="testimonials-track"
        style={{ animationDuration: `calc(var(--tui-testimonials-duration) * ${pace.factor})`, animationDirection: pace.reverse ? "reverse" : undefined }}
        className="flex flex-col [animation:tui-wall_var(--tui-testimonials-duration)_linear_infinite] group-hover/wall:[animation-play-state:paused] motion-reduce:[animation:none]"
      >
        {[false, true].map((copy) => (
          <ul key={String(copy)} aria-hidden={copy || undefined} className={`m-0 flex list-none flex-col gap-(--tui-testimonials-card-gap) p-0 pb-(--tui-testimonials-card-gap) ${copy ? "motion-reduce:hidden" : ""}`}>
            {items.map((item) => (
              <li key={item.name}>
                <Card item={item} />
              </li>
            ))}
          </ul>
        ))}
      </div>
    </div>
  );
}

/**
 * What people said, as a wall. The quotes stand in columns that drift past
 * one another and fade out at the top and bottom, and stop under the pointer
 * so one can be read. Each is a raised panel with the words in it, set in a
 * shell that says who they came from.
 */
export function Testimonials({ eyebrow, title, description, rating, items, className }: TestimonialsProps) {
  return (
    <section data-slot="testimonials" className={["flex w-full flex-col items-center gap-(--tui-testimonials-gap) bg-(--tui-testimonials-bg) px-6 py-(--tui-testimonials-padding-y) font-(family-name:--tui-font) text-(--tui-testimonials-fg)", className].filter(Boolean).join(" ")}>
      {(eyebrow || title || description || rating) && (
        <header className="flex max-w-[42rem] flex-col items-center gap-4 text-center">
          {eyebrow && <span className="inline-flex h-7 items-center rounded-full px-3 text-[12px] font-semibold tracking-[0.08em] text-(--tui-testimonials-accent) uppercase [box-shadow:inset_0_0_0_1px_var(--tui-testimonials-border)]">{eyebrow}</span>}
          {title && <h2 className="m-0 text-(length:--tui-testimonials-title-size) leading-[1.08] font-semibold tracking-[-0.03em] text-balance">{title}</h2>}
          {description && <p className="m-0 text-[17px] leading-relaxed text-pretty text-(--tui-testimonials-muted)">{description}</p>}
          {rating && (
            <span data-slot="testimonials-rating" className="mt-1 inline-flex h-9 items-center gap-2.5 rounded-full bg-(--tui-testimonials-card-bg) pr-4 pl-1 text-[13px] text-(--tui-testimonials-muted) [box-shadow:inset_0_0_0_1px_var(--tui-testimonials-border),0_1px_2px_rgb(0_0_0_/_0.05)]">
              <span className={`inline-flex h-7 items-center rounded-full px-2.5 text-[12px] font-semibold tabular-nums ${raised}`}>{rating.score}</span>
              <Stars />
              {rating.text}
            </span>
          )}
        </header>
      )}
      <div
        data-slot="testimonials-wall"
        className="group/wall grid h-(--tui-testimonials-height) w-full max-w-(--tui-testimonials-width) grid-cols-1 gap-(--tui-testimonials-card-gap) overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_14%,black_86%,transparent)] motion-reduce:h-auto motion-reduce:[mask-image:none] lg:grid-cols-3"
      >
        {/* Narrow screens get every quote in one column. Wide ones deal them out across three. */}
        <Column items={items} pace={PACE[0]!} className="mx-auto w-full max-w-[28rem] lg:hidden" />
        {PACE.map((pace, column) => (
          <Column key={column} items={items.filter((_, i) => i % PACE.length === column)} pace={pace} className="hidden lg:block" />
        ))}
      </div>
    </section>
  );
}
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;

  /* Testimonials */
  --tui-testimonials-duration: 60s;
  --tui-testimonials-height: 620px;
  --tui-testimonials-padding-y: calc(var(--tui-space) * 20);
  --tui-testimonials-gap: calc(var(--tui-space) * 12);
  --tui-testimonials-card-gap: calc(var(--tui-space) * 5);
  --tui-testimonials-width: 1040px;
  --tui-testimonials-padding: calc(var(--tui-space) * 6);
  --tui-testimonials-radius: calc(var(--tui-radius) * 2.5);
  --tui-testimonials-title-size: 40px;
  --tui-testimonials-quote-size: 15px;
  --tui-testimonials-bg: var(--tui-bg);
  --tui-testimonials-card-bg: var(--tui-bg);
  --tui-testimonials-shell-bg: var(--tui-muted);
  --tui-testimonials-fg: var(--tui-fg);
  --tui-testimonials-muted: var(--tui-muted-fg);
  --tui-testimonials-border: var(--tui-border);
  --tui-testimonials-glow: color-mix(in srgb, var(--tui-primary) 12%, transparent);
  --tui-testimonials-accent: var(--tui-primary);
  --tui-testimonials-accent-fg: var(--tui-primary-fg);
}

.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;
}

@keyframes tui-wall {
  to { transform: translateY(-50%); }
}

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