Editorial

Quote

Testimonial parts: a grid, divided or masonry group whose one to four columns step on a parent Section; figures in default, centred or muted tile style; quotes in four sizes with a byline.

Free, MIT licenseOpen in Browse
import type { ComponentProps } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

// cols steps on the section: two across from 42rem, the count from 64rem.
const quoteGroupVariants = cva("group/quote-group", {
  variants: {
    /**
     * The most columns. They step on the enclosing `Section`'s width: `2`
     * shows two columns once it is 42rem wide, and `3` and `4` show two from
     * 42rem and their full count from 64rem. `1` (the default) stays one
     * column.
     */
    cols: {
      1: "",
      2: "@2xl/section:columns-2 @2xl/section:grid-cols-2",
      3: "@2xl/section:columns-2 @2xl/section:grid-cols-2 @5xl/section:columns-3 @5xl/section:grid-cols-3",
      4: "@2xl/section:columns-2 @2xl/section:grid-cols-2 @5xl/section:columns-4 @5xl/section:grid-flow-dense @5xl/section:grid-cols-4",
    },
    /**
     * The layout. `default` (the default) is a grid with 40px gaps
     * (`--grid-gap`). `divided` removes the gaps and draws a line in
     * `foreground` at 10% opacity between figures, with 40px on each side:
     * across while they stack, and down between them when `cols` is above 1
     * and the section is 42rem wide. `masonry` flows the figures into
     * columns of uneven height.
     */
    variant: {
      default: "grid grid-cols-1 gap-(--grid-gap)",
      divided: "grid grid-cols-1 gap-0",
      masonry: "columns-1 gap-(--grid-gap)",
    },
  },
  defaultVariants: { cols: 1, variant: "default" },
})

/**
 * A group of `QuoteFigure` quotes in `cols` columns (1 by default). The
 * columns measure the enclosing `Section`, so place it inside one: two
 * across from 42rem, then the full count from 64rem.
 *
 * The `default` variant is a grid with even gaps. `divided` removes the gaps
 * and draws a line between figures: across the page while they stack, and
 * down between them when `cols` is above 1 and the section is 42rem wide.
 * `masonry` flows the figures into columns of uneven height.
 */
function QuoteGroup({
  className,
  cols = 1,
  variant = "default",
  ...props
}: ComponentProps<"div"> & VariantProps<typeof quoteGroupVariants>) {
  return (
    <div
      data-slot="quote-group"
      data-cols={cols}
      data-variant={variant}
      data-orientation={
        variant === "divided"
          ? cols === 1
            ? "vertical"
            : "horizontal"
          : undefined
      }
      className={cn(quoteGroupVariants({ cols, variant }), className)}
      {...props}
    />
  )
}

// A Card with no border: quote above, byline held to the foot. In a divided
// group it rules its row, sideways from 42rem when cols is above 1.
const quoteFigureVariants = cva(
  "group/quote relative flex flex-col justify-between gap-10 group-data-[variant=divided]/quote-group:border-foreground/10 group-data-[variant=divided]/quote-group:py-10 group-data-[variant=divided]/quote-group:not-first:border-t group-data-[variant=masonry]/quote-group:mb-(--grid-gap) group-data-[variant=masonry]/quote-group:break-inside-avoid group-data-[variant=divided]/quote-group:first:pt-0 group-data-[variant=divided]/quote-group:last:pb-0 @2xl/section:group-data-[orientation=horizontal]/quote-group:px-10 @2xl/section:group-data-[orientation=horizontal]/quote-group:py-0 @2xl/section:group-data-[orientation=horizontal]/quote-group:not-first:border-t-0 @2xl/section:group-data-[orientation=horizontal]/quote-group:not-first:border-l @2xl/section:group-data-[orientation=horizontal]/quote-group:first:pl-0 @2xl/section:group-data-[orientation=horizontal]/quote-group:last:pr-0",
  {
    variants: {
      /**
       * The figure style. `default` (the default) adds nothing. `centered`
       * centers the content and its text. `muted` sets it on a panel with
       * 12px corners and 24px padding, filled with `foreground` at 2.5%
       * opacity (5% in dark mode). In a `masonry` group a `muted` figure
       * keeps 8px (`--tile-gap`) below it instead of 40px.
       */
      variant: {
        default: "",
        centered: "items-center text-center",
        muted:
          "rounded-[12px] bg-foreground/2.5 p-6 group-data-[variant=masonry]/quote-group:mb-(--tile-gap) dark:bg-foreground/5",
      },
    },
    defaultVariants: { variant: "default" },
  }
)

/**
 * One quote with its byline, as a `figure`. Holds an optional `QuoteHeader`,
 * then `Quote`, then `QuoteAuthor`. Spare height goes between them, so the
 * byline sits at the bottom.
 *
 * `centered` centers its content and text. `muted` sets it on a faintly
 * filled panel with rounded corners and 24px padding. Inside a `QuoteGroup`
 * it takes the group's spacing and dividing lines.
 */
function QuoteFigure({
  className,
  variant = "default",
  ...props
}: ComponentProps<"figure"> & VariantProps<typeof quoteFigureVariants>) {
  return (
    <figure
      data-slot="quote-figure"
      data-variant={variant}
      className={cn(quoteFigureVariants({ variant }), className)}
      {...props}
    />
  )
}

/** A row above the quote, for a company name or logo, in small type. */
function QuoteHeader({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="quote-header"
      className={cn("flex items-center gap-2 text-sm", className)}
      {...props}
    />
  )
}

// The marks are drawn, so the copy carries none. sm sits in a tile, md is
// body copy, lg a lead, xl the pull quote that carries a section.
const quoteVariants = cva(
  "grid content-start gap-4 text-pretty *:[p]:first:before:content-[open-quote] *:[p]:last:after:content-[close-quote]",
  {
    variants: {
      /**
       * The text size and width. `sm` is 14px with no width cap. `md` (the
       * default) is 16px, up to 28rem wide. `lg` is 20px, 24px once the
       * section is 42rem wide, up to 36rem wide. `xl` is a pull quote in the
       * display font, centered in its parent: 32px, 48px once the section is
       * 42rem wide, up to 60rem wide.
       */
      size: {
        sm: "text-sm/6",
        md: "max-w-md text-base/7",
        lg: "max-w-xl text-xl/8 @2xl/section:text-2xl/9",
        xl: "mx-auto max-w-240 font-display text-[2rem]/12 tracking-tight @2xl/section:text-5xl/16",
      },
    },
    defaultVariants: { size: "md" },
  }
)

/**
 * The quoted text, as a `blockquote`. Put the copy in one or more `p`
 * children without quote marks: it draws an opening mark before the first
 * paragraph and a closing mark after the last.
 *
 * `size` sets the text size and maximum width: `sm` small text with no width
 * cap, `md` (the default) body text, `lg` a larger lead, and `xl` a centered
 * pull quote in the display font. `lg` and `xl` step up once the section is
 * 42rem wide.
 */
function Quote({
  className,
  size = "md",
  ...props
}: ComponentProps<"blockquote"> & VariantProps<typeof quoteVariants>) {
  return (
    <blockquote
      data-slot="quote"
      data-size={size}
      className={cn(quoteVariants({ size }), className)}
      {...props}
    />
  )
}

/**
 * The byline, as a `figcaption`. Holds `QuoteAuthorName` and
 * `QuoteAuthorRole`, one above the other. With a `QuoteAuthorAvatar` it
 * becomes two columns: the avatar spans both rows and the name and role sit
 * beside it. In a `centered` figure it shrinks to fit and keeps its text
 * left-aligned.
 */
function QuoteAuthor({ className, ...props }: ComponentProps<"figcaption">) {
  return (
    <figcaption
      data-slot="quote-author"
      className={cn(
        "grid items-center gap-x-4 text-sm/7 group-data-[variant=centered]/quote:w-fit group-data-[variant=centered]/quote:text-left has-data-[slot=quote-author-avatar]:grid-cols-[auto_1fr]",
        className
      )}
      {...props}
    />
  )
}

/** Holds the author's picture in `QuoteAuthor`, beside the name and role. */
function QuoteAuthorAvatar({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="quote-author-avatar"
      className={cn("row-span-2 flex", className)}
      {...props}
    />
  )
}

/** The author's name, in semibold. */
function QuoteAuthorName({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="quote-author-name"
      className={cn("font-semibold", className)}
      {...props}
    />
  )
}

/** The author's role or company, in muted text under the name. */
function QuoteAuthorRole({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="quote-author-role"
      className={cn("text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  QuoteGroup,
  QuoteFigure,
  QuoteHeader,
  Quote,
  QuoteAuthor,
  QuoteAuthorAvatar,
  QuoteAuthorName,
  QuoteAuthorRole,
}

Installation

pnpm dlx shadcn add @01orbital/quote
npx shadcn add @01orbital/quote
yarn shadcn add @01orbital/quote
bunx --bun shadcn add @01orbital/quote

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/quote.tsx, as source you own and edit.

The example also uses section. Install it too:

pnpm dlx shadcn add @01orbital/section
npx shadcn add @01orbital/section
yarn shadcn add @01orbital/section
bunx --bun shadcn add @01orbital/section

Usage

import {
  Quote,
  QuoteAuthor,
  QuoteAuthorName,
  QuoteAuthorRole,
  QuoteFigure,
} from "@/components/editorial/quote"
import {
  Section,
  SectionContent,
} from "@/components/editorial/section"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  quote:
    "Good conversations make room for the people behind every message. The kit gave us that room without a redesign.",
  name: "Sam Rivera",
  role: "Product design, Orbcore",
} as const

// One quote as a testimonial section: centred on the reading column.
export default function QuoteDemo() {
  return (
    <Section>
      <SectionContent width="sm">
        <QuoteFigure variant="centered">
          <Quote size="lg">
            <p>{COPY.quote}</p>
          </Quote>
          <QuoteAuthor>
            <QuoteAuthorName>{COPY.name}</QuoteAuthorName>
            <QuoteAuthorRole>{COPY.role}</QuoteAuthorRole>
          </QuoteAuthor>
        </QuoteFigure>
      </SectionContent>
    </Section>
  )
}

Anatomy

Quote is built from 8 parts. In the example they nest like this:

QuoteFigure
├─ Quote
└─ QuoteAuthor
   ├─ QuoteAuthorName
   └─ QuoteAuthorRole
  • QuoteFigure: One quote with its byline, as a figure. Holds an optional QuoteHeader, then Quote, then QuoteAuthor. Spare height goes between them, so the byline sits at the bottom.
  • Quote: The quoted text, as a blockquote. Put the copy in one or more p children without quote marks: it draws an opening mark before the first paragraph and a closing mark after the last.
  • QuoteAuthor: The byline, as a figcaption. Holds QuoteAuthorName and QuoteAuthorRole, one above the other. With a QuoteAuthorAvatar it becomes two columns: the avatar spans both rows and the name and role sit beside it. In a centered figure it shrinks to fit and keeps its text left-aligned.
  • QuoteAuthorName: The author's name, in semibold.
  • QuoteAuthorRole: The author's role or company, in muted text under the name.
  • QuoteGroup: A group of QuoteFigure quotes in cols columns (1 by default). The columns measure the enclosing Section, so place it inside one: two across from 42rem, then the full count from 64rem.
  • QuoteHeader: A row above the quote, for a company name or logo, in small type.
  • QuoteAuthorAvatar: Holds the author's picture in QuoteAuthor, beside the name and role.

API reference

QuoteFigure

One quote with its byline, as a figure. Holds an optional QuoteHeader, then Quote, then QuoteAuthor. Spare height goes between them, so the byline sits at the bottom.

centered centers its content and text. muted sets it on a faintly filled panel with rounded corners and 24px padding. Inside a QuoteGroup it takes the group's spacing and dividing lines.

Prop

Type

It also accepts the HTML attributes of the element it renders.

Quote

The quoted text, as a blockquote. Put the copy in one or more p children without quote marks: it draws an opening mark before the first paragraph and a closing mark after the last.

size sets the text size and maximum width: sm small text with no width cap, md (the default) body text, lg a larger lead, and xl a centered pull quote in the display font. lg and xl step up once the section is 42rem wide.

Prop

Type

It also accepts the HTML attributes of the element it renders.

QuoteAuthor

The byline, as a figcaption. Holds QuoteAuthorName and QuoteAuthorRole, one above the other. With a QuoteAuthorAvatar it becomes two columns: the avatar spans both rows and the name and role sit beside it. In a centered figure it shrinks to fit and keeps its text left-aligned.

It accepts the HTML attributes of the element it renders.

QuoteAuthorName

The author's name, in semibold.

It accepts the HTML attributes of the element it renders.

QuoteAuthorRole

The author's role or company, in muted text under the name.

It accepts the HTML attributes of the element it renders.

QuoteGroup

A group of QuoteFigure quotes in cols columns (1 by default). The columns measure the enclosing Section, so place it inside one: two across from 42rem, then the full count from 64rem.

The default variant is a grid with even gaps. divided removes the gaps and draws a line between figures: across the page while they stack, and down between them when cols is above 1 and the section is 42rem wide. masonry flows the figures into columns of uneven height.

Prop

Type

It also accepts the HTML attributes of the element it renders.

QuoteHeader

A row above the quote, for a company name or logo, in small type.

It accepts the HTML attributes of the element it renders.

QuoteAuthorAvatar

Holds the author's picture in QuoteAuthor, beside the name and role.

It accepts the HTML attributes of the element it renders.

Dependencies

npm: class-variance-authority, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • font-hanken-grotesk