A cinematic portfolio hero where particles morph between your country flag and your name.
Built with React Three Fiber. One component. Zero design files.
🎬 Live demo: morph-hero.othmaro.dev
import { MorphHero, FLAGS } from 'morph-hero';
<MorphHero name="OTHMARO" flag={FLAGS.CR} morphBehavior="hover" />That's it. A full-viewport WebGL hero where:
- Particles assemble into your country's flag (1.8s reveal)
- They morph into your name as the canvas mounts
- Hovering morphs them back to the flag, and back to name on leave
- Mouse parallax drifts the field toward your cursor
- Clicking re-plays the whole reveal
- Constellation lines connect nearby particles for a starfield feel
Most particle heroes layer two things — a flag in the background, a name in the foreground. That's two draw calls and two unrelated particle systems.
morph-hero does it differently: one particle system, two target states, lerping between them. The same particle that's a star in Costa Rica's red stripe at t=0 is a pixel of the letter "O" at t=1.
That architectural choice unlocks:
- A single draw call (cheaper)
- Coherent, fluid motion between states (visually distinct)
- Color interpolation per-particle (flag colors blend into your text color)
- Easy to add new morph targets (city silhouettes, logos, anything sampleable)
npm install morph-heroPeer dependencies (you probably already have these in a React + R3F project):
npm install react react-dom three @react-three/fiberimport { MorphHero, FLAGS } from 'morph-hero';
export default function HomePage() {
return (
<div style={{ position: 'relative', width: '100vw', height: '100vh' }}>
<MorphHero
name="OTHMARO"
flag={FLAGS.CR}
morphBehavior="hover"
background="#000"
/>
{/* Your DOM text overlay for SEO + accessibility */}
<h1 style={{ position: 'absolute', /* ... */ }}>
Othmaro Fallas
</h1>
</div>
);
}The component renders a full-bleed <canvas> with position: absolute; inset: 0;. Its parent must be position: relative (or fixed/absolute). Layer any text you want on top — the particles are decoration; the headline should always live in the DOM for screen readers and SEO.
| Code | Country | Notes |
|---|---|---|
CR |
🇨🇷 Costa Rica | 5 horizontal stripes |
AR |
🇦🇷 Argentina | 3 horizontal bands (Sun of May omitted) |
ES |
🇪🇸 Spain | 3 horizontal stripes (coat of arms omitted) |
MX |
🇲🇽 Mexico | 3 vertical bands (eagle omitted) |
US |
🇺🇸 USA | Stripes + canton with stars |
BR |
🇧🇷 Brazil | Green field + yellow diamond + blue circle |
import { FLAGS, CR, MX, US } from 'morph-hero';
// either via the lookup map:
<MorphHero flag={FLAGS.MX} ... />
// or via direct import:
<MorphHero flag={US} ... />Don't see your country? It takes ~10 lines to add. See the next section.
Most national flags are stripes. Define one with the bands array:
import type { FlagDef } from 'morph-hero';
// 🇩🇪 Germany — 3 equal horizontal stripes
export const DE: FlagDef = {
code: 'DE',
name: 'Germany',
aspectRatio: 5 / 3,
orientation: 'horizontal', // optional, default 'horizontal'
bands: [
{ color: '#000000', height: 1 / 3 },
{ color: '#DD0000', height: 1 / 3 },
{ color: '#FFCC00', height: 1 / 3 },
],
};For vertical bands (France, Italy, Mexico), just set orientation: 'vertical'. The height field then means width fraction:
// 🇫🇷 France — 3 equal vertical bands
export const FR: FlagDef = {
code: 'FR',
name: 'France',
aspectRatio: 3 / 2,
orientation: 'vertical',
bands: [
{ color: '#002395', height: 1 / 3 },
{ color: '#FFFFFF', height: 1 / 3 },
{ color: '#ED2939', height: 1 / 3 },
],
};For flags that can't be expressed as bands (crosses, diamonds, cantons), provide a customSampler function. See src/flags/usa.ts and src/flags/brazil.ts as references.
A custom sampler receives a count + worldWidth, and must return { positions, colors, count, worldWidth, worldHeight } as Float32Arrays.
PRs to add new countries are welcome.
The component has 4 modes for what happens after the initial reveal:
<MorphHero morphBehavior="none" ... /> {/* reveal once, stay as name */}
<MorphHero morphBehavior="hover" ... /> {/* morph back to flag on hover */}
<MorphHero morphBehavior="loop" ... /> {/* auto cycle flag ↔ name every N seconds */}
<MorphHero morphBehavior="scroll" ... /> {/* tie morph to scroll past the hero */}Timing knobs:
<MorphHero
revealDuration={1.8} // seconds for the initial flag → name
morphDuration={1.2} // seconds for subsequent morphs
loopDuration={4} // seconds between loops (loop mode only)
morphEasing="easeInOutCubic" // any of: linear, easeOutCubic, easeOutBack, ...
/>interface MorphHeroProps {
// Required
name: string;
flag: FlagDef;
// Behavior
morphBehavior?: 'none' | 'hover' | 'loop' | 'scroll';
revealDuration?: number; // sec, default 1.8
morphDuration?: number; // sec, default 1.2
loopDuration?: number; // sec, default 4
disableClickReplay?: boolean; // default false
// Visuals
background?: string; // CSS color, default 'transparent'
particleSize?: number; // default 0.04
textColor?: string; // color at name state, default '#FFFFFF'
// Constellation lines
showLines?: boolean; // default true
lineMaxDistance?: number; // default 0.6 (world units)
lineColor?: string; // default '#FFFFFF'
lineOpacity?: number; // default 0.12
// Text rendering
fontFamily?: string; // default system-ui
fontWeight?: number | string; // default 700
// Layout
worldWidth?: number; // override viewport-based default
parallaxStrength?: number; // 0 to disable, default 0.15
morphEasing?: EasingName; // default 'easeInOutCubic'
}If you want to render inside your own Canvas (with other 3D content alongside), use ParticleField directly:
import { Canvas } from '@react-three/fiber';
import { ParticleField, FLAGS } from 'morph-hero';
<Canvas>
<ParticleField text="HELLO" flag={FLAGS.US} morphProgress={0.5} />
{/* your other 3D content */}
</Canvas>You drive morphProgress from your own animation logic (0 = flag, 1 = name).
The pure samplers are also exported if you want to feed your own particle system:
import { sampleTextToPoints, sampleFlagToPoints, FLAGS } from 'morph-hero';
const text = sampleTextToPoints('HELLO', { worldWidth: 8 });
const flag = sampleFlagToPoints(FLAGS.CR, { count: text.count, worldWidth: 8 });
// text.positions and flag.positions are both Float32Array, same length, same mapping.- Single draw call for the particle field, second for constellation lines.
- Tested at 60fps on M1 Mac and iPhone 12 with ~1500 particles.
- Constellation lines rebuild every 6 frames (~10fps for redraw, invisible to user). For huge counts (>2500) consider
showLines={false}on mobile. - DPR cap at 2 out of the box — Retina without melting older GPUs.
- No GLSL shaders — pure JS animation. Easier to fork, debug, and modify.
If you need to support older mobile devices, pass particleSize={0.06} and lineMaxDistance={0.4} to reduce vertex pressure.
A regular particle system has N particles each with one target position. Morph-hero gives each particle two target positions: one for the flag, one for the name letter pixel.
Per frame:
livePosition = lerp(flagPosition, namePosition, easedMorphProgress);
liveColor = lerp(flagColor, nameColor, easedMorphProgress);The catch: the text sampler produces some N particles (driven by glyph pixels at the configured step), but the flag sampler produces a different count. For the morph to be one-to-one, we resize the flag sample to match by padding with cloned entries (or trimming).
That's it. ~30 lines in ParticleField.tsx. The rest is bookkeeping.
| morph-hero | static particles | shader-based | |
|---|---|---|---|
| Customizable name | ✅ | ❌ usually hardcoded | ❌ requires GLSL knowledge |
| Customizable country | ✅ 6 built-in + your own | ❌ | ❌ |
| One coherent particle system | ✅ | ❌ usually two layers | ✅ |
| Forkable without GLSL | ✅ | ✅ | ❌ |
| Click/hover interactions | ✅ | varies | varies |
- Live demo on Vercel → morph-hero.othmaro.dev
- Animated GIF / video in this README
- More countries shipped (FR, IT, DE, JP, CO, CL, PE, VE, UK)
-
png-to-flagCLI helper — generate aFlagDeffrom any PNG - Optional bloom post-processing preset
- Optional GLSL shader path (for users who want it; current path stays JS for forkability)
PRs welcome, especially:
- New country flags (one file per country, see
src/flags/) - Better approximations of complex flags (US stars as actual stars, BR celestial banner, etc.)
- Bug reports with reproductions
Built by @othmarodev — othmaro.dev · Founder of Filaxy Labs, Inc.