All patterns

Coming Soon

A locked preview of an unreleased area, crossed once by a gloss and dated by a badge that lands last.

empty-statespremiumfuturisticautomatic · finite · intermediate · ~1.9s
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.

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

/**
 * Vibary · Coming Soon
 *
 * An area that exists but is not open yet. The layout behind is real
 * enough to recognise and dim enough not to read, a single gloss travels
 * across it once — once, not on a loop, because a surface that keeps
 * gleaming is asking for attention it cannot repay — and the date lands
 * last. Asking to be told swaps the label in place and leaves the rest
 * of the card exactly as it was.
 *
 * 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 ComingSoonPlaceholderProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Badge over the headline. */
  badge?: string;
  /** Headline of the unreleased area. */
  title?: string;
  /** One line about what is coming. */
  message?: string;
  /** Button labels, before and after asking to be notified. */
  notifyLabel?: string;
  notifiedLabel?: string;
  /** Fires when the reader asks to be told. */
  onNotify?: () => void;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Seconds the gloss takes to cross the card. */
  glossSeconds: number;
  /** Peak opacity of the gloss. */
  glossPeak: number;
  /** px the copy travels on its way in. */
  rise: number;
  /** Seconds between the badge, the headline and the rest. */
  stagger: number;
  fadeSeconds: number;
};

// Quality rule: nothing springs and nothing scales — the gloss is a
// translation, the copy is a fade with a few pixels of travel, and the
// button swaps text inside a fixed slot. A locked card that bounces
// promises a product that will not.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a highlight, for a disabled tab inside a working page.
  subtle: {
    glossSeconds: 1.1,
    glossPeak: 0.06,
    rise: 5,
    stagger: 0.05,
    fadeSeconds: 0.26,
  },
  // The all-purpose setting: one clear pass, then stillness.
  default: {
    glossSeconds: 1.4,
    glossPeak: 0.1,
    rise: 8,
    stagger: 0.07,
    fadeSeconds: 0.32,
  },
  // A slower, wider pass, for a marketing page teasing a launch.
  playful: {
    glossSeconds: 1.8,
    glossPeak: 0.14,
    rise: 12,
    stagger: 0.1,
    fadeSeconds: 0.38,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives a card, a gloss and a schematic layout that read
 *  correctly on light and dark pages alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ComingSoonPlaceholder({
  variant = "default",
  badge = "Coming in September",
  title = "Insights is almost ready",
  message = "Usage trends, retention and cohorts in one place.",
  notifyLabel = "Notify me",
  notifiedLabel = "We'll let you know",
  onNotify,
  width = 320,
}: ComingSoonPlaceholderProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [notified, setNotified] = useState(false);

  const rise = reduceMotion ? 0 : cfg.rise;
  const fade = (delay: number) => ({
    duration: cfg.fadeSeconds,
    ease: "easeOut" as const,
    delay,
  });

  const notify = () => {
    if (notified) return;
    setNotified(true);
    onNotify?.();
  };

  return (
    <div
      style={{
        position: "relative",
        width,
        boxSizing: "border-box",
        overflow: "hidden",
        borderRadius: 14,
        background: tone(4),
        border: `1px solid ${tone(11)}`,
      }}
    >
      {/* The shape of the feature, recognisable and unreadable. Static:
          scenery that animates invites a second look at something that
          does not exist yet. */}
      <div
        aria-hidden
        style={{
          position: "absolute",
          inset: 0,
          padding: 16,
          opacity: 0.4,
          userSelect: "none",
        }}
      >
        <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
          {[0, 1, 2].map((tile) => (
            <div
              key={tile}
              style={{
                flex: 1,
                height: 34,
                borderRadius: 8,
                background: tone(8),
              }}
            />
          ))}
        </div>
        {["88%", "64%", "76%"].map((barWidth) => (
          <div
            key={barWidth}
            style={{
              width: barWidth,
              height: 8,
              borderRadius: 4,
              background: tone(8),
              marginBottom: 9,
            }}
          />
        ))}
      </div>

      {/* A scrim between the scenery and the words: enough to read the
          copy against, not enough to hide what is behind it. */}
      <motion.div
        aria-hidden
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ duration: cfg.fadeSeconds * 1.3, ease: "easeOut" }}
        style={{ position: "absolute", inset: 0, background: tone(6) }}
      />

      {/* One pass, then gone. Skewed so it reads as light across glass
          rather than a bar sliding by. The skew is part of the animated
          transform, not a style override Motion would discard. */}
      {reduceMotion ? null : (
        <motion.div
          aria-hidden
          initial={{ x: "-140%", skewX: -14, opacity: 0 }}
          animate={{ x: "240%", skewX: -14, opacity: [0, cfg.glossPeak, 0] }}
          transition={{
            duration: cfg.glossSeconds,
            ease: "easeInOut",
            delay: 0.25,
            opacity: { duration: cfg.glossSeconds, delay: 0.25, times: [0, 0.4, 1] },
          }}
          style={{
            position: "absolute",
            top: 0,
            bottom: 0,
            width: "46%",
            background: `linear-gradient(90deg, transparent, ${tone(70)}, transparent)`,
            pointerEvents: "none",
          }}
        />
      )}

      <div
        style={{
          position: "relative",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          textAlign: "center",
          padding: "26px 22px 22px",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          transition={fade(0.1)}
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 6,
            fontSize: 10.5,
            fontWeight: 600,
            letterSpacing: 0.4,
            textTransform: "uppercase",
            padding: "4px 9px",
            borderRadius: 999,
            background: tone(8),
            border: `1px solid ${tone(14)}`,
          }}
        >
          <StackGlyph />
          {badge}
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          transition={fade(0.1 + cfg.stagger)}
          style={{ fontSize: 15, fontWeight: 650, marginTop: 12 }}
        >
          {title}
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 0.55, y: 0 }}
          transition={fade(0.1 + cfg.stagger * 2)}
          style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5, maxWidth: 232 }}
        >
          {message}
        </motion.div>

        <motion.button
          type="button"
          onClick={notify}
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          transition={fade(0.1 + cfg.stagger * 3)}
          style={{
            font: "inherit",
            fontSize: 12.5,
            fontWeight: 600,
            color: "inherit",
            background: tone(7),
            border: `1px solid ${tone(16)}`,
            borderRadius: 10,
            padding: "8px 14px",
            marginTop: 16,
            cursor: notified ? "default" : "pointer",
          }}
        >
          {/* Both labels share one cell, so the card keeps its width when
              the answer changes. */}
          <span style={{ display: "grid", placeItems: "center" }}>
            <motion.span
              animate={{ opacity: notified ? 0 : 1 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
            >
              {notifyLabel}
            </motion.span>
            <motion.span
              animate={{ opacity: notified ? 1 : 0 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
            >
              {notifiedLabel}
            </motion.span>
          </span>
        </motion.button>
      </div>
    </div>
  );
}

/** Line art authored inline: three offset plates, a feature still being
 *  assembled. Stroked in `currentColor` so it inherits the page theme. */
function StackGlyph() {
  return (
    <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
      <path
        d="M6 1.4l4.2 2.3L6 6 1.8 3.7z"
        stroke="currentColor"
        strokeWidth="1.1"
        strokeLinejoin="round"
        opacity="0.75"
      />
      <path
        d="M1.8 6.6L6 8.9l4.2-2.3"
        stroke="currentColor"
        strokeWidth="1.1"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.45"
      />
    </svg>
  );
}

About this pattern

An area that exists in the navigation but is not open yet. The layout behind the card is real enough to recognise and dim enough not to read, so the reader learns roughly what is coming; a single gloss travels across it once and stops, because a surface that keeps gleaming is asking for attention it cannot repay yet. The badge, headline and date arrive in order, and asking to be notified swaps the button label inside a fixed slot so nothing about the card moves. Reduced motion drops the gloss entirely and keeps the sequence.

Unreleased sectionFeature teaser in navigationWaitlist cardLocked tab in a plan

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
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    An upcoming area appears in the sidebar as a dimmed card with a short note about timing.

Related patterns