Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 02:39:32Get Lifetime →
loopkit
freeNew210 lines · framer-motion

CTA Split Image

Headline on one side, a living visual on the other — no stock photo needed. A two-column call-to-action: headline, subcopy and two buttons on one side, and an abstract animated visual — a drifting blurred colour field behind a small stack of floating cards — on the other. Stacks to a single column on narrow screens.

Use it for
A mid-page or closing CTA on a marketing site where a plain text band feels flat but a real product photo isn't available or on-brand.
Themes
Light + dark
License
Commercial use
Live
New

Launch workflows your team can see through

Connect your tools, watch every run step through in real time, and ship changes without guessing what will break.

Throughput

Latency

Run completed

tsx
"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Two-column CTA. One side carries headline/subcopy/CTAs, the other side is
 * an abstract animated "visual" built entirely from CSS gradients, inline SVG
 * and framer-motion — a drifting blurred colour field behind a small stack of
 * floating cards with a mini chart/wave glyph inside. No photos, no canvas.
 * Stacks to a single column under the `lg` breakpoint.
 */

export interface CtaSplitImageProps {
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
  primaryCta?: string;
  secondaryCta?: string;
  onPrimaryClick?: () => void;
  onSecondaryClick?: () => void;
  /** Put the visual on the left instead of the right (reverses column order on large screens). */
  reverse?: boolean;
  /** Scales the glow/blur strength of the background colour field. */
  glow?: number;
  className?: string;
}

function MiniWave() {
  return (
    <svg viewBox="0 0 120 36" className="h-7 w-full text-signal" fill="none" aria-hidden>
      <path
        d="M2 24 C 14 6, 26 6, 38 20 S 62 34, 74 16 S 98 2, 118 18"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        fill="none"
      />
    </svg>
  );
}

function MiniBars() {
  const heights = [40, 65, 48, 80, 58, 70];
  return (
    <div className="flex h-16 items-end gap-1.5">
      {heights.map((h, i) => (
        <div
          key={i}
          className={cn("w-2.5 rounded-full", i % 2 === 0 ? "bg-signal/70" : "bg-info/60")}
          style={{ height: `${h}%` }}
        />
      ))}
    </div>
  );
}

function FloatingCard({
  className,
  rotate,
  delay,
  float,
  children,
}: {
  className?: string;
  rotate: number;
  delay: number;
  float: boolean;
  children: React.ReactNode;
}) {
  return (
    <motion.div
      className={cn(
        "absolute w-48 rounded-2xl border border-border bg-card/95 p-4 shadow-xl shadow-black/10 backdrop-blur-sm",
        className
      )}
      initial={{ rotate, y: 0 }}
      animate={float ? { y: [0, -10, 0], rotate: [rotate, rotate + 1.5, rotate] } : { rotate }}
      transition={{ duration: 6, repeat: Infinity, ease: "easeInOut", delay }}
    >
      {children}
    </motion.div>
  );
}

export function CtaSplitImage({
  eyebrow = "New",
  headline = "Launch workflows your team can see through",
  subcopy = "Connect your tools, watch every run step through in real time, and ship changes without guessing what will break.",
  primaryCta = "Start building",
  secondaryCta = "View docs",
  onPrimaryClick,
  onSecondaryClick,
  reverse = false,
  glow = 1,
  className,
}: CtaSplitImageProps) {
  const reduce = useReducedMotionSafe();
  const visualRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(visualRef, { amount: 0.3 });
  const float = !reduce && inView;

  return (
    <section className={cn("w-full overflow-hidden bg-background", className)}>
      <div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 sm:py-24 lg:px-10">
        <div className="grid grid-cols-1 items-center gap-y-14 lg:grid-cols-2 lg:gap-x-16">
          <div className={cn(reverse ? "lg:order-2" : "lg:order-1")}>
            <span className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
              <span className="size-1.5 rounded-full bg-signal" aria-hidden />
              {eyebrow}
            </span>
            <h2 className="mt-5 font-display text-[clamp(2rem,4.2vw,3.25rem)] font-extrabold leading-[1.02] tracking-[-0.03em] text-foreground">
              {headline}
            </h2>
            <p className="mt-4 max-w-md text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>
            <div className="mt-8 flex flex-wrap items-center gap-3">
              <a
                href="#"
                onClick={(e) => {
                  e.preventDefault();
                  onPrimaryClick?.();
                }}
                className="inline-flex h-12 items-center rounded-full bg-primary px-6 text-[15px] font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal"
              >
                {primaryCta} →
              </a>
              <a
                href="#"
                onClick={(e) => {
                  e.preventDefault();
                  onSecondaryClick?.();
                }}
                className="inline-flex h-12 items-center rounded-full border-2 border-border px-6 text-[15px] font-semibold text-foreground transition hover:border-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal"
              >
                {secondaryCta}
              </a>
            </div>
          </div>

          <div
            ref={visualRef}
            className={cn(
              "relative isolate flex h-[21rem] items-center justify-center sm:h-[26rem]",
              reverse ? "lg:order-1" : "lg:order-2"
            )}
          >
            <div
              aria-hidden
              className="absolute inset-0 rounded-[2rem]"
              style={{
                background:
                  "radial-gradient(60% 55% at 32% 30%, color-mix(in oklab, var(--signal) 55%, transparent) 0%, transparent 70%), radial-gradient(55% 50% at 72% 68%, color-mix(in oklab, var(--info) 50%, transparent) 0%, transparent 70%)",
                filter: `blur(${32 * glow}px)`,
                opacity: Math.min(1, 0.7 * glow),
              }}
            />
            <motion.div
              aria-hidden
              className="absolute inset-0 rounded-[2rem]"
              style={{
                background: "radial-gradient(45% 45% at 50% 50%, color-mix(in oklab, var(--lime) 35%, transparent) 0%, transparent 75%)",
                filter: `blur(${24 * glow}px)`,
              }}
              animate={float ? { opacity: [0.35, 0.6, 0.35] } : { opacity: 0.45 }}
              transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
            />

            <div className="relative h-full w-full max-w-sm">
              <FloatingCard className="left-2 top-6 sm:left-4" rotate={-6} delay={0} float={float}>
                <p className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Throughput</p>
                <div className="mt-3">
                  <MiniBars />
                </div>
              </FloatingCard>

              <FloatingCard className="right-0 top-0 sm:right-2" rotate={5} delay={0.6} float={float}>
                <p className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Latency</p>
                <div className="mt-3">
                  <MiniWave />
                </div>
              </FloatingCard>

              <FloatingCard className="bottom-2 left-1/2 -translate-x-1/2" rotate={2} delay={1.2} float={float}>
                <div className="flex items-center gap-2">
                  <span className="flex size-6 items-center justify-center rounded-full bg-ok/15 text-ok" aria-hidden>
                    <svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round">
                      <path d="M5 12.5 9.5 17 19 7" />
                    </svg>
                  </span>
                  <p className="text-sm font-medium text-card-foreground">Run completed</p>
                </div>
              </FloatingCard>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

export default CtaSplitImage;

How it behaves

  • →The visual side is built from layered CSS radial gradients using the --signal/--info/--lime theme colours via color-mix(), so it re-colours instantly with the .dark class — no canvas or JS colour reads needed.
  • →Three floating cards (mini bar chart, mini wave line, a status row) idle-float on independent framer-motion loops.
  • →Floating is driven by useInView on the visual column: it only animates while the column is scrolled into view, and stops entirely when it leaves.
  • →reverse swaps the text/visual column order on large screens via CSS order classes; the DOM order (text first) stays the same for reading order.
  • →glow scales the blur radius and opacity of the background colour field.
  • →prefers-reduced-motion freezes the cards and the glow pulse to a static frame.

Accessibility

  • ✓All decorative gradients and the floating cards' container are aria-hidden; only the headline, subcopy and two real <a> CTAs are in the accessibility tree.
  • ✓Headline is a single <h2> so the block can be composed under a page's existing <h1>.
  • ✓Both CTAs have visible focus-visible outlines and accept onPrimaryClick/onSecondaryClick instead of forcing a real navigation.
  • ✓Honours prefers-reduced-motion.