All patterns

Create First Item

An outlined row fills its slots from the left to show the shape of the thing you have not made yet.

empty-statesfriendlyenergeticautomatic · finite · starter · ~1.2s
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.

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

/**
 * Vibary · Create First Item
 *
 * An empty list explains itself by showing the shape of what belongs in
 * it. A dashed row arrives, its slots fill from the left as if being
 * written, and the invitation follows. Accept it and the same row is
 * replaced in place by a real one — the preview and the result occupy
 * the same cell, so nothing on the page shifts at the hand-off.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CreateFirstItemProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Sentence under the outlined row. */
  hint?: string;
  /** Label of the primary action. */
  actionLabel?: string;
  /** Title of the row created when the action is pressed. */
  itemTitle?: string;
  /** Secondary line of that row. */
  itemMeta?: string;
  /** Button and swatch color. A literal brand color, not a surface. */
  accent?: string;
  /** Fires when the action is pressed. */
  onCreate?: () => void;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** px the outlined row and the copy travel on their way in. */
  rise: number;
  /** Seconds a slot bar takes to grow out to full width. */
  barSeconds: number;
  /** Seconds between one bar starting and the next. */
  stagger: number;
  fadeSeconds: number;
  /** Seconds the preview takes to hand over to the real row. */
  swapSeconds: number;
  popSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the sprung elements are glyphs, never text, and every
// spring sits above 0.89 damping ratio (damping / 2√stiffness) so each
// lands with one settle. Bars grow on a tween because a growing bar that
// overshoots reads as a bug.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A quiet suggestion, for a list that is empty only briefly.
  subtle: {
    rise: 5,
    barSeconds: 0.26,
    stagger: 0.05,
    fadeSeconds: 0.22,
    swapSeconds: 0.2,
    popSpring: { type: "spring", stiffness: 520, damping: 46 },
  },
  // Clearly a demonstration of what goes here. All-purpose.
  default: {
    rise: 9,
    barSeconds: 0.34,
    stagger: 0.08,
    fadeSeconds: 0.28,
    swapSeconds: 0.26,
    popSpring: { type: "spring", stiffness: 420, damping: 40 },
  },
  // The full walkthrough, for a first-run screen someone sees once.
  playful: {
    rise: 13,
    barSeconds: 0.42,
    stagger: 0.11,
    fadeSeconds: 0.32,
    swapSeconds: 0.3,
    popSpring: { type: "spring", stiffness: 340, damping: 33 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps the outlined row and its slots legible on light
 *  and dark pages alike. The accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function CreateFirstItem({
  variant = "default",
  hint = "Your first document will look like this",
  actionLabel = "New document",
  itemTitle = "Weekly status",
  itemMeta = "Draft · edited just now",
  accent = "#5B5BD6",
  onCreate,
  width = 320,
}: CreateFirstItemProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [created, setCreated] = useState(false);

  // Reduced motion keeps the whole sequence — outline, slots, invitation
  // — and drops only the travel and the growth.
  const rise = reduceMotion ? 0 : cfg.rise;
  const swap = { duration: cfg.swapSeconds, ease: "easeOut" as const };

  const create = () => {
    setCreated(true);
    onCreate?.();
  };

  return (
    <div
      style={{
        width,
        boxSizing: "border-box",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        padding: "22px 20px 20px",
      }}
    >
      {/* Preview and result share one grid cell, so the row is already
          its final size before anything is created. */}
      <motion.div
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
        style={{ display: "grid", width: "100%" }}
      >
        <motion.div
          aria-hidden
          animate={{ opacity: created ? 0 : 1 }}
          transition={swap}
          style={{
            gridArea: "1 / 1",
            display: "flex",
            alignItems: "center",
            gap: 12,
            padding: "13px 14px",
            borderRadius: 12,
            border: `1px dashed ${tone(20)}`,
            pointerEvents: "none",
          }}
        >
          <motion.div
            initial={{ scale: reduceMotion ? 1 : 0.6, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            transition={
              reduceMotion
                ? { duration: 0.2, ease: "easeOut" }
                : { ...cfg.popSpring, delay: 0.1, opacity: { duration: 0.2 } }
            }
            style={{
              width: 26,
              height: 26,
              borderRadius: 8,
              background: tone(9),
              flexShrink: 0,
            }}
          />
          <div style={{ flex: 1, display: "grid", gap: 7 }}>
            {["62%", "38%"].map((barWidth, index) => (
              <motion.div
                key={barWidth}
                initial={{ scaleX: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
                animate={{ scaleX: 1, opacity: 1 }}
                transition={{
                  duration: reduceMotion ? 0.2 : cfg.barSeconds,
                  ease: "easeOut",
                  delay: 0.16 + index * cfg.stagger,
                }}
                style={{
                  width: barWidth,
                  height: index === 0 ? 8 : 6,
                  borderRadius: 4,
                  background: tone(index === 0 ? 12 : 8),
                  transformOrigin: "left center",
                }}
              />
            ))}
          </div>
        </motion.div>

        <motion.div
          initial={false}
          animate={{ opacity: created ? 1 : 0, y: created ? 0 : rise * 0.4 }}
          transition={swap}
          style={{
            gridArea: "1 / 1",
            display: "flex",
            alignItems: "center",
            gap: 12,
            padding: "13px 14px",
            borderRadius: 12,
            background: tone(5),
            border: `1px solid ${tone(12)}`,
            pointerEvents: created ? "auto" : "none",
          }}
        >
          <div
            style={{
              width: 26,
              height: 26,
              borderRadius: 8,
              background: accent,
              display: "grid",
              placeItems: "center",
              flexShrink: 0,
            }}
          >
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
              <path
                d="M4 2.5h4l2.5 2.5v6.5H4z"
                stroke="#fff"
                strokeWidth="1.2"
                strokeLinejoin="round"
              />
              <path d="M8 2.5V5h2.5" stroke="#fff" strokeWidth="1.2" strokeLinejoin="round" />
            </svg>
          </div>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontSize: 13, fontWeight: 620 }}>{itemTitle}</div>
            <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>{itemMeta}</div>
          </div>
        </motion.div>
      </motion.div>

      <motion.div
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: created ? 0 : 0.52, y: 0 }}
        transition={{
          duration: cfg.fadeSeconds,
          ease: "easeOut",
          delay: created ? 0 : 0.34,
        }}
        style={{ fontSize: 12, marginTop: 12, textAlign: "center" }}
      >
        {hint}
      </motion.div>

      <motion.button
        type="button"
        onClick={create}
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: cfg.fadeSeconds, ease: "easeOut", delay: 0.42 }}
        style={{
          font: "inherit",
          display: "inline-flex",
          alignItems: "center",
          gap: 7,
          fontSize: 12.5,
          fontWeight: 600,
          color: "#fff",
          background: accent,
          border: "none",
          borderRadius: 10,
          padding: "9px 14px",
          marginTop: 14,
          cursor: "pointer",
        }}
      >
        <motion.span
          initial={{ scale: reduceMotion ? 1 : 0.5 }}
          animate={{ scale: 1 }}
          transition={
            reduceMotion
              ? { duration: 0.2 }
              : { ...cfg.popSpring, delay: 0.5 }
          }
          style={{ display: "inline-flex", lineHeight: 0 }}
        >
          <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
            <path
              d="M6 1.5v9M1.5 6h9"
              stroke="#fff"
              strokeWidth="1.6"
              strokeLinecap="round"
            />
          </svg>
        </motion.span>
        {actionLabel}
      </motion.button>
    </div>
  );
}

About this pattern

An empty list that only says "nothing here" leaves the reader to imagine what belongs in it. This one demonstrates instead: a dashed row arrives, its swatch settles and its slot bars grow out from the left as if the row were being written, and the invitation follows once the shape is legible. Pressing the action replaces the outline with a real row in the same grid cell, so the demonstration becomes the result without the list resizing under it. Reduced motion keeps the order — outline, slots, invitation — and drops the growth.

First run listNew workspaceEmpty project boardZero-state file browser

Where it shows up

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

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    No issues match these filtersWhen there is something to show, it appears here.
    Issue tracker

    A new team's issue list shows the outline of a row alongside the action that creates one.

Related patterns