# CTA Minimal Serif — Tailwind CTA Section

> One large serif sentence that rises word by word, closed by a magnetic link: a ring of turning type leans toward the cursor while the label's underline draws in.

- Category: [Calls to Action](https://pixelpapa.com/components/cta.md)
- Tags: cta, minimal, serif, magnetic, editorial
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/cta/cta-minimal-serif

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/cta-minimal-serif.json"
```

The file is written to `components/pixelpapa/cta-minimal-serif.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUpRight } from "lucide-react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
import { useId, useRef, type PointerEvent } from "react";
import { cn } from "@/lib/utils";

type Detail = { label: string; value: string; href?: string };

type CtaMinimalSerifProps = {
  index?: string;
  studio?: string;
  sentence?: string;
  /** Words (without punctuation) set in italic accent colour. */
  emphasis?: string[];
  linkLabel?: string;
  href?: string;
  details?: Detail[];
};

const ease = [0.22, 1, 0.36, 1] as const;

function MagneticLink({ href, label }: { href: string; label: string }) {
  const ref = useRef<HTMLAnchorElement>(null);
  const reduce = useReducedMotion();
  const pathId = `pp-cta-minimal-serif-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const sx = useSpring(x, { stiffness: 170, damping: 14, mass: 0.5 });
  const sy = useSpring(y, { stiffness: 170, damping: 14, mass: 0.5 });
  // The ring travels further than the label, so the pair stretches toward the pointer.
  const ringX = useTransform(sx, (v) => v * 2.2);
  const ringY = useTransform(sy, (v) => v * 2.2);
  const ringText = `${label} · ${label} · `.toUpperCase();

  function onPointerMove(e: PointerEvent<HTMLAnchorElement>) {
    const r = ref.current?.getBoundingClientRect();
    if (!r || reduce) return;
    x.set((e.clientX - (r.left + r.width / 2)) * 0.12);
    y.set((e.clientY - (r.top + r.height / 2)) * 0.18);
  }

  function reset() {
    x.set(0);
    y.set(0);
  }

  return (
    <motion.a
      ref={ref}
      href={href}
      style={{ x: sx, y: sy }}
      onPointerMove={onPointerMove}
      onPointerLeave={reset}
      whileTap={{ scale: 0.97 }}
      className="group relative -m-5 inline-flex items-center gap-6 rounded-full p-5 outline-offset-0 sm:gap-8"
    >
      <motion.span style={{ x: ringX, y: ringY }} className="relative flex size-32 shrink-0 items-center justify-center sm:size-36">
        <svg
          viewBox="0 0 120 120"
          aria-hidden
          className="absolute inset-0 size-full motion-reduce:animate-none"
          style={{ animation: "pp-cta-minimal-serif-turn 16s linear infinite" }}
        >
          <defs>
            <path id={pathId} d="M60 60m-50 0a50 50 0 1 1 100 0a50 50 0 1 1-100 0" />
          </defs>
          <text className="fill-muted-foreground font-mono text-[9px] transition-colors duration-300 group-hover:fill-foreground">
            <textPath href={`#${pathId}`} textLength="308" lengthAdjust="spacing">
              {ringText}
            </textPath>
          </text>
        </svg>
        <span className="relative flex size-[58%] items-center justify-center overflow-hidden rounded-full border border-foreground/15 text-foreground transition-colors duration-500 group-hover:border-primary group-hover:text-primary-foreground group-focus-visible:border-primary group-focus-visible:text-primary-foreground">
          <span className="absolute inset-0 scale-0 rounded-full bg-primary transition-transform duration-500 ease-[cubic-bezier(.22,1,.36,1)] group-hover:scale-100 group-focus-visible:scale-100" />
          <ArrowUpRight className="relative size-7 transition-transform duration-500 ease-out group-hover:rotate-45" strokeWidth={1.5} />
        </span>
      </motion.span>

      <span className="relative font-serif text-3xl text-foreground sm:text-5xl">
        {label}
        <span className="absolute inset-x-0 -bottom-1.5 h-px bg-border" />
        <span className="absolute inset-x-0 -bottom-1.5 h-px origin-left scale-x-0 bg-foreground transition-transform duration-700 ease-[cubic-bezier(.22,1,.36,1)] group-hover:scale-x-100 group-focus-visible:scale-x-100" />
      </span>
    </motion.a>
  );
}

export default function CtaMinimalSerif({
  index = "06",
  studio = "Saltmarsh Studio · Lisbon and remote",
  sentence = "Let's make the thing people can't stop telling their friends about.",
  emphasis = ["can't", "stop"],
  linkLabel = "Start a project",
  href = "#",
  details = [
    { label: "New business", value: "hello@saltmarsh.studio", href: "#" },
    { label: "Availability", value: "Two project slots from February" },
  ],
}: CtaMinimalSerifProps) {
  const reduce = useReducedMotion();
  const words = sentence.split(" ");

  return (
    <section className="bg-background px-6 py-24 sm:py-32">
      <style>{`@keyframes pp-cta-minimal-serif-turn { to { transform: rotate(360deg); } }`}</style>
      <div className="mx-auto max-w-6xl">
        <div className="flex items-center justify-between gap-6 font-mono text-xs tracking-wide text-muted-foreground uppercase">
          <span>
            <span className="text-foreground">({index})</span> Contact
          </span>
          <motion.span
            aria-hidden
            initial={{ scaleX: 0 }}
            animate={{ scaleX: 1 }}
            transition={{ duration: 1, ease }}
            className="h-px flex-1 origin-left bg-border"
          />
          <span className="hidden sm:block">{studio}</span>
        </div>

        <motion.h2
          aria-label={sentence}
          initial="hidden"
          animate="shown"
          transition={{ staggerChildren: reduce ? 0 : 0.04, delayChildren: 0.1 }}
          className="mt-12 flex max-w-5xl flex-wrap gap-x-[0.24em] font-serif text-[clamp(2.75rem,7.4vw,6.75rem)] leading-[1.04] tracking-tight text-foreground sm:mt-16"
        >
          {words.map((word, i) => {
            const accent = emphasis.includes(word.replace(/[.,!?;:]/g, ""));
            return (
              // Padding plus negative margin gives descenders and italics room inside the clip.
              <span key={`${word}-${i}`} aria-hidden className="-mb-[0.14em] inline-block overflow-hidden px-[0.04em] pb-[0.14em]">
                <motion.span
                  variants={{
                    hidden: reduce ? { opacity: 0 } : { y: "115%", rotate: 5 },
                    shown: { y: 0, rotate: 0, opacity: 1 },
                  }}
                  transition={{ duration: 0.75, ease }}
                  className={cn("inline-block origin-bottom-left", accent && "text-primary italic")}
                >
                  {word}
                </motion.span>
              </span>
            );
          })}
        </motion.h2>

        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, delay: 0.55, ease }}
          className="mt-14 flex flex-wrap items-end justify-between gap-x-16 gap-y-12 sm:mt-20"
        >
          <MagneticLink href={href} label={linkLabel} />
          <dl className="flex flex-wrap gap-x-14 gap-y-6 text-sm">
            {details.map((d) => (
              <div key={d.label}>
                <dt className="font-mono text-xs tracking-wide text-muted-foreground uppercase">{d.label}</dt>
                <dd className="mt-2 text-base text-foreground">
                  {d.href ? (
                    <a href={d.href} className="underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground">
                      {d.value}
                    </a>
                  ) : (
                    d.value
                  )}
                </dd>
              </div>
            ))}
          </dl>
        </motion.div>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "CTA Minimal Serif" component from PixelPapa UI to this project.

What it is: One large serif sentence that rises word by word, closed by a magnetic link: a ring of turning type leans toward the cursor while the label's underline draws in.
Category: Calls to Action. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/cta-minimal-serif.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/cta-minimal-serif.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/cta-minimal-serif" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More calls to action

- [CTA App Download](https://pixelpapa.com/components/cta/cta-app-download.md) (Pro): App download panel with generic store buttons, a scannable-looking code, and a phone that tilts toward the cursor while its route draws itself and a map saves offline.
- [CTA Countdown Launch](https://pixelpapa.com/components/cta/cta-countdown-launch.md) (Pro): A launch countdown on split-flap tiles whose digits roll over each second, with orbit rings turning behind it and a waitlist field that confirms your place inline.
- [CTA Dark Spotlight](https://pixelpapa.com/components/cta/cta-dark-spotlight.md): An always-dark closing section lit by a cursor torch: the headline ink and a dot grid brighten where the light falls, and it drifts on its own when no pointer is around.
- [CTA Gradient Glow Card](https://pixelpapa.com/components/cta/cta-gradient-glow-card.md) (Pro): A closing card ringed by a slowly rotating gradient border, with a soft light that trails the cursor and a copyable install command.
- [CTA Marquee Text Band](https://pixelpapa.com/components/cta/cta-marquee-text-band.md): Three rows of giant type drift in opposite directions behind one button; they speed up and lean with page scroll and slow to a crawl while you hover the button.
- [CTA Split With Form](https://pixelpapa.com/components/cta/cta-split-with-form.md) (Pro): Pitch and a what-happens-next timeline on the left, a demo request form on the right that validates, sends and morphs into a drawn-check confirmation.
