bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/arrow-slide-button

Usage

Usagetsx
<ArrowSlideButton />

Props

Standard props — see source.


Source

arrow-slide-button.tsxtsx
"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