Catalog

Edit Props (live)

TEXT
TEXT
TEXT
IMAGE

Source

248 lines
// Port of the hyperframes `instagram-follow` block (Apache-2.0).
// Original: github.com/heygen-com/hyperframes @ registry/blocks/instagram-follow
// Rewritten in Remotion: GSAP timeline → interpolate() + spring().
//
// Timeline (at 30fps):
//   0.1s  — card slides up from bottom (y: 300 → 0, opacity 0 → 1)
//   1.0s  — follow button press-in (scale 0.92)
//   1.15s — elastic spring back + IG blue → dark gray + Follow fades out
//   1.18s — Following + chevron fades in
//   3.8s  — card slides back down to bottom
//   4.5s  — done

import React from 'react';
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';

interface Props {
  displayName: string;
  handle: string;
  followerCount: string;
  avatarUrl: string;
}

export default function InstagramFollow({
  displayName,
  handle,
  followerCount,
  avatarUrl,
}: Props) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const s = (seconds: number) => seconds * fps;

  const slideIn = interpolate(frame, [s(0.1), s(0.6)], [300, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const fadeIn = interpolate(frame, [s(0.1), s(0.6)], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const slideOut = interpolate(frame, [s(3.8), s(4.05)], [0, 300], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const fadeOut = interpolate(frame, [s(3.8), s(4.05)], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // Button press-in then elastic release
  const pressIn = interpolate(frame, [s(1.0), s(1.15)], [1, 0.92], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const bounce =
    frame >= s(1.15)
      ? spring({ frame: frame - s(1.15), fps, config: { damping: 8, stiffness: 120 } })
      : 0;
  const btnScale = frame < s(1.15) ? pressIn : 0.92 + bounce * 0.08;

  // Blue → dark gray + Follow/Following crossfade at 1.15s
  const colorT = interpolate(frame, [s(1.15), s(1.27)], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const btnBg = lerpColor('#0095f6', '#2f2f2f', colorT);
  const followOpacity = interpolate(frame, [s(1.15), s(1.23)], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const followingOpacity = interpolate(frame, [s(1.18), s(1.26)], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const translateY = slideIn + slideOut;
  const opacity = Math.min(fadeIn, fadeOut);

  return (
    <AbsoluteFill
      style={{
        background: 'linear-gradient(180deg, #2a1a3e 0%, #0d1021 100%)',
        fontFamily: 'system-ui, sans-serif',
      }}
    >
      <div
        style={{
          position: 'absolute',
          bottom: 240,
          left: '50%',
          transform: `translate(-50%, ${translateY}px)`,
          opacity,
          display: 'flex',
          alignItems: 'center',
          gap: 30,
          background: '#1a1a1a',
          borderRadius: 75,
          padding: '25px 40px 25px 25px',
          boxShadow: '0 8px 40px rgba(0, 0, 0, 0.4)',
        }}
      >
        <Avatar url={avatarUrl} />
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 2,
            marginRight: 20,
          }}
        >
          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <div
              style={{
                fontSize: 42,
                fontWeight: 700,
                color: '#fff',
                lineHeight: 1.3,
              }}
            >
              {displayName}
            </div>
            <VerifiedBadge />
          </div>
          <div style={{ fontSize: 28, color: '#a0a0a0', lineHeight: 1.3 }}>
            {handle}
          </div>
          <div style={{ fontSize: 25, color: '#737373', lineHeight: 1.3 }}>
            {followerCount}
          </div>
        </div>
        <div
          style={{
            position: 'relative',
            width: 250,
            height: 80,
            borderRadius: 40,
            background: btnBg,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            overflow: 'hidden',
            transform: `scale(${btnScale})`,
          }}
        >
          <span
            style={{
              position: 'absolute',
              fontSize: 30,
              fontWeight: 700,
              color: '#fff',
              opacity: followOpacity,
            }}
          >
            Follow
          </span>
          <span
            style={{
              position: 'absolute',
              fontSize: 30,
              fontWeight: 700,
              color: '#fff',
              opacity: followingOpacity,
              display: 'flex',
              alignItems: 'center',
              gap: 8,
            }}
          >
            Following
            <svg viewBox='0 0 24 24' width='18' height='18' fill='none'>
              <polyline
                points='6 9 12 15 18 9'
                stroke='#fff'
                strokeWidth={3}
                strokeLinecap='round'
                strokeLinejoin='round'
              />
            </svg>
          </span>
        </div>
      </div>
    </AbsoluteFill>
  );
}

function Avatar({ url }: { url: string }) {
  if (url) {
    return (
      // eslint-disable-next-line @next/next/no-img-element
      <img
        src={url}
        alt=''
        style={{
          width: 120,
          height: 120,
          borderRadius: '50%',
          objectFit: 'cover',
          flexShrink: 0,
          border: '3px solid #333',
        }}
      />
    );
  }
  return (
    <div
      style={{
        width: 120,
        height: 120,
        borderRadius: '50%',
        flexShrink: 0,
        border: '3px solid #333',
        background:
          'linear-gradient(135deg, #833ab4 0%, #fd1d1d 50%, #fcb045 100%)',
      }}
    />
  );
}

function VerifiedBadge() {
  return (
    <svg viewBox='0 0 40 40' width='34' height='34' fill='none'>
      <circle cx='20' cy='20' r='20' fill='#0095F6' />
      <path
        d='M17.5 27.5L10 20l2.5-2.5 5 5 10-10L30 15 17.5 27.5z'
        fill='#fff'
      />
    </svg>
  );
}

function lerpColor(a: string, b: string, t: number): string {
  const ca = hexToRgb(a);
  const cb = hexToRgb(b);
  return `rgb(${Math.round(ca[0] + (cb[0] - ca[0]) * t)}, ${Math.round(
    ca[1] + (cb[1] - ca[1]) * t
  )}, ${Math.round(ca[2] + (cb[2] - ca[2]) * t)})`;
}
function hexToRgb(hex: string): [number, number, number] {
  const m = hex.replace('#', '');
  const n = parseInt(m, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

Schema (Zod)

import { z } from 'zod';

export const Schema = z.object({
  displayName: z.string().describe('$port:text Display name'),
  handle: z.string().describe('$port:text Handle (e.g. @wireflow_ai)'),
  followerCount: z.string().describe('$port:text Follower count label'),
  avatarUrl: z.string().describe('$port:image Avatar image URL').default(''),
});

Instagram Follow

instagram_follow

Instagram-style profile card with verified badge and Follow → Following toggle animation. Slide-in, elastic button press, slide-out. Ported from hyperframes (Apache-2.0).

Workflow Inputs (4)

  • Display name
    displayName
    TEXTRequired
  • Handle (e.g. @wireflow_ai)
    handle
    TEXTRequired
  • Follower count label (e.g. "12.4K followers")
    followerCount
    TEXTRequired
  • Avatar image URL
    avatarUrl
    IMAGE

Meta

Updated
4/21/2026