liten

Brand Reveal

A logo mark that zooms in, then a wordmark that unfurls beside it under a travelling light.

Vercel

The logo mark zooms up from almost nothing on a springy pop, then the wordmark unfurls to its right: a bright specular light rakes across the type and each glyph ignites, emerging soft and pale at the light's edge before cooling to solid ink as the light moves on. The lockup stays optically centred, so the mark drifts left as the word claims its width. Under reduced motion the finished lockup renders at rest. Built with Motion.

Installation

Complete the shared Setup first, then add Motion.

Terminal
bun add motion

Copy the component into components/ui/brand-reveal.tsx.

components/ui/brand-reveal.tsx
'use client';import * as React from 'react';import {  motion,  animate,  useMotionValue,  useTransform,  useReducedMotion,} from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const POP = { type: 'spring' as const, duration: 0.78, bounce: 0.34 };const SWEEP_DELAY = 0.42;const SWEEP_DURATION = 0.95;const TAIL_DURATION = 0.55;function DefaultMark() {  return (    <svg viewBox="0 0 40 40" fill="currentColor" aria-hidden className="size-full">      <path d="M20 3.5c4.1 0 7.6 3 8.3 6.9 3.9.7 6.9 4.2 6.9 8.3s-3 7.6-6.9 8.3c-.7 3.9-4.2 6.9-8.3 6.9s-7.6-3-8.3-6.9c-3.9-.7-6.9-4.2-6.9-8.3s3-7.6 6.9-8.3C12.4 6.5 15.9 3.5 20 3.5Z" />    </svg>  );}export type BrandRevealProps = {  children?: string;  logo?: React.ReactNode;  size?: number;  accent?: string;  wordmarkClassName?: string;  autoPlay?: boolean;  className?: string;};export function BrandReveal({  children = 'Introducing',  logo,  size = 44,  accent = '#3b9eff',  wordmarkClassName,  autoPlay = true,  className,}: BrandRevealProps) {  const reduce = useReducedMotion();  const wordRef = React.useRef<HTMLDivElement>(null);  const [width, setWidth] = React.useState(0);  const [play, setPlay] = React.useState(false);  const [done, setDone] = React.useState(false);  const p = useMotionValue(0);  const tail = useMotionValue(0);  React.useLayoutEffect(() => {    const el = wordRef.current;    if (!el) return;    const measure = () => setWidth(Math.ceil(el.getBoundingClientRect().width) + 1);    measure();    const ro = new ResizeObserver(measure);    ro.observe(el);    return () => ro.disconnect();  }, [children, size, wordmarkClassName]);  React.useEffect(() => {    if (autoPlay && !reduce) setPlay(true);  }, [autoPlay, reduce]);  React.useEffect(() => {    if (!play || reduce || !width) return;    const sweep = animate(p, 1, {      duration: SWEEP_DURATION,      delay: SWEEP_DELAY,      ease: EASE_OUT,      onComplete: () => {        cool = animate(tail, 1, {          duration: TAIL_DURATION,          ease: EASE_OUT,          onComplete: () => setDone(true),        });      },    });    let cool: ReturnType<typeof animate> | undefined;    return () => {      sweep.stop();      cool?.stop();    };  }, [play, reduce, width, p, tail]);  const clipWidth = useTransform(p, (v) => v * width);  const fillImage = useTransform([p, tail], ([v, t]: number[]) => {    const e = v * 103;    const a1 = Math.max(0, e - 12);    const a2 = Math.max(0, e - 3);    const edge = Math.max(0, e);    const acc = `color-mix(in srgb, var(--brand-accent) ${Math.round((1 - t) * 100)}%, var(--br-ink))`;    return `linear-gradient(90deg, var(--br-ink) 0%, var(--br-ink) ${a1}%, ${acc} ${a2}%, ${acc} ${edge}%, transparent ${edge}%, transparent 100%)`;  });  const glowImage = useTransform(p, (v) => {    const e = v * 103;    const g0 = Math.max(0, e - 15);    const g1 = Math.max(0, e - 5);    const g2 = Math.max(0, e + 3);    return `linear-gradient(90deg, transparent ${g0}%, var(--brand-accent) ${g1}%, transparent ${g2}%, transparent 100%)`;  });  const glowOpacity = useTransform([p, tail], ([v, t]: number[]) => {    const fadeIn = v <= 0 ? 0 : v < 0.06 ? v / 0.06 : 1;    return 0.7 * fadeIn * (1 - t);  });  const wordBase = cn(    'brand-reveal-word w-max whitespace-pre pr-[0.14em] font-semibold leading-none tracking-[-0.02em]',    wordmarkClassName,  );  const restFontSize = wordmarkClassName ? undefined : size * 0.72;  return (    <div      style={{ '--brand-accent': accent } as React.CSSProperties}      className={cn('flex items-center justify-center gap-3.5', className)}    >      <motion.div        initial={reduce ? false : { scale: 0.14, opacity: 0 }}        animate={reduce ? undefined : { scale: 1, opacity: 1 }}        transition={reduce ? { duration: 0 } : POP}        style={{ width: size, height: size }}        className="shrink-0 text-neutral-900 dark:text-white"      >        {logo ?? <DefaultMark />}      </motion.div>      {reduce ? (        <div          ref={wordRef}          className={cn(wordBase, 'text-neutral-900 dark:text-white')}          style={{ fontSize: restFontSize }}        >          {children}        </div>      ) : (        <div className="relative">          <motion.div style={{ width: clipWidth }} className="overflow-hidden">            <motion.div              ref={wordRef}              aria-label={children}              className={cn(wordBase, 'brand-reveal-clip')}              style={                done                  ? {                      fontSize: restFontSize,                      backgroundImage:                        'linear-gradient(90deg, var(--br-ink) 0%, var(--br-ink) 100%)',                    }                  : { fontSize: restFontSize, backgroundImage: fillImage }              }            >              {children}            </motion.div>          </motion.div>          {!done && (            <motion.div              aria-hidden              style={{ opacity: glowOpacity }}              className="pointer-events-none absolute left-0 top-0 mix-blend-plus-lighter"            >              <motion.div                className={cn(wordBase, 'brand-reveal-clip')}                style={{                  fontSize: restFontSize,                  backgroundImage: glowImage,                  filter: `blur(${Math.round(size * 0.1)}px)`,                }}              >                {children}              </motion.div>            </motion.div>          )}        </div>      )}    </div>  );}

Add the reveal styles to your global.css.

global.css
.brand-reveal-word {
  --br-ink: #171717;
}
.dark .brand-reveal-word {
  --br-ink: #ffffff;
}
.brand-reveal-clip {
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
}

Usage

Pass your mark as logo, the wordmark text as the child, and its brand font through wordmarkClassName. The mark is drawn in currentColor so it inverts per theme. The reveal plays once on mount; remount with a changing key to replay it.

Example.tsx
import { BrandReveal } from '@/components/ui/brand-reveal';
import { Geist } from 'next/font/google';

const geist = Geist({ subsets: ['latin'], weight: ['600'] });

export default function Example() {
  return (
    <BrandReveal
      logo={<VercelMark />}
      size={44}
      wordmarkClassName={`${geist.className} text-[40px] font-semibold tracking-[-0.03em]`}
    >
      Vercel
    </BrandReveal>
  );
}

Examples

Linear's geometric mark and wordmark in Inter.

Linear

Framer's stacked-triangle mark and Inter wordmark.

Framer

Props

PropTypeDefaultDescription
childrenstring"Introducing"The wordmark that reveals beside the mark.
logoReactNodebuilt-in markThe mark that pops in first.
sizenumber44Edge length of the mark in px.
accentstring"#3b9eff"Colour of the light riding the reveal edge.
wordmarkClassNamestring-Classes that set the wordmark's font, size, and weight.
autoPlaybooleantruePlay automatically on mount.
classNamestring-Forwarded to the root.
On this page0%