Frontend Engineer · Seoul → Singapore · Available Q3

Builds the 240ms a click takes to feel right. Frontend at Mitra. Demos below are live, single-file, run on this page.

05+ live demos 0 frameworks ~720 lines 60 fps target
/01   MAGNETIC BUTTON
cursor → element

Buttons should know where you're pointing.

Cursor radius pulls the button along its axis. Spring-eased return when you leave the field.

// translate by (cursor − center) * 0.35
const r = btn.getBoundingClientRect();
const dx = (e.clientX − r.left − r.width/2) * 0.35;
const dy = (e.clientY − r.top  − r.height/2) * 0.35;
btn.style.transform =
  `translate(${dx}px, ${dy}px)`;

transition: 600ms cubic-bezier(0.22, 0.8, 0.24, 1) · pull-radius: 180px · strength: 0.35

/02   PARALLAX TILT
3D from cursor

Surface depth without WebGL.

rotateX/rotateY from cursor offset. Children get translateZ for parallax. Spotlight follows the pointer.

CASE · 04
A trading dashboard that doesn't lie about latency.
2025 · Singapore
// max ±12deg, clamped & damped
const px = (e.clientX − r.left) / r.width − 0.5;
const py = (e.clientY − r.top)  / r.height − 0.5;
card.style.transform =
  `rotateY(${px * 12}deg) rotateX(${−py * 12}deg)`;
card.style.setProperty('--mx',
  `${px*100+50}%`);

perspective: 900px · max-tilt: 12deg · transition: 240ms cubic-bezier(0.22, 0.8, 0.24, 1)

/03   EASING LAB
scroll-driven

The same 0 → 1247 feels different per curve.

Three counters animate together when this section enters viewport. Side-by-side proves easing isn't decoration — it's tone of voice.

Linear
0
t
Ease-out
0
1−(1−t)³
Out-back
0
overshoot 1.7
// IntersectionObserver fires once
const animate = (el, ease) => {
  const t0 = performance.now();
  const step = (t) => {
    const p = Math.min(1, (t−t0)/1400);
    el.textContent = Math.floor(
      ease(p) * 1247
    ).toLocaleString();
    if (p < 1) requestAnimationFrame(step);
  };
  requestAnimationFrame(step);
};

duration: 1400ms · target: 1247 · 60fps via requestAnimationFrame

/04   SHARED ELEMENT
FLIP technique

Click any card. It expands in place. Click again — it returns home.

FLIP (First, Last, Invert, Play). Measure source rect, position destination, invert with transform, play forward. No layout thrash.

01Trading desk redesign
02Realtime collab cursors
03Component library v3
04Onboarding flow audit
05Charting engine refactor
06Design tokens pipeline
// FLIP — first, last, invert, play
const first = src.getBoundingClientRect();
moveToDestination(src);
const last  = src.getBoundingClientRect();
const dx = first.left − last.left;
const dy = first.top  − last.top;
src.animate(
  [{transform:`translate(${dx}px,${dy}px)`},
   {transform:'none'}],
  {duration:420, easing:'var(--ease)'}
);

duration: 420ms cubic-bezier(0.22, 0.8, 0.24, 1) · backdrop blur 12px · ESC to close

Project

Detail view animates in from the source card.

/05   MORPHING HEADER
scroll-position aware

Header collapses as you scroll. Logo shrinks. Background fades in.

Sticky element that responds to its own scroll container's offset. Threshold-based, hardware-accelerated.

On the cost of decoration

Most micro-interactions are paid for in milliseconds the user can feel. The right ones are paid for in trust.

I build for the second category. A magnetic button isn't a flourish — it's a signal that the system is paying attention to where you are.

Scroll further. The header shrinks. The logo trims. The background blurs. None of it is decorative — it gives back the screen real estate the page needs.

Every animation in this lab respects prefers-reduced-motion. The settings panel forces it manually for users who want to try both modes.

Bottom of the scroller. Header is fully compact now. Threshold was 40px.

// state changes once per threshold cross
scroller.addEventListener('scroll', () => {
  const compact = scroller.scrollTop > 40;
  header.classList.toggle(
    'compact', compact
  );
});

threshold: 40px · transition: 240ms cubic-bezier(0.22, 0.8, 0.24, 1) · backdrop-filter blur 8px

/06   DISCIPLINES
what I optimise for

Performance is a craft, not a bug fix.

Render
60fps non-negotiable
Compositor-only animations. transform + opacity. No layout/paint inside the animation hot path.
Bundle
Ship the smallest thing
First paint < 1.2s on 4G. Audit every dependency. No 80KB date library for one format string.
A11y
Motion is opt-out
prefers-reduced-motion + manual toggle. Focus rings preserved. Labels for every interactive element.
DX
Tokens, not magic numbers
CSS custom properties from a single source. Change one variable, watch the entire surface respond.
/07   FIELD NOTES
writing in public
2026.04.14
Why I stopped using Framer Motion for everything. A 90KB animation library for a 12-line CSS transition is a tax. Reach for it when state-driven layout transitions need orchestration — not for hover states.
2026.03.22
The 240ms rule. Anything between 200–280ms feels intentional but not slow. Below 150ms reads as glitch; above 320ms reads as wait.
2026.02.08
Cursors are UI. A custom cursor that grows on interactive elements is the cheapest signal you can give. mix-blend-mode: difference works on any background.
2026.01.17
Easing has a voice. ease-out feels confident. ease-in-out feels considered. cubic-bezier(0.22, 0.8, 0.24, 1) feels like Mitra.
/08   CONTACT
open · Q3 2026
SETTINGS● LIVE
Easing curve
cubic-bezier(0.22, 0.8, 0.24, 1)
Reduced motion