Floating Button
A button pinned above the page, anchored to a corner or edge midpoint by
default, and draggable anywhere. Built on Button,
so every variant, size and colorScheme comes along.
Your app
The button floats above the content, pinned to a corner.
"use client";
import { Box, FloatingButton, Icon, Text, VStack } from "astralis-ui";
import { MessageCircle } from "lucide-react";
/*
* The `transform: translateZ(0)` on the panel is load-bearing: a transformed
* ancestor becomes the containing block for `position: fixed`, so the button
* pins to this panel instead of the real viewport. Dragging is disabled in
* these contained demos: the drag math works in viewport coordinates, which a
* containing block deliberately breaks.
*/
export function FloatingButtonDemo() {
return (
<Box
position="relative"
h="56"
w="full"
maxW="md"
overflow="hidden"
rounded="xl"
border="normal"
borderColor="subtle"
bg="subtle"
style={{ transform: "translateZ(0)" }}
>
<VStack gap="2" p="5" alignItems="start">
<Text size="xl" weight="medium">
Your app
</Text>
<Text size="sm" color="muted">
The button floats above the content, pinned to a corner.
</Text>
</VStack>
<FloatingButton
draggable={false}
aria-label="Open chat"
leftIcon={<Icon as={MessageCircle} size="sm" />}
>
Chat
</FloatingButton>
</Box>
);
}Import#
import { FloatingButton } from "astralis-ui";Usage#
The default is a solid brand pill resting in the bottom-right corner, with
elevation and a full radius: the affordance of something sitting above the
page rather than in it. It renders in a position: fixed wrapper just under
the overlay layer, so it clears page chrome but stays beneath modals and
drawers.
The demos on this page are fenced into their panels (a transformed ancestor
becomes the containing block for fixed), so they rest against the panel
instead of your screen.
Placement#
Six resting anchors: the four corners plus the two horizontal edge midpoints.
offset controls how far it sits from the edges.
"use client";
import { Box, FloatingButton } from "astralis-ui";
const anchors = [
{ placement: "top-left", colorScheme: "teal" },
{ placement: "center-top", colorScheme: "blue" },
{ placement: "top-right", colorScheme: "purple" },
{ placement: "bottom-left", colorScheme: "pink" },
{ placement: "center-bottom", colorScheme: "brand" },
{ placement: "bottom-right", colorScheme: "green" },
] as const;
/* One panel, six resting anchors: four corners plus the two edge midpoints.
The transform scopes `fixed` to the panel; see floating-button-demo.tsx. */
export function FloatingButtonPlacements() {
return (
<Box
position="relative"
h="64"
w="full"
overflow="hidden"
rounded="xl"
border="normal"
borderColor="subtle"
bg="subtle"
style={{ transform: "translateZ(0)" }}
>
{anchors.map(({ placement, colorScheme }) => (
<FloatingButton
key={placement}
placement={placement}
colorScheme={colorScheme}
offset="sm"
size="xs"
draggable={false}
aria-label={placement}
>
{placement}
</FloatingButton>
))}
</Box>
);
}Icon-only#
Omit the label and Button's icon-only behavior kicks in: a square frame that
the default rounded="full" turns into the classic circular FAB. Icon-only
buttons must carry an aria-label. The library warns in development
when one is missing.
"use client";
import { Box, FloatingButton, Icon } from "astralis-ui";
import { Plus, Pencil, ArrowUp } from "lucide-react";
/* Icon-only: with no label, Button gives the FAB a square frame, and the
default `rounded="full"` turns it into the classic circle. */
export function FloatingButtonIcon() {
return (
<Box
position="relative"
h="56"
w="full"
maxW="md"
overflow="hidden"
rounded="xl"
border="normal"
borderColor="subtle"
bg="subtle"
style={{ transform: "translateZ(0)" }}
>
<FloatingButton
placement="bottom-right"
size="lg"
draggable={false}
aria-label="Create"
leftIcon={<Icon as={Plus} size="md" />}
/>
<FloatingButton
placement="center-bottom"
colorScheme="gray"
variant="surface"
draggable={false}
aria-label="Compose"
leftIcon={<Icon as={Pencil} size="sm" />}
/>
<FloatingButton
placement="bottom-left"
size="sm"
colorScheme="teal"
draggable={false}
aria-label="Back to top"
leftIcon={<Icon as={ArrowUp} size="sm" />}
/>
</Box>
);
}Dragging#
Dragging is on by default and pointer-based, so mouse, touch and pen all work
from one path. A press only becomes a drag past dragThreshold. Below that
it stays a click, so a slightly shaky tap still fires the action. With the
button focused, arrow keys nudge it 8px per press (1px with Shift held),
which keeps repositioning reachable without a pointer.
The launcher floating on this very site is a draggable FloatingButton. Try
moving it. The contained demos above disable dragging because the drag math
works in viewport coordinates, which the demo panels deliberately break.
Set draggable={false} when the position is part of the design.
Controlled position#
Pass position with onPositionChange to own the position yourself, for
example to restore it across visits. onPositionCommit fires once when a
drag ends, which is the hook for persisting; onPositionChange fires every
frame, which is not.
const [position, setPosition] = useState<FloatingButtonPosition | null>(
() => readSavedPosition(), // null = rest at `placement`
);
<FloatingButton
position={position}
onPositionChange={setPosition}
onPositionCommit={savePosition}
aria-label="Open chat"
>
Chat
</FloatingButton>;A dragged position is clamped to the viewport (minus edgePadding) during
the drag and again on window resize, so a shrinking window can never strand
the button off-screen.
Props#
FloatingButton extends Button, so variant,
colorScheme, size, rounded, icons and loading states all apply:
everything except fullWidth, which makes no sense on a floating element.
| Prop | Type | Default | Description |
|---|---|---|---|
placement | "bottom-right" | "bottom-left" | "top-right" | "top-left" | "center-bottom" | "center-top" | "bottom-right" | Anchor the button rests at until it is dragged: a corner or an edge midpoint. |
offset | "sm" | "md" | "lg" | "md" | Distance from the viewport edges at rest. |
draggable | boolean | true | Allow repositioning by pointer drag and arrow keys. Disable for a button that must stay put. |
position | { x: number; y: number } | null | None | Controlled position in viewport pixels. Pass with onPositionChange to own the position yourself; omit to let the button track it internally. |
onPositionChange | (position: { x: number; y: number }) => void | None | Fires on every drag frame, and on keyboard repositioning. |
onPositionCommit | (position: { x: number; y: number }) => void | None | Fires once when a drag ends, the right hook for persisting a position. |
edgePadding | number | 8 | Clearance kept between the button and the viewport edges when clamping. |
dragThreshold | number | 4 | Pointer travel (px) before a press becomes a drag. Below it the gesture is still a click, so a shaky tap does not swallow the action. |
wrapperClassName | string | None | Class for the fixed wrapper element; className still targets the button itself. |
Accessibility#
- A real
<button>via Button, so keyboard and screen-reader semantics come for free. - Arrow-key repositioning works whenever the button has focus; Shift steps by 1px for fine placement.
- The click that browsers fire after a drag is swallowed, so repositioning never accidentally triggers the action.
- Icon-only usage requires an
aria-label; a development-mode warning fires when it's missing.