Skip to content
UI Kit
Accent color

Blur in

A whole line goes from blurred to sharp. It stays real text, only opacity, filter and transform move.

One of 5 components in Text effects

3 variants

Variants

Soft

A light haze that rises a little, the lead follows a beat later.

Strong

Deep blur and a slight zoom for a short brand line.

Sequence on scroll

Three lines sharpen one after another while you scroll. The last one carries the accent.

Details

What Blur in brings along, read from the registry and the source.

Category
Text effects
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/blur-in.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The BlurInProps interface from components/ui/blur-in.tsx, with the comments from the code.

  • childrenrequired
    React.ReactNode

    The line. It stays real text, so screen readers and search read it as it is.

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render, keeps the heading level. Default p.

  • amountoptional
    'soft' | 'strong'

    soft: a light haze (default). strong: deep blur and a small zoom.

  • riseoptional
    boolean

    Rise a little while it sharpens

  • triggeroptional
    BlurInTrigger

    loadviewin-viewhoverscroll

    load: right away. view (or in-view): when it scrolls in (default). hover: again on every hover and focus. scroll: sharpens with the scroll position, from the bottom of the window to about its middle.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that sharpens it, 0 to 1. Default [0.06, 0.46].

  • durationoptional
    number

    Milliseconds the line takes. Default 1000.

  • delayoptional
    number

    Milliseconds before it starts, to follow another line. Default 0.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 149 lines
components/ui/blur-in.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

/** in-view is the same as view */
export type BlurInTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';

export interface BlurInProps {
  /** The line. It stays real text, so screen readers and search read it as it is. */
  children: React.ReactNode;
  /** Element to render, keeps the heading level. Default p. */
  as?: Tag;
  /** soft: a light haze (default). strong: deep blur and a small zoom. */
  amount?: 'soft' | 'strong';
  /** Rise a little while it sharpens */
  rise?: boolean;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * hover: again on every hover and focus. scroll: sharpens with the scroll
   * position, from the bottom of the window to about its middle.
   */
  trigger?: BlurInTrigger;
  /** With trigger scroll: the part of the way through the window that sharpens it, 0 to 1. Default [0.06, 0.46]. */
  scrollRange?: [number, number];
  /** Milliseconds the line takes. Default 1000. */
  duration?: number;
  /** Milliseconds before it starts, to follow another line. Default 0. */
  delay?: number;
  /** Classes for the element, e.g. text-display, text-h1, text-h2, text-lead */
  className?: string;
}

/*
 * Scroll: --kitfx-p comes from the scroll link and is eased once (p * (2 - p)),
 * so the line sharpens quickly at first and settles softly. Without the
 * variable the line stands sharp.
 */
const CSS = `
.kitfx-bi-in{display:block}
span.kitfx-bi>.kitfx-bi-in{display:inline-block}
.kitfx-bi[data-mode=play]>.kitfx-bi-in{animation:var(--kitfx-bi-name) var(--kitfx-bi-dur) cubic-bezier(.2,.7,.2,1) var(--kitfx-bi-delay) both}
.kitfx-bi[data-mode=wait]>.kitfx-bi-in{opacity:0}
.kitfx-bi[data-amount=soft]{--kitfx-bi-name:kitfx-bi-soft;--kitfx-bi-blur:10px;--kitfx-bi-s:0}
.kitfx-bi[data-amount=strong]{--kitfx-bi-name:kitfx-bi-strong;--kitfx-bi-blur:22px;--kitfx-bi-s:.05}
.kitfx-bi[data-amount=soft][data-rise]{--kitfx-bi-name:kitfx-bi-soft-rise}
.kitfx-bi[data-amount=strong][data-rise]{--kitfx-bi-name:kitfx-bi-strong-rise}
.kitfx-bi[data-rise]{--kitfx-bi-y:.3em}
.kitfx-bi[data-mode=scroll]>.kitfx-bi-in{--e:calc(var(--kitfx-p,1) * (2 - var(--kitfx-p,1)));opacity:var(--e);filter:blur(calc((1 - var(--e)) * var(--kitfx-bi-blur)));transform:translateY(calc((1 - var(--e)) * var(--kitfx-bi-y,0em))) scale(calc(1 + (1 - var(--e)) * var(--kitfx-bi-s)));transition:opacity .2s linear,filter .2s linear,transform .2s linear}
@keyframes kitfx-bi-soft{from{opacity:0;filter:blur(10px)}}
@keyframes kitfx-bi-strong{from{opacity:0;filter:blur(22px);transform:scale(1.05)}}
@keyframes kitfx-bi-soft-rise{from{opacity:0;filter:blur(10px);transform:translateY(.3em)}}
@keyframes kitfx-bi-strong-rise{from{opacity:0;filter:blur(22px);transform:translateY(.3em) scale(1.05)}}
@keyframes kitfx-bi-fade{from{opacity:0}}
@media (prefers-reduced-motion:reduce){.kitfx-bi[data-mode]>.kitfx-bi-in{animation:kitfx-bi-fade .5s ease both;opacity:1;filter:none;transform:none}}`;

/** Plays a CSS entrance from the first paint, holds lines further down until they scroll in. */
function usePlay<T extends HTMLElement>(trigger: BlurInTrigger) {
  const ref = useRef<T>(null);
  const [run, setRun] = useState(0);
  const [mode, setMode] = useState<'play' | 'wait' | 'idle' | 'scroll'>(trigger === 'hover' ? 'idle' : trigger === 'scroll' ? 'scroll' : 'play');
  useEffect(() => {
    const el = ref.current;
    if (!el || (trigger !== 'view' && trigger !== 'in-view') || prefersReducedMotion()) return;
    if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
    setMode('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setMode('play');
        setRun((r) => r + 1);
        io.disconnect();
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [trigger]);
  const replay = useCallback(() => {
    if (prefersReducedMotion()) return;
    setMode('play');
    setRun((r) => r + 1);
  }, []);
  return { ref, run, mode, replay };
}

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number]) {
  const [start, end] = range;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}

/**
 * The whole line goes from blurred to sharp. Only opacity, filter and
 * transform move, so nothing around it shifts. Without JavaScript and with
 * reduced motion the line stands sharp (a short fade at most).
 *   <BlurIn as="h1" className="text-display">Café Mira</BlurIn>
 */
export function BlurIn({ children, as: Tag = 'p', amount = 'soft', rise, trigger = 'view', scrollRange = [0.06, 0.46], duration = 1000, delay = 0, className }: BlurInProps) {
  const { ref, run, mode, replay } = usePlay<HTMLElement>(trigger);
  useScrollLink(ref, trigger === 'scroll', scrollRange);
  const vars = { '--kitfx-bi-dur': `${duration}ms`, '--kitfx-bi-delay': `${delay}ms` } as React.CSSProperties;
  const hover = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : {};
  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <Tag ref={ref as never} className={cn('kitfx-bi', className)} data-mode={mode} data-amount={amount} data-rise={rise ? '' : undefined} style={vars} {...hover}>
        <span key={run} className="kitfx-bi-in">
          {children}
        </span>
      </Tag>
    </>
  );
}

Frequent questions

How do I install Blur in?

With aiw add @ai-website/blur-in. The command puts components/ui/blur-in.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Blur in have?

3 variants, Soft, Strong and Sequence on scroll.

Can an AI assistant build with Blur in?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Blur in with aiw_components and install it with aiw_add @ai-website/blur-in.

Often searched

Jump to

Copied to the clipboard