Sweep Button
A label box and an arrow box that merge while a fill sweeps across the label.
$npx shadcn@latest add https://framebrick.co/r/sweep-button.jsonFrom the Ciketo template, see it in context.
import { SweepButton } from "@/components/ui/sweep-button";
export default function SweepButtonDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-6">
<SweepButton href="#">Book a viewing</SweepButton>
<SweepButton href="#" arrow={false}>
Our work
</SweepButton>
<SweepButton href="#" size="lg">
Start a project
</SweepButton>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/sweep-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/sweep-button.tsx.import * as React from "react"; import Link from "next/link"; import { cn } from "@/lib/utils"; /* * Sweep Button — a label box and an arrow box two pixels apart. On hover and on keyboard focus a fill sweeps across * the label box from the left, the label turns light, the gap closes and the inner corners go square so the two boxes * become one block, and the arrow turns 45° (from the Ciketo "Button/Primary"). Without the arrow it is the label box * alone, with the sweep. * * It is plain CSS: no animation library, no JavaScript, so it works as it is on a server page and without scripts. * Framer's spring (bounce 0, 0.4s) is approximated by cubic-bezier(0.22, 1, 0.36, 1) over 400 ms — the curve Ciketo's * own Next.js edition uses for its width changes; a critically damped spring has that shape. With reduced motion the * state simply changes, with no sweep. * * Improvement over Ciketo, which fades the label's colour and so passes through light text on a light box half way: * the light label is a second copy, clipped by the same edge as the fill, so the text changes colour exactly where the * sweep passes. The copy is aria-hidden: the label is read once. */ const ease = "duration-400 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"; const sizes = { sm: { label: "h-8 px-4 text-xs", arrow: "size-8" }, default: { label: "h-9 px-5 text-sm", arrow: "size-9" }, lg: { label: "h-11 px-7 text-base", arrow: "size-11" }, } as const; type Common = { size?: keyof typeof sizes; /** The arrow box beside the label. Without it the button is the label box alone. */ arrow?: boolean; className?: string; children?: React.ReactNode; }; export type SweepButtonProps = | (Common & { href: string } & Omit<React.ComponentProps<"a">, keyof Common | "href">) | (Common & { href?: undefined } & Omit<React.ComponentProps<"button">, keyof Common>); export function SweepButton({ size = "default", arrow = true, className, children, ...props }: SweepButtonProps) { const s = sizes[size]; const content = ( <> <span className={cn( "relative flex items-center justify-center overflow-hidden rounded-md bg-secondary font-medium whitespace-nowrap text-secondary-foreground transition-[border-radius]", arrow && "group-hover:rounded-r-none group-focus-visible:rounded-r-none", s.label, ease, )} > {children} {/* The fill and the light label are one layer, so they share an edge. */} <span aria-hidden className={cn( "absolute inset-0 flex items-center justify-center bg-primary px-[inherit] text-primary-foreground transition-[clip-path]", "[clip-path:inset(0_100%_0_0)] group-hover:[clip-path:inset(0)] group-focus-visible:[clip-path:inset(0)]", ease, )} > {children} </span> </span> {arrow && ( <span aria-hidden className={cn( "ml-0.5 grid shrink-0 place-items-center rounded-md bg-secondary text-secondary-foreground transition-[margin,border-radius]", "group-hover:ml-0 group-hover:rounded-l-none group-focus-visible:ml-0 group-focus-visible:rounded-l-none", s.arrow, ease, )} > <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className={cn( "size-4 transition-transform group-hover:rotate-45 group-focus-visible:rotate-45", ease, )} > <path d="M4.5 11.5l7-7M5.5 4.5h6v6" /> </svg> </span> )} </> ); const classes = cn( "group inline-flex shrink-0 cursor-pointer items-stretch rounded-md outline-none select-none", "focus-visible:ring-[3px] focus-visible:ring-ring/50", className, ); if (props.href !== undefined) { const { href, ...rest } = props; const external = /^https?:\/\//.test(href); return ( <Link href={href} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} {...rest} className={classes}> {content} </Link> ); } const { type = "button", ...rest } = props as React.ComponentProps<"button">; return ( <button type={type} {...rest} className={cn(classes, "disabled:pointer-events-none disabled:opacity-50")}> {content} </button> ); }
Usage
import { SweepButton } from "@/components/ui/sweep-button";
<SweepButton href="/contact">Book a viewing</SweepButton>
<SweepButton href="/work" arrow={false}>Our work</SweepButton>
<SweepButton type="submit" size="lg">Start a project</SweepButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "default" | "lg" | "default" | Height 32 / 36 / 44 px; the arrow box is a square of the same size. |
| arrow | boolean | true | The arrow box beside the label. Without it the button is the label box alone, with the sweep. |
| 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, disabled, aria-*). |
- Plain CSS: no animation library and no JavaScript, so it works as it is in a server component and without scripts.
- Framer's spring (bounce 0, 0.4 s) is approximated by cubic-bezier(0.22, 1, 0.36, 1) over 400 ms, the curve Ciketo's own Next.js edition uses for its width changes.
- The light label is a second copy clipped by the same edge as the fill, so the text changes colour exactly where the sweep passes. Ciketo fades the colour, which leaves light text on a light box half way. The copy is aria-hidden, so the label is read once.
- The sweep also runs on keyboard focus. With reduced motion the state simply changes, with no sweep. On a touch screen there is no hover, so only the resting look shows.
- The label is rendered twice, so pass text or inline content, not a component with state of its own.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called SweepButton using Next.js Link — plain CSS, no animation library.
- Two boxes in an inline-flex row, items-stretch, 2px apart (ml-0.5 on the second): a label box and a square arrow box
of the same height (sizes: h-8 px-4 text-xs, h-9 px-5 text-sm, h-11 px-7 text-base). Both bg-secondary, rounded-md,
text-secondary-foreground; the label is font-medium. The root has the class "group".
- Inside the label box (relative, overflow-hidden) put a second copy of the label in an absolutely positioned layer
(inset-0, flex, centred) with bg-primary text-primary-foreground and aria-hidden. At rest it has clip-path
inset(0 100% 0 0); on group-hover and group-focus-visible it becomes inset(0), transitioning clip-path over 400ms with
cubic-bezier(0.22,1,0.36,1). The fill and the light label are the same layer, so they share one edge.
- On group-hover and group-focus-visible: the label box loses its right corners (rounded-r-none), the arrow box loses
its margin (ml-0) and its left corners (rounded-l-none), so the two become one block — transitioning border-radius and
margin with the same duration and curve; and the arrow (an up-right arrow, 16px) rotates 45°.
- motion-reduce:transition-none on everything that transitions.
- An arrow={false} prop drops the arrow box. If an href is given render a Link, external http(s) links open in a new tab
with rel noopener noreferrer; otherwise a button. Visible focus ring (ring-[3px] ring-ring/50).










