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 } from "motion/react";
import { RotateCcw } from "lucide-react";
import { type VariantProps } from "class-variance-authority";

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

type ButtonProps = React.ComponentPropsWithoutRef<"button"> &
  VariantProps<typeof buttonVariants> & { asChild?: boolean };

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 (
    <Button
      className={cn("flex items-center rounded-lg", className)}
      variant={variant}
      size={resolvedSize}
      disabled={disabled}
      asChild
      {...props}
    >
      <motion.button
        // When there's a label, only the icon spins; otherwise the whole button spins
        animate={!label ? rotateControls : undefined}
        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 }}
      >
        <motion.span
          className="inline-flex"
          animate={label ? rotateControls : undefined}
        >
          <RotateCcw aria-label="restart-btn" size={iconSize} />
        </motion.span>
        {label && <span>{label}</span>}
      </motion.button>
    </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: 3/15/2026