All patterns

Read More Expand

A truncated post opens to full height while the fade that hid the cut-off line lifts with it.

socialcalmminimalinteraction · finite · intermediate · ~0.3s
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.

228 lines · react + motion only
import { useEffect, useId, useState } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Read More Expand
 *
 * A truncated post opens to its full height while the fade that hid the
 * cut-off line lifts with it. The fade is a mask, not a gradient panel,
 * so it works over any page color without knowing what that color is.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `body`, `collapsedHeight`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ReadMoreExpandProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Start expanded. */
  defaultOpen?: boolean;
  /** Called with the new state on every toggle. */
  onOpenChange?: (open: boolean) => void;
  author?: string;
  handle?: string;
  timestamp?: string;
  body?: string;
  /** Height of the truncated post, in px. */
  collapsedHeight?: number;
};

type VariantConfig = {
  /** Seconds for the height to open. */
  height: number;
  /** Seconds for the fade mask to lift. */
  mask: number;
  /** Where the fade starts while collapsed, as a percentage of the box. */
  maskStart: number;
};

// Height is one of the few properties worth animating outright — this
// genuinely is a size change — so it runs as a short eased tween. A
// spring would overshoot, and a container that springs past its own text
// flashes a clipped line. Nothing here scales: the paragraph is at its
// final size from the first frame and is only ever uncovered.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a beat. For a comment list where most posts get opened.
  subtle: { height: 0.22, mask: 0.2, maskStart: 62 },
  // Enough time to see the text was there all along. All-purpose.
  default: { height: 0.3, mask: 0.28, maskStart: 55 },
  // A slower unfold, for a long-form post in a reading surface.
  playful: { height: 0.38, mask: 0.34, maskStart: 48 },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields surfaces and borders correctly toned on a light
 *  page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const BODY =
  "We rewrote the onboarding flow three times before we admitted the problem was not the flow. People were not confused about which button to press; they were unsure whether the thing they had just imported was safe to share with their team. So we stopped adding steps and started answering that one question on the first screen — who can see this, and what happens if you are wrong. Completion went up nine points and support tickets about permissions went down by half, which is the only pair of numbers that ever mattered here.";

export default function ReadMoreExpand({
  variant = "default",
  defaultOpen = false,
  onOpenChange,
  author = "Lena Fischer",
  handle = "@lenaf",
  timestamp = "3h",
  body = BODY,
  collapsedHeight = 84,
}: ReadMoreExpandProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [open, setOpen] = useState(defaultOpen);
  const bodyId = `${useId()}-body`;

  // The fade is a mask over the text itself, so it needs no knowledge of
  // the page background — no opaque gradient panel to keep in sync with a
  // theme. Lifting it is one value moving from "fades early" to "no fade".
  const reveal = useMotionValue(defaultOpen ? 1 : 0);
  const maskStop = useTransform(reveal, [0, 1], [cfg.maskStart, 100]);
  const maskImage = useMotionTemplate`linear-gradient(to bottom, rgb(0 0 0) ${maskStop}%, rgb(0 0 0 / 0) 100%)`;

  useEffect(() => {
    const controls = animate(reveal, open ? 1 : 0, {
      duration: reduceMotion ? 0 : cfg.mask,
      ease: "easeOut",
    });
    return () => controls.stop();
  }, [open, reduceMotion, cfg.mask, reveal]);

  const toggle = () => {
    const next = !open;
    setOpen(next);
    onOpenChange?.(next);
  };

  const heightTween = reduceMotion
    ? { duration: 0 }
    : { duration: cfg.height, ease: [0.32, 0.72, 0, 1] as const };

  return (
    <article
      style={{
        width: 320,
        padding: "14px 15px 11px",
        borderRadius: 16,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
        <span
          aria-hidden
          style={{
            width: 28,
            height: 28,
            borderRadius: "50%",
            display: "grid",
            placeItems: "center",
            fontSize: 10.5,
            fontWeight: 600,
            color: "#fff",
            background: "linear-gradient(140deg,#3FA98C,#2C7F8F)",
          }}
        >
          LF
        </span>
        <span style={{ fontSize: 12.5 }}>
          <strong style={{ fontWeight: 650 }}>{author}</strong>
          <span style={{ opacity: 0.42 }}>
            {" "}
            {handle} · {timestamp}
          </span>
        </span>
      </div>

      <motion.div
        id={bodyId}
        initial={false}
        animate={{ height: open ? "auto" : collapsedHeight }}
        transition={{ height: heightTween }}
        style={{
          overflow: "hidden",
          marginTop: 9,
          maskImage,
          WebkitMaskImage: maskImage,
        }}
      >
        <p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, opacity: 0.82 }}>
          {body}
        </p>
      </motion.div>

      <button
        type="button"
        onClick={toggle}
        aria-expanded={open}
        aria-controls={bodyId}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 6,
          marginTop: 8,
          padding: "5px 9px 5px 8px",
          borderRadius: 9,
          border: 0,
          background: "transparent",
          color: "inherit",
          fontFamily: "inherit",
          fontSize: 12,
          fontWeight: 600,
          cursor: "pointer",
        }}
      >
        <motion.span
          aria-hidden
          initial={false}
          animate={{ rotate: open ? 180 : 0 }}
          transition={{ duration: reduceMotion ? 0 : cfg.height, ease: [0.32, 0.72, 0, 1] }}
          style={{ display: "grid", placeItems: "center", opacity: 0.6 }}
        >
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none">
            <path
              d="M6 9.5 12 15.5 18 9.5"
              stroke="currentColor"
              strokeWidth="2.2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </motion.span>
        {/* Fixed-width slot: the wording changes, nothing beside it moves. */}
        <span style={{ position: "relative", width: 66, height: 15 }}>
          <AnimatePresence initial={false}>
            <motion.span
              key={open ? "less" : "more"}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{
                position: "absolute",
                inset: 0,
                lineHeight: "15px",
                textAlign: "left",
                opacity: 0.75,
              }}
            >
              {open ? "Show less" : "Read more"}
            </motion.span>
          </AnimatePresence>
        </span>
      </button>
    </article>
  );
}

About this pattern

Truncation is a promise that there is more, and expanding should look like uncovering rather than like fetching. The paragraph is laid out at its final size from the first frame and simply revealed as the height opens on a short eased tween — never a spring, since a container that overshoots its own text flashes a clipped line at the bottom. The fade over the cut-off line is a mask on the text itself rather than an opaque gradient panel, which means it needs no knowledge of the page color and cannot fall out of step with a theme; lifting it is one value moving from "fades early" to "no fade at all", animated alongside the height. Collapsing runs the same two values backwards, and the button's wording swaps inside a fixed-width slot so nothing beside it shifts.

Long post truncationComment see moreExpandable descriptionReview text overflow

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 clipped passage grows to its full height and the fade at its foot lifts.

Related patterns