All patterns

Back to Top Appear

A return control lifts into the corner once the reader is deep enough for it to matter.

navigationsubtlefriendlyautomatic · finite · starter · ~0.3s
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.

312 lines · react + motion only
import { useEffect, useRef, useState, type UIEvent } from "react";
import { animate, AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Back to Top Appear
 *
 * The return control stays out of the way until the reader is deep
 * enough for it to be worth something, then lifts into the corner.
 * Pressing it takes the list home and the button sees itself out.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `threshold`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BackToTopAppearProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Scroll distance, in px, before the control is worth showing. */
  threshold?: number;
  /**
   * Scrolls the list once on mount so the control appears without input.
   * Turn this off in a real app — there the reader is the trigger.
   */
  demoScroll?: boolean;
  /** Fires when the control is pressed. */
  onReturn?: () => void;
};

type VariantConfig = {
  /** How far the control rises into place, in px. */
  lift: number;
  /** Size it enters from, as a fraction of its resting size. */
  scaleFrom: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Seconds for the exit, which is deliberately quicker than the entrance. */
  exit: number;
  /** Seconds for the return journey up the list. */
  returnSeconds: number;
};

// Quality rule: this control appears in the corner of the reader's eye
// while they are moving, so it must not wobble there. Every spring is at
// or above a 0.8 damping ratio and lands once. Variants change the travel
// and the pace of the return, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a move — for a text-dense page where the control should
  // register only when looked for.
  subtle: {
    lift: 6,
    scaleFrom: 0.96,
    spring: { type: "spring", stiffness: 540, damping: 44 },
    exit: 0.12,
    returnSeconds: 0.45,
  },
  // Rises a touch as it fades in. The all-purpose setting.
  default: {
    lift: 12,
    scaleFrom: 0.88,
    spring: { type: "spring", stiffness: 440, damping: 36 },
    exit: 0.14,
    returnSeconds: 0.55,
  },
  // More travel and a longer glide home — for a feed people scroll a
  // long way down.
  playful: {
    lift: 18,
    scaleFrom: 0.8,
    spring: { type: "spring", stiffness: 380, damping: 32 },
    exit: 0.16,
    returnSeconds: 0.7,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const UPDATES = [
  ["Billing v2 moved to review", "Priya Raman · 12 min ago"],
  ["Export queue drained", "Platform · 40 min ago"],
  ["Seat limit raised to 60", "Workspace · 1 h ago"],
  ["Two invoices need attention", "Finance · 2 h ago"],
  ["Search index rebuilt", "Platform · 3 h ago"],
  ["New teammate invited", "Workspace · Yesterday"],
  ["Retention policy updated", "Security · Yesterday"],
  ["Weekly digest sent", "Reports · Yesterday"],
  ["API keys rotated", "Security · 2 days ago"],
  ["Region added: eu-west", "Platform · 3 days ago"],
] as const;

export default function BackToTopAppear({
  variant = "default",
  threshold = 120,
  demoScroll = true,
  onReturn,
}: BackToTopAppearProps) {
  const [visible, setVisible] = useState(false);
  const scrollRef = useRef<HTMLDivElement>(null);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const handleScroll = (event: UIEvent<HTMLDivElement>) => {
    const next = event.currentTarget.scrollTop > threshold;
    if (next !== visible) setVisible(next);
  };

  // The preview has no one to scroll it, so the list scrolls itself far
  // enough to earn the control, and stops the moment a real wheel or
  // touch arrives.
  useEffect(() => {
    const element = scrollRef.current;
    if (!element || !demoScroll) return;
    const target = threshold + 110;

    if (reduceMotion) {
      const timer = window.setTimeout(() => {
        element.scrollTop = target;
      }, 240);
      return () => window.clearTimeout(timer);
    }

    const controls = animate(0, target, {
      duration: 1.5,
      delay: 0.4,
      ease: "easeInOut",
      onUpdate: (value) => {
        element.scrollTop = value;
      },
    });
    const stop = () => controls.stop();
    element.addEventListener("wheel", stop, { passive: true });
    element.addEventListener("touchstart", stop, { passive: true });
    return () => {
      controls.stop();
      element.removeEventListener("wheel", stop);
      element.removeEventListener("touchstart", stop);
    };
  }, [demoScroll, reduceMotion, threshold]);

  const goToTop = () => {
    onReturn?.();
    const element = scrollRef.current;
    if (!element) return;
    // Reduced motion: arrive at the top rather than travel there. A long
    // scroll is the largest movement this component can produce, so it is
    // the first thing to drop.
    if (reduceMotion) {
      element.scrollTop = 0;
      setVisible(false);
      return;
    }
    animate(element.scrollTop, 0, {
      duration: cfg.returnSeconds,
      ease: [0.32, 0.72, 0, 1],
      onUpdate: (value) => {
        element.scrollTop = value;
      },
    });
  };

  // Reduced motion: the control still arrives and still leaves — it is a
  // control, not an ornament — it simply fades instead of travelling.
  const enter = reduceMotion
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, transition: { duration: 0.1 } },
        transition: { duration: 0.14, ease: "easeOut" as const },
      }
    : {
        initial: { opacity: 0, y: cfg.lift, scale: cfg.scaleFrom },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: {
          opacity: 0,
          y: cfg.lift * 0.5,
          scale: cfg.scaleFrom,
          transition: { duration: cfg.exit, ease: "easeIn" as const },
        },
        transition: cfg.spring,
      };

  return (
    <div
      style={{
        position: "relative",
        width: 330,
        height: 344,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
        overflow: "hidden",
      }}
    >
      <div
        ref={scrollRef}
        onScroll={handleScroll}
        style={{ height: "100%", overflowY: "auto", padding: "16px 16px 22px" }}
      >
        <div style={{ fontSize: 15, fontWeight: 650 }}>Workspace activity</div>
        <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
          Everything from the last few days
        </div>

        <div style={{ marginTop: 12 }}>
          {UPDATES.map(([title, meta]) => (
            <div
              key={title}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 10,
                padding: "10px 0",
                borderTop: `1px solid ${tone(10)}`,
              }}
            >
              <span
                aria-hidden
                style={{
                  width: 6,
                  height: 6,
                  flexShrink: 0,
                  borderRadius: 3,
                  background: tone(24),
                }}
              />
              <span style={{ minWidth: 0 }}>
                <span
                  style={{
                    display: "block",
                    fontSize: 13,
                    fontWeight: 600,
                    whiteSpace: "nowrap",
                    overflow: "hidden",
                    textOverflow: "ellipsis",
                  }}
                >
                  {title}
                </span>
                <span style={{ display: "block", fontSize: 11.5, opacity: 0.5 }}>
                  {meta}
                </span>
              </span>
            </div>
          ))}
        </div>
      </div>

      {/* Anchored to this panel's bottom-right corner rather than the
          viewport's, so the pattern drops into a card unchanged. For a
          whole page, swap `absolute` for `fixed`, keep the same offsets,
          and read `window.scrollY` in place of the container's scrollTop. */}
      <AnimatePresence>
        {visible && (
          <motion.button
            key="back-to-top"
            type="button"
            onClick={goToTop}
            aria-label="Back to top"
            whileHover={reduceMotion ? undefined : { y: -2 }}
            whileTap={reduceMotion ? undefined : { scale: 0.94 }}
            {...enter}
            style={{
              position: "absolute",
              right: 14,
              bottom: 14,
              display: "grid",
              placeItems: "center",
              width: 40,
              height: 40,
              borderRadius: 20,
              border: `1px solid ${tone(14)}`,
              // Opaque, not toned: it sits over scrolling text, and a
              // translucent control would let the lines run through it.
              // `Canvas`/`CanvasText` are the CSS system colors for page
              // background and page text, so it lands light in a light app
              // and dark in a dark one.
              background: "Canvas",
              color: "CanvasText",
              boxShadow: "0 8px 22px rgba(0,0,0,0.24)",
              cursor: "pointer",
            }}
          >
            <svg
              width="16"
              height="16"
              viewBox="0 0 18 18"
              fill="none"
              stroke={ACCENT}
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden
            >
              <path d="M9 14V4.6" />
              <path d="M4.8 8.8 9 4.4l4.2 4.4" />
            </svg>
          </motion.button>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

The smallest useful piece of navigation on a long page, and the one most often done badly. The control is absent — not faded, not disabled — until the scroll passes a threshold that means the reader has genuinely travelled, then it lifts into the corner on a hard-damped spring and stops. It leaves faster than it arrives, because a control that lingers on the way out reads as reluctant. The corner of the eye is the hardest place in an interface to put movement, so there is exactly one settle here and no bounce at all; the press itself gets a small compression instead, which is felt rather than watched. The journey home is an eased glide with a long tail, so the reader can still recognise what went by.

Long activity feedArticle footer returnSearch results pageDocumentation sidebar

Where it shows up

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

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    A corner control that only appears once a thread has been scrolled a fair way.

Related patterns