All patterns

Template Gallery Pick

The chosen thumbnail grows in place into a preview of the workspace it would create.

onboardingpremiumelegantinteraction · finite · advanced · ~0.5s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

452 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Template Gallery Pick
 *
 * Choosing a template expands its thumbnail into a preview of the
 * workspace it will create. The artwork is one shared element that
 * grows in place, so the thing being previewed is visibly the thing
 * that was picked.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `templates`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type GalleryTemplate = {
  id: string;
  name: string;
  summary: string;
  detail: string;
  /** Artwork motif: "board", "calendar" or "pipeline". */
  art: "board" | "calendar" | "pipeline";
  /** Hex tint for the thumbnail wash. */
  tint: string;
};

export type TemplateGalleryPickProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Your own templates. The embedded sample is used when omitted. */
  templates?: GalleryTemplate[];
  /** Confirm button label inside the preview. */
  useLabel?: string;
  /** Confirm button color. */
  accent?: string;
  /** Fires when a template's preview is confirmed. */
  onUse?: (id: string) => void;
};

type VariantConfig = {
  expand: { type: "spring"; stiffness: number; damping: number };
  /** Pause before the preview's text arrives, in seconds. */
  contentDelay: number;
  /** How far the preview's text rises into place, in px. */
  rise: number;
};

// Quality rule: the frame may grow, the words may not. Every spring sits
// at or above a 0.8 damping ratio, and the copy inside the preview fades
// up at a constant size rather than riding the scale — text carried by a
// layout animation is text nobody can read while it moves.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Straight in, no settle. For a gallery of twenty templates.
  subtle: {
    expand: { type: "spring", stiffness: 630, damping: 52 },
    contentDelay: 0.05,
    rise: 2,
  },
  // One soft landing. The all-purpose setting.
  default: {
    expand: { type: "spring", stiffness: 360, damping: 36 },
    contentDelay: 0.1,
    rise: 7,
  },
  // A slower, weightier expansion for a hero picker on its own screen.
  playful: {
    expand: { type: "spring", stiffness: 240, damping: 27 },
    contentDelay: 0.19,
    rise: 14,
  },
};

/** Neutral surfaces are mixed from the inherited text color, so the card
 *  reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** The tint is a brand color, not a surface, so it stays literal — it is
 *  only ever used at low strength as a wash behind the artwork. */
const wash = (tint: string, percent: number) =>
  `color-mix(in srgb, ${tint} ${percent}%, transparent)`;

const SAMPLE_TEMPLATES: GalleryTemplate[] = [
  {
    id: "tracker",
    name: "Project tracker",
    summary: "3 boards · 24 cards",
    detail:
      "Boards for planned, active and shipped work, with a weekly review view already set up.",
    art: "board",
    tint: "#5B5BD6",
  },
  {
    id: "calendar",
    name: "Content calendar",
    summary: "Month view · 12 slots",
    detail:
      "A month grid with draft, review and publish states, plus a backlog for unscheduled ideas.",
    art: "calendar",
    tint: "#2F8F9D",
  },
  {
    id: "pipeline",
    name: "Sales pipeline",
    summary: "5 stages · 40 deals",
    detail:
      "Stages from first contact to signed, with per-stage value roll-ups on the summary row.",
    art: "pipeline",
    tint: "#B07A3E",
  },
];

/** Thumbnail and preview share an aspect ratio, so the shared frame
 *  scales uniformly — a frame that changes shape mid-flight distorts
 *  everything drawn inside it. */
const THUMB_WIDTH = 88;
const THUMB_HEIGHT = 30;
const PREVIEW_HEIGHT = 97;
const STAGE_HEIGHT = 258;

export default function TemplateGalleryPick({
  variant = "default",
  templates = SAMPLE_TEMPLATES,
  useLabel = "Use this template",
  accent = "#5B5BD6",
  onUse,
}: TemplateGalleryPickProps) {
  const [openId, setOpenId] = useState<string | null>(null);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const open = templates.find((template) => template.id === openId) ?? null;
  const morph = reduceMotion
    ? { duration: 0.2, ease: "easeOut" as const }
    : cfg.expand;

  return (
    <div
      style={{
        position: "relative",
        width: 320,
        height: STAGE_HEIGHT,
        padding: 18,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        boxSizing: "border-box",
        overflow: "hidden",
      }}
    >
      <div style={{ fontSize: 15, fontWeight: 650 }}>Pick a starting point</div>
      <p style={{ margin: "4px 0 12px", fontSize: 12, opacity: 0.55 }}>
        Every template is fully editable once it is created.
      </p>

      <div style={{ display: "flex", gap: 8 }}>
        {templates.map((template) => (
          <button
            key={template.id}
            type="button"
            onClick={() => setOpenId(template.id)}
            style={{
              flex: 1,
              padding: 0,
              textAlign: "left",
              fontFamily: "inherit",
              color: "inherit",
              background: "transparent",
              border: "none",
              cursor: "pointer",
            }}
          >
            <motion.span
              // The frame is the shared element. Only the wash lives on
              // it, so the expansion has nothing brittle to carry.
              layoutId={reduceMotion ? undefined : `template-frame-${template.id}`}
              transition={morph}
              style={{
                display: "block",
                height: THUMB_HEIGHT,
                borderRadius: 9,
                border: `1px solid ${tone(12)}`,
                background: `linear-gradient(135deg, ${wash(
                  template.tint,
                  24
                )}, ${wash(template.tint, 8)})`,
                overflow: "hidden",
              }}
            >
              <TemplateArt art={template.art} tint={template.tint} />
            </motion.span>
            <span
              style={{
                display: "block",
                marginTop: 6,
                fontSize: 11.5,
                fontWeight: 600,
              }}
            >
              {template.name}
            </span>
            <span
              style={{ display: "block", marginTop: 1, fontSize: 10.5, opacity: 0.45 }}
            >
              {template.summary}
            </span>
          </button>
        ))}
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          marginTop: 14,
          padding: "9px 11px",
          fontSize: 12,
          borderRadius: 10,
          border: `1px dashed ${tone(14)}`,
          opacity: 0.6,
        }}
      >
        <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
          <path
            d="M8 3.6v8.8M3.6 8h8.8"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
          />
        </svg>
        Start from an empty workspace
      </div>

      <div
        style={{
          marginTop: 12,
          fontSize: 11.5,
          opacity: 0.4,
        }}
      >
        Browse all 24 templates
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            key="preview"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0.16 : 0.2, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              padding: 18,
              boxSizing: "border-box",
              // Opaque, because the gallery is still mounted underneath:
              // `Canvas`/`CanvasText` are the CSS system colors for page
              // background and text, so the preview lands light in a
              // light app and dark in a dark one.
              background: "Canvas",
              color: "CanvasText",
            }}
          >
            <motion.div
              layoutId={reduceMotion ? undefined : `template-frame-${open.id}`}
              transition={morph}
              style={{
                height: PREVIEW_HEIGHT,
                borderRadius: 12,
                border: `1px solid ${tone(12)}`,
                background: `linear-gradient(135deg, ${wash(
                  open.tint,
                  24
                )}, ${wash(open.tint, 8)})`,
                overflow: "hidden",
              }}
            >
              <TemplateArt art={open.art} tint={open.tint} />
            </motion.div>

            <motion.div
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                duration: 0.28,
                delay: cfg.contentDelay,
                ease: "easeOut",
              }}
              style={{ marginTop: 11 }}
            >
              <div
                style={{
                  display: "flex",
                  alignItems: "baseline",
                  justifyContent: "space-between",
                  gap: 8,
                }}
              >
                <span style={{ fontSize: 14.5, fontWeight: 650 }}>{open.name}</span>
                <span style={{ fontSize: 11, opacity: 0.45 }}>{open.summary}</span>
              </div>
              <p
                style={{
                  margin: "5px 0 0",
                  fontSize: 12,
                  lineHeight: 1.5,
                  opacity: 0.6,
                }}
              >
                {open.detail}
              </p>
            </motion.div>

            <motion.div
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                duration: 0.28,
                delay: cfg.contentDelay + 0.06,
                ease: "easeOut",
              }}
              style={{ display: "flex", gap: 8, marginTop: 12 }}
            >
              <button
                type="button"
                onClick={() => setOpenId(null)}
                style={{
                  padding: "9px 13px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: "transparent",
                  border: `1px solid ${tone(18)}`,
                  borderRadius: 9,
                  cursor: "pointer",
                }}
              >
                Back
              </button>
              <button
                type="button"
                onClick={() => {
                  onUse?.(open.id);
                  setOpenId(null);
                }}
                style={{
                  flex: 1,
                  padding: "9px 13px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "#ffffff",
                  background: accent,
                  border: "none",
                  borderRadius: 9,
                  cursor: "pointer",
                }}
              >
                {useLabel}
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

/** Abstract artwork, drawn as rectangles so it scales with the frame
 *  without a single asset request. */
function TemplateArt({
  art,
  tint,
}: {
  art: GalleryTemplate["art"];
  tint: string;
}) {
  return (
    <svg
      viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
      preserveAspectRatio="none"
      width="100%"
      height="100%"
      aria-hidden
      style={{ display: "block" }}
    >
      {art === "board" &&
        [6, 32, 58].map((x, column) => (
          <g key={x}>
            <rect
              x={x}
              y={4}
              width={24}
              height={2.5}
              rx={1.25}
              fill={tint}
              opacity={0.55}
            />
            {[0, 1, 2].slice(0, 3 - column).map((row) => (
              <rect
                key={row}
                x={x}
                y={9 + row * 6.5}
                width={24}
                height={5}
                rx={1.8}
                fill="currentColor"
                opacity={0.16}
              />
            ))}
          </g>
        ))}

      {art === "calendar" &&
        [0, 1, 2].map((row) =>
          [0, 1, 2, 3, 4, 5].map((column) => (
            <rect
              key={`${row}-${column}`}
              x={6 + column * 13}
              y={5 + row * 8}
              width={10}
              height={6}
              rx={1.6}
              fill={row === 1 && column < 3 ? tint : "currentColor"}
              opacity={row === 1 && column < 3 ? 0.5 : 0.15}
            />
          ))
        )}

      {art === "pipeline" && (
        <g>
          {[8, 13, 18, 21, 24].map((height, index) => (
            <rect
              key={index}
              x={6 + index * 16}
              y={THUMB_HEIGHT - 3 - height}
              width={11}
              height={height}
              rx={2}
              fill={index === 4 ? tint : "currentColor"}
              opacity={index === 4 ? 0.55 : 0.16}
            />
          ))}
        </g>
      )}
    </svg>
  );
}

About this pattern

A starting-point chooser where picking and previewing are the same gesture. The thumbnail's frame is a shared element: Motion measures it in the strip and again in the preview and grows it between the two, so what is being described is visibly what was tapped. Thumbnail and preview are cut to the same aspect ratio, which keeps the scale uniform — a frame that changes shape mid-flight distorts everything drawn inside it. Only the wash and the artwork ride the expansion; the name, the summary and the buttons fade up afterwards at their final size, because text carried by a layout animation is text nobody can read while it moves. The artwork is inline SVG, so a gallery of templates costs no image requests.

Template chooserOnboarding flowStarter content pickerTheme gallery

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    A template tile opens into a full preview of the pages it would add.

Related patterns