FeatureGridBlock

A header over a grid of features: an icon on a brand tint, a title and a sentence each. Columns drop as the width runs out, down to one on a phone.

Blocks FeatureGridBlock.jsx FeatureGridBlock.d.ts FeatureGridBlock.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

FeatureGridBlock is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { FeatureGridBlock } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A header over a grid of features: an icon on a brand tint, a title and a sentence each. Columns drop as the width runs out, down to one on a phone.

Use it when

  • Three to eight parallel reasons, capabilities or steps.

Don't use it when

  • One idea with a picture. Use SplitBlock.

Example

<FeatureGridBlock
  eyebrow="Why it holds"
  title="Three tiers, referenced one way"
  tone="subtle"
  items={[
    { icon: <PaletteIcon />, title: "Primitives name values", description: "Nothing here knows what it is for." },
    { icon: <LayersIcon />, title: "Semantics name jobs", description: "The only tier a component reads." },
    { icon: <BlocksIcon />, title: "Components name parts", description: "Retuned per context." },
  ]}
/>

Stacking blocks

Blocks are Sections, so a page is a list of them. Alternate tone (base, subtle, base, brand) so one band ends where the next begins, keep one HeroBlock at the top and one CtaBlock at the end, and let everything between be the argument. Pass dark to scope dark mode to a single block.

Title size

Set titleSize to move the title along the type scale, from display-lg down to heading-md. It defaults to heading-lg. Only its size changes: the heading level stays what the block renders.

Tokens

Reads the section, typography and card tokens through Section, Heading and Text; it has none of its own. Retheme it by retheming those.

Accessibility

Each block is a <section>. Give the page one h1 (the HeroBlock's title) and let every other block's title be an h2, which is what they render.

Props

The typed contract, from FeatureGridBlock.d.ts.

import * as React from "react";

/** One feature in a FeatureGridBlock. */
export interface FeatureItem {
  /** An icon, drawn on a brand tint. */
  icon?: React.ReactNode;
  title: React.ReactNode;
  /** One sentence. */
  description?: React.ReactNode;
  /** Adds a link under the description. */
  href?: string;
  /** The link's text. @default "Learn more" */
  linkLabel?: string;
}

/** A header over a grid of features, each with an icon, a title and a sentence. */
export interface FeatureGridBlockProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  /** A few words above the title. */
  eyebrow?: React.ReactNode;
  /** The block's heading. */
  title?: React.ReactNode;
  /** The title's type size, on the system's heading and display scale. @default "heading-lg" */
  titleSize?: "display-lg" | "display-md" | "display-sm" | "heading-xl" | "heading-lg" | "heading-md";
  lead?: React.ReactNode;
  actions?: React.ReactNode;
  items: FeatureItem[];
  /** Columns on a wide screen; fewer as the width runs out, one on a phone. @default 3 */
  columns?: 2 | 3 | 4;
  /** The header's alignment. @default "center" */
  align?: "start" | "center";
  /** plain sits on the band; cards puts each feature on the card surface. @default "plain" */
  variant?: "plain" | "cards";
  /** The band's surface, passed to Section. @default "base" */
  tone?: "base" | "subtle" | "brand" | "brand-muted" | "secondary" | "secondary-muted";
  /** Scopes dark mode to this block, so everything inside reads light on dark. */
  dark?: boolean;
  /** Layers the texture token over the tone. */
  texture?: boolean;
  /** Padding above and below, passed to Section: `sm`, `md`, `lg`, `xl` or `none`, from the module padding steps; `default` is `md` and `compact` is `sm`. @default "default" */
  spacing?: "none" | "sm" | "md" | "lg" | "xl" | "default" | "compact";
  /** Padding above, when it differs from `spacing`. */
  spacingTop?: "none" | "sm" | "md" | "lg" | "xl";
  /** Padding below, when it differs from `spacing`. */
  spacingBottom?: "none" | "sm" | "md" | "lg" | "xl";
  /** `full` spans the screen; `inset` sets the band in from the page edges with the container radius. Passed to Section. @default "full" */
  bleed?: "full" | "inset";
  /** The inner column's width. @default "default" */
  width?: "narrow" | "default" | "wide" | "full";
}

export declare function FeatureGridBlock(props: FeatureGridBlockProps): React.JSX.Element;

Tokens it reads

Every custom property FeatureGridBlock.jsx resolves, read from the source: 6 component, 10 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-card-bgcomponentvar(--dt-surface-raised)
--dt-card-border-colorcomponentvar(--dt-border-default)
--dt-card-border-widthcomponentvar(--dt-border-width-default)
--dt-card-fgcomponentvar(--dt-text-primary)
--dt-card-paddingcomponentvar(--dt-space-inset-lg)
--dt-card-radiuscomponentvar(--dt-radius-container)
--dt-layout-module-gapsemanticvar(--dt-space-stack-xl)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-size-control-mdsemanticvar(--dt-dim-10)
--dt-space-inline-lgsemanticvar(--dt-dim-6)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-surface-brand-mutedsemanticvar(--dt-color-primary-050)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)

Source

React only, no dependencies, every value a token.

import React from "react";
import { Section } from "../primitives/Section.jsx";
import { Heading } from "../typography/Heading.jsx";
import { Text } from "../typography/Text.jsx";
import { BlockHeader } from "./BlockHeader.jsx";

/* The narrowest a column may get before the grid drops a column, per count:
   more columns ask for less room each, and a phone always ends up with one. */
const MIN = { 2: "360px", 3: "260px", 4: "200px" };

/* A header over a grid of features: an icon on a brand tint, a title and a
   sentence each. Cards puts each on a raised surface; plain leaves the band's
   own background. */
export function FeatureGridBlock({ eyebrow, title, titleSize = "heading-lg", lead, actions, items = [], columns = 3, align = "center", variant = "plain", tone = "base", dark, texture, spacing = "default", width = "default", ...rest }) {
  const card = variant === "cards";
  return (
    <Section tone={tone} dark={dark} texture={texture} spacing={spacing} width={width} {...rest}>
      <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-layout-module-gap)" }}>
        {(eyebrow || title || lead) && <BlockHeader eyebrow={eyebrow} title={title} size={titleSize} lead={lead} actions={actions} align={align} />}
        <div style={{ display: "grid", gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, ${MIN[columns] || MIN[3]}), 1fr))`, gap: "var(--dt-space-inline-lg)" }}>
          {items.map((it, i) => (
            <div key={it.title || i} style={{
              display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-xs)",
              padding: card ? "var(--dt-card-padding)" : 0,
              background: card ? "var(--dt-card-bg)" : undefined,
              border: card ? "var(--dt-card-border-width) solid var(--dt-card-border-color)" : undefined,
              borderRadius: card ? "var(--dt-card-radius)" : undefined,
              color: card ? "var(--dt-card-fg)" : undefined,
            }}>
              {it.icon && (
                <span aria-hidden="true" style={{
                  display: "inline-flex", alignItems: "center", justifyContent: "center", alignSelf: "flex-start",
                  width: "var(--dt-size-control-md)", height: "var(--dt-size-control-md)", marginBottom: "var(--dt-space-stack-xs)",
                  borderRadius: "var(--dt-radius-control)", background: "var(--dt-surface-brand-muted)", color: "var(--dt-text-on-brand-muted)",
                }}>{it.icon}</span>
              )}
              <Heading level={3} size="heading-sm">{it.title}</Heading>
              {it.description && <Text variant="small" tone="secondary">{it.description}</Text>}
              {it.href && <a href={it.href} style={{ marginTop: "var(--dt-space-stack-2xs)", color: "var(--dt-text-link)", fontSize: "var(--dt-text-body-sm-size)", textUnderlineOffset: 3 }}>{it.linkLabel || "Learn more"}</a>}
            </div>
          ))}
        </div>
      </div>
    </Section>
  );
}