Brand Reveal
A logo mark that zooms in, then a wordmark that unfurls beside it under a travelling light.
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.
bun add motionCopy the component into 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.
.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.
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.
Framer's stacked-triangle mark and Inter wordmark.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | string | "Introducing" | The wordmark that reveals beside the mark. |
logo | ReactNode | built-in mark | The mark that pops in first. |
size | number | 44 | Edge length of the mark in px. |
accent | string | "#3b9eff" | Colour of the light riding the reveal edge. |
wordmarkClassName | string | - | Classes that set the wordmark's font, size, and weight. |
autoPlay | boolean | true | Play automatically on mount. |
className | string | - | Forwarded to the root. |