Refresh Button

An icon button that spins on click and supports async refresh callbacks. Perfect for reloading component previews or any live data widget.

Made by Léo
Open in
demo-refresh.tsx
"use client";

import {
  RefreshButton,
  type RefreshButtonProps,
} from "@/components/unlumen-ui/refresh";

type RefreshButtonDemoProps = Pick<RefreshButtonProps, "variant" | "size">;

export const RefreshButtonDemo = ({
  variant = "neutral",
  size = "icon-sm",
}: RefreshButtonDemoProps) => {
  return (
    <div className="flex min-h-[240px] items-center justify-center gap-3">
      <RefreshButton variant={variant} size={size} />
      <RefreshButton variant={variant} label="Refresh" />
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/refresh.tsx
"use client";

import * as React from "react";
import { motion, useAnimation, type HTMLMotionProps } from "motion/react";
import { RotateCcw } from "lucide-react";
import { type VariantProps } from "class-variance-authority";

import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type ButtonProps = HTMLMotionProps<"button"> &
  VariantProps<typeof buttonVariants>;

export interface RefreshButtonProps
  extends Omit<ButtonProps, "children" | "asChild"> {
  /** Called on tap. Can be async — awaited before resetting the animation. */
  onRefresh?: () => void | Promise<void>;
  /** Icon size in px. @default 14 */
  iconSize?: number;
  /** Optional text label displayed next to the icon. */
  label?: string;
}

export function RefreshButton({
  className,
  variant = "neutral",
  size,
  onRefresh,
  iconSize = 14,
  label,
  disabled,
  ...props
}: RefreshButtonProps) {
  const rotateControls = useAnimation();
  const resolvedSize = size ?? (label ? "sm" : "icon-sm");
  // Accumulated rotation — always increments negatively so it never reverses
  const totalRotation = React.useRef(0);

  const animateTo = React.useCallback(
    (delta: number, transition: object) => {
      totalRotation.current += delta;
      return rotateControls.start({
        rotate: totalRotation.current,
        transition,
      });
    },
    [rotateControls],
  );

  return (
    <motion.button
      className={cn(
        buttonVariants({ variant, size: resolvedSize }),
        "flex items-center rounded-lg",
        className,
      )}
      onTapStart={() =>
        animateTo(-20, { type: "spring", stiffness: 400, damping: 25 })
      }
      onTap={async () => {
        await onRefresh?.();
        await animateTo(-340, {
          type: "spring",
          stiffness: 200,
          damping: 18,
        });
      }}
      onTapCancel={() =>
        animateTo(-340, { type: "spring", stiffness: 200, damping: 18 })
      }
      whileHover={{ scale: 1.05 }}
      whileTap={{ scale: 0.95 }}
      disabled={disabled}
      {...props}
    >
      <motion.span className="inline-flex self-center" animate={rotateControls}>
        <RotateCcw aria-label="restart-btn" size={iconSize} />
      </motion.span>
      {label && <span>{label}</span>}
    </motion.button>
  );
}

Update the import paths to match your project setup.

Usage

import { RefreshButton } from "@/components/unlumen-ui/components/buttons/refresh";

<RefreshButton onRefresh={() => refetch()} />;

Async refresh

The button disables itself and spins continuously while the promise is pending:

<RefreshButton
  onRefresh={async () => {
    await fetchData();
  }}
/>

Controlled

<RefreshButton isRefreshing={loading} onRefresh={handleRefresh} />

API

PropTypeDefaultDescription
onRefresh() => void | Promise<void>Callback fired on click. Async promises are awaited.
isRefreshingbooleanControlled refreshing state (disables button).
variant"default" | "ghost" | "outline" | "secondary""ghost"Visual variant.
size"sm" | "default" | "lg""default"Button size.

Built by Léo from Unlumen :3

Last updated: 4/29/2026