primitives
Arrow Slide Button
A pill-shaped button with a dual-arrow slide-through animation on hover. At rest a single right-pointing arrow sits inside the trailing edge and the label is shifted slightly left. On hover a second arrow enters from the left, the trailing arrow exits off-screen right, the label glides right to fill the gap, and a circle expands from center to 220 px to flood the button with a soft primary-tinted fill — giving the impression of an energetic wipe-in. On active/press the button scales down 5% and the ring pulses. The effect uses pure CSS transitions (no JS animation loop): hover classes are toggled by the `.animated` mount-gate so the component is fully static on SSR and for users who prefer reduced motion.
Ported from ryota1231 (MIT)
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/arrow-slide-buttonUsage
<ArrowSlideButton />Props
Standard props — see source.
Source
"use client";
import { forwardRef, useEffect, useState } from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./arrow-slide-button.module.css";
export type ArrowSlideButtonProps = {
/** Button label text (default "E N T R Y"). */
children?: React.ReactNode;
/** Additional class names merged onto the root element. */
className?: string;
/** HTML button type (default "button"). */
type?: "button" | "submit" | "reset";
/** Disables the button and suppresses animation. */
disabled?: boolean;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type">;
const ArrowPath =
"M16.1716 10.9999L10.8076 5.63589L12.2218 4.22168L20 11.9999L12.2218 19.778L10.8076 18.3638L16.1716 12.9999H4V10.9999H16.1716Z";
export const ArrowSlideButton = forwardRef<
HTMLButtonElement,
ArrowSlideButtonProps
>(function ArrowSlideButton(
{
children = "E N T R Y",
className = "",
type = "button",
disabled = false,
...props
},
ref,
) {
// Mount-gate + null-safe reduced-motion check.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
const rootClassName = [
styles.root,
animate && styles.animated,
className,
]
.filter(Boolean)
.join(" ");
return (
<button
ref={ref}
type={type}
disabled={disabled}
className={rootClassName}
{...props}
>
{/* Leading arrow: enters from left on hover */}
<svg
xmlns="http://www.w3.org/2000/svg"
className={styles.arrLeading}
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path d={ArrowPath} />
</svg>
{/* Label text */}
<span className={styles.text}>{children}</span>
{/* Expanding circle fill (decorative) */}
<span className={styles.circle} aria-hidden="true" />
{/* Trailing arrow: exits to the right on hover */}
<svg
xmlns="http://www.w3.org/2000/svg"
className={styles.arrTrailing}
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path d={ArrowPath} />
</svg>
</button>
);
});
Dependencies
- motion
- @bottega/tokens