Logo

Lift Button

A floating scroll-to-top button that gracefully appears on scroll, with support for custom containers, target-based navigation, and fully customizable styling.

Scrollable Container

Top of the content

Scroll down to trigger the floating lift button.

Section 1

This is a sample content block to demonstrate the scroll behavior.

Section 2

This is a sample content block to demonstrate the scroll behavior.

Section 3

This is a sample content block to demonstrate the scroll behavior.

Section 4

This is a sample content block to demonstrate the scroll behavior.

Section 5

This is a sample content block to demonstrate the scroll behavior.

Section 6

This is a sample content block to demonstrate the scroll behavior.

Section 7

This is a sample content block to demonstrate the scroll behavior.

Section 8

This is a sample content block to demonstrate the scroll behavior.

You've reached the bottom.

Installation

Run the following command to add the lift-button component to your project using the Flow UI registry:

npx shadcn@latest add @flowui/lift-button

Install Dependencies

Ensure you have lucide-react installed for the default icon.

npm install lucide-react

Create Lift Button Component

Create a new file lift-button.tsx in your components folder (e.g., components/ui/lift-button.tsx) and copy the code below.

components/ui/lift-button.tsx
"use client";

import { cn } from "@/lib/utils";
import { ArrowUp } from "lucide-react";
import { ComponentProps, useEffect, useState } from "react";

type LiftButtonProps = {
    className?: string;
    children?: React.ReactNode;
    scrollContainerId?: string;
    targetId?: string;
    alwaysVisible?: boolean;
    threshold?: number;
} & ComponentProps<"button">;

const LiftButton = ({
    className,
    children,
    scrollContainerId,
    targetId,
    alwaysVisible = false,
    threshold = 0,
    ...props
}: LiftButtonProps) => {
    // State to control the visibility
    const [visible, setVisible] = useState<boolean>(alwaysVisible);

    // Scroll based button visibility
    useEffect(() => {
        if (alwaysVisible) return;

        // Get the scroll container
        const container = scrollContainerId ? document.getElementById(scrollContainerId) : window;

        if (!container) return;

        const handleScroll = () => {
            const scrollPosition = container === window ?
                window.pageYOffset || document.documentElement.scrollTop :
                (container as HTMLElement).scrollTop;

            setVisible(scrollPosition > threshold);
        };

        container?.addEventListener("scroll", handleScroll, { passive: true });
        handleScroll();

        return () => container?.removeEventListener("scroll", handleScroll);
    }, [alwaysVisible, threshold, scrollContainerId]);

    // Scroll to top functionality
    const handleClick = () => {
        if(scrollContainerId) {
            const container = document.getElementById(scrollContainerId);
            if(!container) return;

            if(targetId) {
                const target = document.getElementById(targetId);
                if(!target) return;

                const containerRec = container.getBoundingClientRect();
                const targetRec = target.getBoundingClientRect();

                const offset = targetRec.top - containerRec.top + container.scrollTop;
                container.scrollTo({ top: offset, behavior: "smooth" });
            } else {
                container.scrollTo({ top: 0, behavior: "smooth" });
            }

            return;
        }
        if(targetId) {
            const target = document.getElementById(targetId);
            target?.scrollIntoView({ behavior: "smooth" });
            return;
        } else {
            window.scrollTo({ top: 0, behavior: "smooth" });
        }
    }

    return (
        <button
            className={cn(
                "inline-flex justify-center items-center transition-all duration-300",
                className,
                visible ? "opacity-100" : "opacity-0 pointer-events-none"
            )}
            {...props}
            onClick={handleClick}
        >
            {children ? (
                children
            ) : (
                <ArrowUp className="size-5" />
            )}
        </button>
    )
}

export default LiftButton;

Usage

Always Visible

Set the alwaysVisible prop to keep the button permanently visible without requiring any scroll.

Always Visible

Perfect for static layouts

import LiftButton from "@/components/ui/lift-button";
import { ArrowUp } from "lucide-react";

export function AlwaysVisibleExample() {
    return (
        <LiftButton
            alwaysVisible
            className="size-9 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90"
        >
            <ArrowUp className="size-4" />
        </LiftButton>
    );
}

Scroll to a Specific Section

Use the targetId prop to scroll to a specific DOM element instead of the top of the page/container. Combine with scrollContainerId to scope scrolling within a container.

This is the top section.

Content Layer 1
Content Layer 2
Content Layer 3

Target Section

Will scroll back here

Content Layer 4
Content Layer 5
Content Layer 6

Click the rocket below

import LiftButton from "@/components/ui/lift-button";

export const ScrollToSectionExample = () => {
    return (
        <div className="relative h-75">
            {/* Scrollable area */}
            <div id="my-container" className="h-full overflow-y-auto pr-12">
                <div id="target-section">
                    {/* Content */}
                </div>
            </div>
            {/* Button */}
            <LiftButton
                scrollContainerId="my-container"
                targetId="target-section"
            >
                {/* Icon */}
            </LiftButton>
        </div>
    );
}

Custom Threshold

Control how far the user must scroll before the button appears by setting the threshold prop (in pixels).

Threshold: 200px

import LiftButton from "@/components/ui/lift-button";

// Button appears only after scrolling 200px
<LiftButton threshold={200} className="...">
    <ArrowUp className="size-4" />
</LiftButton>

Understand the positioning

import LiftButton from "@/components/ui/lift-button";
export const ScrollToSectionExample = () => {
    return (
        // Outer Container - Help to position the button, if using absolute styling
        <div className="relative h-75">
            {/* Main Scrollable Container + Target Element */}
            <div id="my-container" className="w-full h-full overflow-y-auto">
                <div id="target-section"></div>
            </div>
            {/* Button - Inside the outer container */}
            <LiftButton scrollContainerId="my-container" targetId="target-section">
            </LiftButton>
        </div>
    );
}

Positioning

The LiftButton does not apply any positioning by default. You must provide positioning styles such as fixed, absolute, or sticky via className depending on your layout needs.

Props

The LiftButton component accepts all native <button> HTML attributes, plus the following:

PropTypeDefaultDescription
classNamestring-Additional CSS classes for styling the button.
childrenReact.ReactNode<ArrowUp />Custom content inside the button. Defaults to an ArrowUp icon from lucide-react.
scrollContainerIdstring-The id of a scrollable container to listen to. Defaults to window if not provided.
targetIdstring-The id of a target element to scroll into view. If not set, scrolls to the top of the container.
alwaysVisiblebooleanfalseWhen true, the button is permanently visible regardless of scroll position.
thresholdnumber0The scroll distance (in pixels) the user must exceed before the button becomes visible.

Tip

Since the component extends the native <button> element, you can pass any standard button attributes like aria-label, disabled, title, etc. for full accessibility control.

On this page