All patterns

Seed First Project

The blank panel steps aside and a starter project builds itself in its place, task by task.

onboardingenergeticfriendlyinteraction · finite · intermediate · ~0.7s
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.

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

/**
 * Vibary · Seed First Project
 *
 * An empty workspace fills itself. The blank panel steps aside, a
 * starter project takes its place, and its tasks arrive in the order
 * they would have been written — so the workspace looks created rather
 * than merely loaded.
 *
 * 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; tune via `variant`, `project`,
 * `tasks`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SeedTask = {
  label: string;
  meta: string;
};

export type EmptyWorkspaceSeedProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Name of the project that gets created. */
  project?: string;
  /** Your own starter tasks. The embedded sample is used when omitted. */
  tasks?: SeedTask[];
  /** Label of the button on the empty state. */
  actionLabel?: string;
  /** Project swatch and button color. */
  accent?: string;
  /** Fires when the starter project is created. */
  onSeed?: () => void;
};

type VariantConfig = {
  /** px each arriving element travels. */
  rise: number;
  /** Seconds between one task landing and the next starting. */
  stagger: number;
  /** Seconds the empty panel takes to leave. */
  exitSeconds: number;
  cardSpring: { type: "spring"; stiffness: number; damping: number };
  taskSeconds: number;
};

// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8, and the rows are text, so they travel on tweens. Variants change
// the pace and the distance, never the bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely there. For a workspace that seeds itself without being asked.
  subtle: {
    rise: 6,
    stagger: 0.045,
    exitSeconds: 0.14,
    cardSpring: { type: "spring", stiffness: 520, damping: 46 },
    taskSeconds: 0.22,
  },
  // A clear sequence of things being made. All-purpose.
  default: {
    rise: 10,
    stagger: 0.07,
    exitSeconds: 0.18,
    cardSpring: { type: "spring", stiffness: 420, damping: 40 },
    taskSeconds: 0.28,
  },
  // A longer build, for the one time a new account is set up.
  playful: {
    rise: 14,
    stagger: 0.1,
    exitSeconds: 0.22,
    cardSpring: { type: "spring", stiffness: 340, damping: 33 },
    taskSeconds: 0.32,
  },
};

const SAMPLE_TASKS: SeedTask[] = [
  { label: "Write the project brief", meta: "Today" },
  { label: "Collect reference links", meta: "Thu" },
  { label: "Share with the team", meta: "Fri" },
];

/** Fixed so the panel cannot resize as its contents are replaced — a
 *  frame that jumps mid-swap makes both states read as sloppy. */
const STAGE_HEIGHT = 214;

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function EmptyWorkspaceSeed({
  variant = "default",
  project = "Launch plan",
  tasks = SAMPLE_TASKS,
  actionLabel = "Create starter project",
  accent = "#5B5BD6",
  onSeed,
}: EmptyWorkspaceSeedProps) {
  const [seeded, setSeeded] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: everything still arrives in order, it just arrives
  // without travelling. The sequence is the information.
  const rise = reduceMotion ? 0 : cfg.rise;

  const seed = () => {
    setSeeded(true);
    onSeed?.();
  };

  return (
    <div
      style={{
        width: 320,
        boxSizing: "border-box",
        padding: 16,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          marginBottom: 12,
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>Projects</span>
        <button
          type="button"
          onClick={() => setSeeded(false)}
          disabled={!seeded}
          style={{
            padding: "4px 9px",
            fontSize: 11,
            fontWeight: 600,
            fontFamily: "inherit",
            color: "inherit",
            background: "transparent",
            border: `1px solid ${tone(14)}`,
            borderRadius: 7,
            opacity: seeded ? 0.6 : 0,
            cursor: seeded ? "pointer" : "default",
          }}
        >
          Empty it
        </button>
      </div>

      <div style={{ position: "relative", height: STAGE_HEIGHT }}>
        <AnimatePresence initial={false}>
          {!seeded && (
            <motion.div
              key="empty"
              initial={false}
              exit={{
                opacity: 0,
                y: -rise * 0.6,
                transition: { duration: cfg.exitSeconds, ease: "easeIn" },
              }}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                justifyContent: "center",
                gap: 10,
                padding: 16,
                boxSizing: "border-box",
                borderRadius: 14,
                border: `1px dashed ${tone(20)}`,
                background: tone(3),
                textAlign: "center",
              }}
            >
              <EmptyMark />
              <div style={{ fontSize: 13, fontWeight: 600 }}>Nothing here yet</div>
              <p
                style={{
                  margin: 0,
                  maxWidth: 210,
                  fontSize: 11.5,
                  lineHeight: 1.55,
                  opacity: 0.55,
                }}
              >
                Start from a plan we prepared and fill in the details later.
              </p>
              <button
                type="button"
                onClick={seed}
                style={{
                  marginTop: 2,
                  padding: "9px 14px",
                  fontSize: 12.5,
                  fontWeight: 650,
                  fontFamily: "inherit",
                  color: "#ffffff",
                  background: accent,
                  border: "none",
                  borderRadius: 10,
                  cursor: "pointer",
                }}
              >
                {actionLabel}
              </button>
            </motion.div>
          )}
        </AnimatePresence>

        {seeded && (
          <div style={{ position: "absolute", inset: 0 }}>
            <motion.div
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: rise }}
              animate={{ opacity: 1, y: 0 }}
              transition={
                reduceMotion
                  ? { duration: 0.2, ease: "easeOut" }
                  : { ...cfg.cardSpring, delay: cfg.exitSeconds * 0.5 }
              }
              style={{
                display: "flex",
                alignItems: "center",
                gap: 10,
                padding: "10px 12px",
                borderRadius: 12,
                border: `1px solid ${tone(12)}`,
                background: tone(7),
              }}
            >
              <span
                style={{
                  width: 22,
                  height: 22,
                  borderRadius: 7,
                  display: "grid",
                  placeItems: "center",
                  background: accent,
                }}
              >
                <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
                  <path
                    d="M1.6 3.4a1.4 1.4 0 0 1 1.4-1.4h1.7l1 1.3h3.7a1.4 1.4 0 0 1 1.4 1.4v3.9a1.4 1.4 0 0 1-1.4 1.4H3a1.4 1.4 0 0 1-1.4-1.4z"
                    stroke="#ffffff"
                    strokeWidth="1.2"
                    strokeLinejoin="round"
                  />
                </svg>
              </span>
              <span style={{ fontSize: 13, fontWeight: 650 }}>{project}</span>
              <span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.45 }}>
                Just now
              </span>
            </motion.div>

            <ul style={{ listStyle: "none", margin: "10px 0 0", padding: 0 }}>
              {tasks.map((task, index) => (
                <motion.li
                  key={task.label}
                  // Rows are text, so they translate and fade only —
                  // never scale, never rebound.
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: rise }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: reduceMotion ? 0.18 : cfg.taskSeconds,
                    delay:
                      (reduceMotion ? 0.04 : cfg.stagger) * index +
                      (reduceMotion ? 0.06 : cfg.exitSeconds + 0.1),
                    ease: "easeOut",
                  }}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: 9,
                    padding: "9px 2px",
                    borderTop: `1px solid ${tone(8)}`,
                    fontSize: 12.5,
                  }}
                >
                  <span
                    style={{
                      width: 13,
                      height: 13,
                      borderRadius: 999,
                      border: `1.4px solid ${tone(28)}`,
                    }}
                  />
                  {task.label}
                  <span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.45 }}>
                    {task.meta}
                  </span>
                </motion.li>
              ))}
            </ul>

            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 0.5 }}
              transition={{
                duration: 0.24,
                delay: reduceMotion
                  ? 0.2
                  : cfg.exitSeconds + 0.1 + cfg.stagger * tasks.length + 0.1,
                ease: "easeOut",
              }}
              style={{ marginTop: 10, fontSize: 11, fontWeight: 600 }}
            >
              {`${tasks.length} tasks added · edit anything`}
            </motion.div>
          </div>
        )}
      </div>
    </div>
  );
}

/** Outline of a stack of sheets — an inline SVG so the file stays a
 *  single copyable unit with no asset to fetch. */
function EmptyMark() {
  return (
    <svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden>
      <rect
        x="9.5"
        y="6.5"
        width="23"
        height="19"
        rx="4"
        stroke="currentColor"
        strokeOpacity="0.32"
        strokeWidth="1.6"
      />
      <rect
        x="6.5"
        y="13.5"
        width="29"
        height="21"
        rx="5"
        stroke="currentColor"
        strokeOpacity="0.5"
        strokeWidth="1.6"
      />
      <path
        d="M12.5 21h11M12.5 26h17"
        stroke="currentColor"
        strokeOpacity="0.32"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

About this pattern

The first thing a new account needs is something to look at. Pressing the empty state's button sends the blank panel up and out on a short eased exit, then the project card lands on a spring and its tasks follow in the order they would have been written — a build, not a page load, which is what makes a seeded workspace feel authored rather than faked. The frame around both states has a fixed height so nothing jumps as one replaces the other, and every task row translates and fades only, because rows are text. Reduced motion keeps the order of arrival and drops the travel.

Empty workspaceFirst project creationStarter templateNew account setup

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

    An empty page replaced by a template whose blocks arrive in reading order.

Related patterns