Floating Bar
A bar that floats at the bottom of the screen over a blurred band, holding a Nav or a few actions. Its inset, height and corner come from tokens and follow the size of what it holds.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/floating-bar.jsonUsage
import { FloatingBar, floatingBarReserve } from "@/components/ui/floating-bar"Examples
Default
Holds a Nav. The page puts floatingBarReserve() on its outer element, so its last content scrolls clear of the bar. Scroll the preview to see the band blur what passes under it.
Top
side="top" mirrors it: the inset reads the top safe area, the band fades downward, and the page reserves room at its top.
Actions for a selection
Any controls can go inside. Set size to the controls' size so the bar's height and corner follow them.
3 selected
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| sideFloatingBar | "top" | "bottom" | "bottom" | The screen edge the bar floats on. |
| sizeFloatingBar | "sm" | "default" | "lg" | "default" | The size of the controls inside. The bar's height and corner follow it. |
| classNameFloatingBar | string | - | Merged onto the bar's surface, not the fixed layer around it. |
| floatingBarReserveexport | ({ side?, size? }) => string | - | A padding class for the page under the bar, on the bar's side: the inset, the bar, one more gutter and the safe area. |
| sideFloatingBar"top" | "bottom" = "bottom"The screen edge the bar floats on. |
| sizeFloatingBar"sm" | "default" | "lg" = "default"The size of the controls inside. The bar's height and corner follow it. |
| classNameFloatingBarstringMerged onto the bar's surface, not the fixed layer around it. |
| floatingBarReserveexport({ side?, size? }) => stringA padding class for the page under the bar, on the bar's side: the inset, the bar, one more gutter and the safe area. |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
type FloatingBarSize = "sm" | "default" | "lg"
type FloatingBarSide = "top" | "bottom"
// Every number here is derived, so a retune of the tokens it reads moves the
// whole bar together:
//
// inset the chrome gutter (`--spacing-chrome-gutter`, the same one
// SiteHeader and Section pad with) plus the phone's safe area on the
// bar's side.
// height the child control's height plus this bar's `p-1` on both sides.
// Nav items are h-7 / h-8 / h-10, so the bar is 9 / 10 / 12 spacing
// steps. `sm` items grow to h-8 on a coarse pointer, and so does the
// bar.
// radius the child's own corner plus the `p-1`, so the two stay concentric
// (the radius ladder's "It runs the other way too"). Nav reads the
// action ramp per size, and this map follows it.
// band twice the inset plus the bar, so the blur is solid out to about the
// bar's far edge and fades over the same distance past it.
//
// All of them read the tokens as live `var()`s, never a named utility, which
// Tailwind would freeze at compile time.
const BAR_SIZE: Record<FloatingBarSize, string> = {
sm: "[--floating-bar-height:calc(var(--spacing)*9)] pointer-coarse:[--floating-bar-height:calc(var(--spacing)*10)]",
default: "[--floating-bar-height:calc(var(--spacing)*10)]",
lg: "[--floating-bar-height:calc(var(--spacing)*12)]",
}
const BAR_RADIUS: Record<FloatingBarSize, string> = {
sm: "rounded-[calc(var(--radius-action-sm)_+_var(--spacing))]",
default: "rounded-[calc(var(--radius-action)_+_var(--spacing))]",
lg: "rounded-[calc(var(--radius-action-lg)_+_var(--spacing))]",
}
// The two sides are mirrors: the inset, the safe area and the band all flip,
// and the band fades away from the screen edge it sits on (SiteHeader's scrim
// fades down; a bottom bar's fades up).
const SIDE: Record<FloatingBarSide, { root: string; band: string }> = {
bottom: {
root: "bottom-0 pb-[calc(var(--spacing-chrome-gutter)_+_env(safe-area-inset-bottom))]",
band: "bottom-0 h-[calc((var(--spacing-chrome-gutter)_+_var(--floating-bar-height))_*_2_+_env(safe-area-inset-bottom))] mask-t-from-50%",
},
top: {
root: "top-0 pt-[calc(var(--spacing-chrome-gutter)_+_env(safe-area-inset-top))]",
band: "top-0 h-[calc((var(--spacing-chrome-gutter)_+_var(--floating-bar-height))_*_2_+_env(safe-area-inset-top))] mask-b-from-50%",
},
}
// The inset, the bar, one more gutter, and the safe area, as padding on the
// bar's side of the page.
const RESERVE: Record<FloatingBarSide, Record<FloatingBarSize, string>> = {
bottom: {
sm: "pb-[calc(var(--spacing-chrome-gutter)_*_2_+_var(--spacing)_*_9_+_env(safe-area-inset-bottom))]",
default: "pb-[calc(var(--spacing-chrome-gutter)_*_2_+_var(--spacing)_*_10_+_env(safe-area-inset-bottom))]",
lg: "pb-[calc(var(--spacing-chrome-gutter)_*_2_+_var(--spacing)_*_12_+_env(safe-area-inset-bottom))]",
},
top: {
sm: "pt-[calc(var(--spacing-chrome-gutter)_*_2_+_var(--spacing)_*_9_+_env(safe-area-inset-top))]",
default: "pt-[calc(var(--spacing-chrome-gutter)_*_2_+_var(--spacing)_*_10_+_env(safe-area-inset-top))]",
lg: "pt-[calc(var(--spacing-chrome-gutter)_*_2_+_var(--spacing)_*_12_+_env(safe-area-inset-top))]",
},
}
/**
* Padding for a page under a FloatingBar, so its content can scroll clear of
* the bar. Put it on the page's own outer element, with the bar's side and
* size: `<main className={floatingBarReserve({ side: "top" })}>`. A
* full-screen page meant to sit under the bar leaves it off.
*/
function floatingBarReserve({
side = "bottom",
size = "default",
}: { side?: FloatingBarSide; size?: FloatingBarSize } = {}) {
return RESERVE[side][size]
}
// @use-when a bar that floats over the page at the bottom (or top) of the
// screen and stays put while the page scrolls: a row of page links on a
// phone-style app, a switcher between demos, a bulk-action bar for selected
// rows. It is only the floating surface: put a Nav inside it for links, which
// owns the items and the current page. Not SiteHeader, which is a site's
// full-width top bar, and not VideoControls, which floats inside a player.
function FloatingBar({
side = "bottom",
size = "default",
className,
children,
...props
}: React.ComponentProps<"div"> & {
/** The screen edge the bar floats on. */
side?: FloatingBarSide
/** The size of the controls inside it, so the bar's height and corner
* follow them. Match it to the child: `<Nav size="sm">` takes `sm`. */
size?: FloatingBarSize
}) {
return (
<div
data-slot="floating-bar"
data-side={side}
data-size={size}
className={cn(
// Page chrome, so `z-40`: below anything portaled to <body>, which is
// how a menu opened from the bar still lands on top of it.
"pointer-events-none fixed inset-x-0 z-40 flex justify-center px-(--spacing-chrome-gutter)",
SIDE[side].root,
BAR_SIZE[size]
)}
>
{/* The band blurs whatever scrolls under the bar, fading out away from
the screen edge: SiteHeader's scrim recipe. */}
<div
aria-hidden="true"
data-slot="floating-bar-band"
className={cn(
"absolute inset-x-0 -z-10 bg-background-translucent backdrop-blur-md reduce-transparency:bg-background reduce-transparency:backdrop-blur-none",
SIDE[side].band
)}
/>
<div
data-slot="floating-bar-surface"
className={cn(
"pointer-events-auto max-w-full overflow-x-auto p-1 shadow-ring-lg",
"bg-background-translucent backdrop-blur-md reduce-transparency:bg-background reduce-transparency:backdrop-blur-none",
BAR_RADIUS[size],
className
)}
{...props}
>
{children}
</div>
</div>
)
}
export { FloatingBar, floatingBarReserve }