Circle Button
A round call to action: two circles slide together while the arrow turns to point up-right.
$npx shadcn@latest add https://framebrick.co/r/circle-button.jsonFrom the Compose Digi template, see it in context.
import { CircleButton } from "@/components/ui/circle-button";
/* The pale circle and the dark one slide together, and the arrow turns from right to up-right. */
export default function CircleButtonDemo() {
return (
<div className="flex items-center gap-6">
<p className="text-xl font-semibold tracking-tight">
Tell us your
<br />
new ideas
</p>
<CircleButton href="#" label="Contact us" />
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/circle-button.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/circle-button.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { motion, type HTMLMotionProps, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Circle Button — a round call to action made of two circles (from the Compose Digi footer "Button/Action"): a pale one, and a * dark one holding an arrow that pointed right, 32px to its right. On hover, and on keyboard focus, the two slide together * until they overlap, so what is left is one dark circle, while the arrow turns from right to up-right (0° → -45°). * Leaving mirrors it. * * Framer's over-damped spring drives all three moves: stiffness 500, damping 60, mass 1 (no overshoot, about 0.65 s). * The button has no visible text, so `label` is its accessible name. `size` is the diameter of a circle and `gap` how far * apart they rest; the closed button is size + gap wide. Pass `href` to render a Next.js link; external http(s) links open * in a new tab. */ const slide: Transition = { type: "spring", stiffness: 500, damping: 60, mass: 1 }; type Common = { /** The accessible name: the button shows only an arrow. */ label: string; /** Diameter of each circle, in px. */ size?: number; /** How far apart the circles rest, in px; they meet in the middle. */ gap?: number; /** In place of the arrow: an svg that takes its colour from the text colour. It turns with the arrow's turn. */ icon?: React.ReactNode; className?: string; }; export type CircleButtonProps = | (Common & { href: string } & Omit<HTMLMotionProps<"a">, keyof Common | "href" | "children">) | (Common & { href?: undefined } & Omit<HTMLMotionProps<"button">, keyof Common | "children">); const MotionLink = motion.create(Link); export function CircleButton({ label, size = 62, gap = 32, icon, className, ...props }: CircleButtonProps) { const middle = gap / 2; const motionProps = { initial: "rest", animate: "rest", whileHover: "hover", whileFocus: "hover", "aria-label": label, className: cn( "relative block shrink-0 cursor-pointer rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className, ), style: { width: size + gap, height: size }, } as const; const content = ( <> <motion.span aria-hidden className="absolute top-0 left-0 rounded-full bg-foreground/20" style={{ width: size, height: size }} variants={{ rest: { x: 0 }, hover: { x: middle } }} transition={slide} /> <motion.span aria-hidden className="absolute top-0 left-0 flex items-center justify-center rounded-full bg-primary text-primary-foreground" style={{ width: size, height: size }} variants={{ rest: { x: gap }, hover: { x: middle } }} transition={slide} > <motion.span className="flex w-[58%]" variants={{ rest: { rotate: 0 }, hover: { rotate: -45 } }} transition={slide}> {icon ?? ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" className="size-full"> <path d="M3.5 12h17M14 5.5l6.5 6.5-6.5 6.5" /> </svg> )} </motion.span> </motion.span> </> ); if (props.href !== undefined) { const { href, ...anchor } = props; const external = /^https?:\/\//.test(href); return ( <MotionLink href={href} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...anchor} {...motionProps}> {content} </MotionLink> ); } const { type = "button", ...button } = props as HTMLMotionProps<"button">; return ( <motion.button type={type} {...button} {...motionProps}> {content} </motion.button> ); }
Usage
import { CircleButton } from "@/components/ui/circle-button";
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">Tell us your new ideas</h2>
<CircleButton href="/contact" label="Contact us" />
</div>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | The accessible name: the button shows only an arrow. |
| size | number | 62 | Diameter of each circle, in px. |
| gap | number | 32 | How far apart the circles rest, in px; they meet in the middle. The closed button is size + gap wide. |
| icon | ReactNode | — | An svg in place of the arrow; it takes its colour from the text colour and turns the same way. |
| href | string | — | Render a Next.js link instead of a button. http(s) links open in a new tab. |
| …props | button / anchor props | — | Passed to the element (onClick, aria-*, Motion props). |
- It is Compose Digi's footer call to action: two 62 px circles, a pale one and a dark one holding an arrow, 32 px to its right. On hover and on keyboard focus they slide together until they overlap, so what is left is one dark circle, while the arrow turns from right to up-right (0° → −45°). One over-damped spring drives all three moves — stiffness 500, damping 60, mass 1, no overshoot, about 0.65 s — and leaving mirrors it.
- There is no visible text, so `label` is the accessible name (an aria-label). The icon is decorative.
- The closed button is size + gap wide and size tall, so it takes the same room open or closed, and the focus ring follows that pill.
- The pale circle is the foreground at 20% and the dark one is bg-primary with text-primary-foreground, so a themed project gets its own colours; the arrow's contrast is the theme's primary against its foreground.
- Transforms only, so <MotionConfig reducedMotion="user"> skips them.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called CircleButton — a round call to action made of two circles. - Props: label (the accessible name, set as aria-label), size (circle diameter in px, 62), gap (how far apart they rest, 32), icon (an svg in place of the default arrow), href (a Next.js Link via motion.create(Link); external http(s) links open in a new tab, otherwise a motion.button), className. - The root is relative block rounded-full, width size + gap and height size, with a visible focus ring (ring-[3px] ring-ring/50). - Two absolutely positioned circles of the given size at top-0 left-0: a pale one (bg-foreground/20) and, on top, a dark one (bg-primary, text-primary-foreground) with a right arrow in it (a 24px viewBox, stroke 1.25, round caps, 58% of the circle's width). - Variants rest and hover (also on keyboard focus), one spring for everything: stiffness 500, damping 60, mass 1. The pale circle's x goes from 0 to gap / 2, the dark one's from gap to gap / 2 (so they overlap), and the arrow's wrapper rotates from 0 to -45°.











