Skip to content

othmarodev/morph-hero

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

morph-hero

A cinematic portfolio hero where particles morph between your country flag and your name.
Built with React Three Fiber. One component. Zero design files.

npm bundle size react 18+ MIT

🎬 Live demo: morph-hero.othmaro.dev


What you'll get

import { MorphHero, FLAGS } from 'morph-hero';

<MorphHero name="OTHMARO" flag={FLAGS.CR} morphBehavior="hover" />

That's it. A full-viewport WebGL hero where:

  1. Particles assemble into your country's flag (1.8s reveal)
  2. They morph into your name as the canvas mounts
  3. Hovering morphs them back to the flag, and back to name on leave
  4. Mouse parallax drifts the field toward your cursor
  5. Clicking re-plays the whole reveal
  6. Constellation lines connect nearby particles for a starfield feel

Why a "morph" instead of layered scenes?

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)

Install

npm install morph-hero

Peer dependencies (you probably already have these in a React + R3F project):

npm install react react-dom three @react-three/fiber

Quick start

import { 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.


Available flags

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.


Adding your own flag

Pattern A — simple horizontal/vertical bands

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 },
  ],
};

Pattern B — custom sampler for complex flags

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.


Morph behaviors

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, ...
/>

All props

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'
}

Lower-level building blocks

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.

Performance notes

  • 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.


How the morph works (the trick)

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.


How this differs from other portfolio heroes

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

Roadmap

  • 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-flag CLI helper — generate a FlagDef from any PNG
  • Optional bloom post-processing preset
  • Optional GLSL shader path (for users who want it; current path stays JS for forkability)

Contributing

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

License

MIT © Othmaro Fallas Rojas

Built by @othmarodevothmaro.dev · Founder of Filaxy Labs, Inc.

About

Cinematic portfolio hero — particles morph between your country flag and your name. React Three Fiber, zero shaders, one drop-in component.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors