Skip to content

Reveals content with a fade, a small rise and a clearing blur, once.

01Preview

This week

Sep 16–22 vs last week

  • Revenue

    $48.2k

    +12%

  • Teams

    1,284

    +38

  • Churn

    1.9%

    +0.3%

What’s new

6 releases
  1. Deploy previews for every branch

    v2.14 · Sep 22

    Each push gets its own URL, posted to the pull request.

  2. Audit log export

    v2.13 · Sep 15

    Download 90 days of workspace events as CSV or JSON.

  3. Faster cold starts

    v2.12 · Sep 8

    Functions now boot in under 120 ms at the 95th percentile.

  4. Scoped API keys

    v2.11 · Sep 1

    Limit a key to one project and read-only access.

  5. Two-person approval for production

    v2.10 · Aug 25

    Require a second reviewer before a deploy goes live.

  6. Usage alerts

    v2.9 · Aug 18

    Get an email when a project passes 80% of its quota.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { BlurIn, BlurInItem } from "@/components/ui/blur-in";

// The first time it scrolls into view.
<BlurIn as="section">
  <h2>Deploy previews for every branch</h2>
  <p>Each push gets its own URL.</p>
</BlurIn>

// On mount, one piece after another.
<BlurIn as="ul" trigger="mount" stagger>
  {stats.map((s) => (
    <BlurInItem as="li" key={s.label}>{s.label}</BlurInItem>
  ))}
</BlurIn>

04Source

"use client";
import { motion, useReducedMotion, type Transition, type Variants } from "motion/react";
import { createContext, useContext, useState } from "react";
import { ease } from "@/lib/motion";

type Tag = "div" | "section" | "article" | "header" | "ul" | "ol" | "li" | "p" | "span" | "h1" | "h2" | "h3";

// Motion's own handlers share these names with different signatures.
type Native = Omit<
  React.ComponentProps<"div">,
  "ref" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration"
>;

export type BlurInProps = Native & {
  /** The element to render. */
  as?: Tag;
  /** mount: as soon as it renders. inView: the first time it scrolls into view (at once if it already is). */
  trigger?: "mount" | "inView";
  /** With inView: play again each time it re-enters. Off by default; a reveal is for the first look. */
  replay?: boolean;
  /** With inView: how much of it must be visible, 0–1. */
  amount?: number;
  /** With inView: the scrolling element to watch instead of the window. */
  root?: React.RefObject<Element | null>;
  /** Seconds before it starts, e.g. to follow something else in. */
  delay?: number;
  /** Seconds the reveal takes. */
  duration?: number;
  /** How far it rises, in px. */
  y?: number;
  /** How much blur it clears, in px. Keep it small on large areas. */
  blur?: number;
  /**
   * Reveal the BlurInItem children one after another instead of this element as a whole.
   * true staggers them 50ms apart; a number sets the gap in seconds. Capped after the eighth.
   */
  stagger?: boolean | number;
  /** Called once the reveal has finished. */
  onRevealed?: () => void;
  ref?: React.Ref<HTMLElement>;
};

export type BlurInItemProps = Native & { as?: Tag; ref?: React.Ref<HTMLElement> };

type Shape = { y: number; blur: number; duration: number; reduce: boolean; settled: boolean };
const Ctx = createContext<Shape | null>(null);

// Hidden and shown are the same shape everywhere, so server and client render the
// same first frame whatever the motion setting. Reduced motion only changes the
// transition: the fade stays, rise and blur snap. The filter is removed when done,
// because even blur(0px) traps position: fixed descendants.
// Items leave delay out entirely: any delay of their own, even 0, overrides the stagger.
function reveal({ y, blur, duration, reduce }: Omit<Shape, "settled">, delay?: number): Variants {
  const wait = delay ? { delay: reduce ? Math.min(delay, 0.1) : delay } : {};
  const transition: Transition = reduce
    ? { duration: 0.2, ease: ease.out, y: { duration: 0 }, filter: { duration: 0 }, ...wait }
    : { duration, ease: ease.out, ...wait };
  return {
    hidden: { opacity: 0, y, filter: `blur(${blur}px)` },
    shown: { opacity: 1, y: 0, filter: "blur(0px)", transition, transitionEnd: { filter: "none" } },
  };
}

// Stagger by position, but stop adding delay after the eighth item, so the tail of a
// long list never arrives after someone has started reading.
const capped = (gap: number, start: number) => (i: number) => start + Math.min(i, 7) * gap;

export function BlurIn({
  as = "div",
  trigger = "inView",
  replay = false,
  amount = 0.15,
  root,
  delay = 0,
  duration = 0.5,
  y = 8,
  blur = 4,
  stagger = false,
  onRevealed,
  className,
  children,
  ref,
  ...rest
}: BlurInProps) {
  const reduce = !!useReducedMotion();
  // Items that mount after the reveal (a list that grows) are simply there.
  const [settled, setSettled] = useState(false);
  const Comp = motion[as] as typeof motion.div;
  const staggered = stagger !== false;
  const gap = stagger === true ? 0.05 : typeof stagger === "number" ? stagger : 0;

  const variants: Variants = staggered
    ? { hidden: {}, shown: { transition: { delayChildren: reduce ? delay : capped(gap, delay) } } }
    : reveal({ y, blur, duration, reduce }, delay);

  const play =
    trigger === "mount"
      ? { animate: "shown" }
      : { whileInView: "shown", viewport: { once: !replay, amount, root } };

  return (
    <Ctx.Provider value={{ y, blur, duration, reduce, settled }}>
      <Comp
        ref={ref as React.Ref<HTMLDivElement>}
        data-blur-in=""
        data-trigger={trigger}
        initial="hidden"
        {...play}
        variants={variants}
        onAnimationComplete={(name) => {
          if (name !== "shown") return;
          setSettled(true);
          onRevealed?.();
        }}
        className={className}
        {...rest}
      >
        {children}
      </Comp>
      {/* Without JavaScript nothing would ever reveal it, so it starts visible. */}
      <noscript>
        <style>{`[data-blur-in],[data-blur-in-item]{opacity:1!important;transform:none!important;filter:none!important}`}</style>
      </noscript>
    </Ctx.Provider>
  );
}

/** One piece of a staggered BlurIn. Outside one it renders as a plain element. */
export function BlurInItem({ as = "div", className, children, ref, ...rest }: BlurInItemProps) {
  const shape = useContext(Ctx);
  const Comp = motion[as] as typeof motion.div;
  if (!shape) {
    const Plain = as as "div";
    return (
      <Plain ref={ref as React.Ref<HTMLDivElement>} className={className} {...(rest as React.ComponentProps<"div">)}>
        {children}
      </Plain>
    );
  }
  return (
    <Comp
      ref={ref as React.Ref<HTMLDivElement>}
      data-blur-in-item=""
      // No initial label of its own: that would detach it from the parent's variants.
      // It inherits "hidden" and plays "shown" when the parent does, on the stagger.
      initial={shape.settled ? false : undefined}
      variants={reveal(shape)}
      className={className}
      {...rest}
    >
      {children}
    </Comp>
  );
}

05Props

BlurIn

PropTypeDefaultDescription
as"div" | "section" | "article" | "header" | "ul" | "ol" | "li" | "p" | "span" | "h1" | "h2" | "h3""div"The element to render, so a list stays a list.
trigger"mount" | "inView""inView"Reveal as soon as it renders, or the first time it scrolls into view (at once if it already is).
replaybooleanfalseWith inView, hide again on leaving and reveal on every return.
amountnumber0.15With inView, the share of it that must be visible, 0–1.
rootRefObject<Element | null>With inView, the scrolling element to watch instead of the window.
delaynumber0Seconds before it starts, e.g. to follow a heading in.
durationnumber0.5Seconds the reveal takes.
ynumber8How far it rises, in px.
blurnumber4How much blur clears, in px. Keep it small on large areas.
staggerboolean | numberfalseReveal the BlurInItem children in turn instead of the whole element: true is 50ms apart, a number sets the gap in seconds.
onRevealed() => voidCalled when the reveal has finished.

BlurInItem

PropTypeDefaultDescription
assame as BlurIn"div"One piece of a staggered BlurIn. It takes its rise, blur and timing from the parent, and renders as a plain element outside one.

06Notes

Behavior

  • Plays once by default: scrolling back up never hides what someone has already read. Content already on screen when it mounts reveals at once.
  • The stagger stops adding delay after the eighth item, so a long list is fully in within about half a second. Items added after the reveal are simply there, with no entrance of their own.
  • The filter is removed when the reveal ends (even blur(0px) would trap position: fixed children), and the rise ends at transform: none.
  • Without JavaScript a noscript rule shows everything, so the reveal never hides content for good.

Motion

  • Opacity 0 → 1, rise 8px → 0 and blur 4px → 0 together, 500ms on the expo ease-out: the first 100ms carry most of it, so it reads as arriving, not fading.
  • Stagger 50ms between items, capped at seven gaps. Blur stays at 4px by default because blur cost grows with area; 2–3px suits whole panels.
  • Reduced motion: a 200ms fade, the rise and blur snap, no stagger. The first frame is identical either way, so server and client render the same markup.

Accessibility

  • Adds no roles and changes no reading order: content is in the DOM from the first render and read normally throughout.
  • Use as to keep semantics (ul with li items, section with a heading). Don't reveal a control the user is about to need; reveals are for content.