Floating action
One action held in a bottom corner of the viewport, such as Talk to sales or Back to top. It can wait for a scroll threshold, closes to its icon on phones, and respects safe-area insets.
cmp_floating_action_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_floating_action_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-315f0ba6363ae53b0ddd0f6ff18343dfb825112c5a6e423789c99ae1b4db8676
<!--
One action held in a bottom corner of the viewport: Talk to sales, Back to top, Book a visit.
It is a single elevated pill whose leading icon sits in a disc concentric with it, so on a
phone the pill closes to exactly that disc's circle without its corner moving. It can wait
until the page has scrolled past a threshold, and while hidden it is inert and invisible, so
it is neither tabbable nor in the accessibility tree. A same-page href (#top) scrolls there,
smoothly unless reduced motion is set, and moves focus to the target.
-->
<script lang="ts" module>
export type FloatingActionPosition = 'bottom-end' | 'bottom-start';
export type FloatingActionCollapse = 'sm' | 'md' | 'never';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLButtonAttributes & HTMLAnchorAttributes,
'href' | 'type' | 'children' | 'onclick' | 'onfocusin' | 'onfocusout' | 'class' | 'inert'
>;
interface Props extends Passthrough {
/** The action, shown beside the icon. It stays the accessible name when collapsed. */
label: string;
/** Renders a link. A same-page fragment (#top, #pricing) scrolls there and moves focus. */
href?: string;
/** Click handler; for a link it runs before the fragment scroll and can cancel it. */
onclick?: (event: MouseEvent) => void;
/** Leading icon, decorative, sized to 20 px. Without one the control never collapses. */
icon?: Snippet;
/** Corner, in logical terms: bottom-end is bottom right in LTR and bottom left in RTL. */
position?: FloatingActionPosition;
/** Pixels scrolled before the control appears; 0 shows it from the start. */
showAfter?: number;
/** Below this breakpoint (640 or 768 px) only the icon shows. Needs an icon. */
collapseBelow?: FloatingActionCollapse;
/** CSS length added to the bottom offset, to clear a cookie bar or a bottom toolbar. */
offset?: string;
/** The element that scrolls, when it is not the window (an app shell's main pane). */
scrollContainer?: HTMLElement | null;
/** Extra classes, for example a z-index that suits your page. */
class?: string;
}
let {
label,
href,
onclick,
icon,
position = 'bottom-end',
showAfter = 0,
collapseBelow = 'sm',
offset,
scrollContainer = null,
class: className,
...rest
}: Props = $props();
/* Complete class strings per breakpoint, so Tailwind finds every one of them. */
const COLLAPSE = {
sm: { label: 'max-sm:sr-only', pill: 'max-sm:pe-1' },
md: { label: 'max-md:sr-only', pill: 'max-md:pe-1' },
never: { label: '', pill: '' }
} as const;
const collapse = $derived(icon ? (COLLAPSE[collapseBelow] ?? COLLAPSE.sm) : COLLAPSE.never);
const threshold = $derived(Math.max(0, Number(showAfter) || 0));
let past = $state(false);
/* A focused control never hides under the keyboard user, even if the page scrolls back. */
let focused = $state(false);
const visible = $derived(threshold === 0 || past || focused);
/* Switching between link and button replaces the element, and the old one never reports
losing focus, so the latch is cleared whenever the element changes. */
const isLink = $derived(href !== undefined);
$effect(() => {
void isLink;
focused = false;
});
$effect(() => {
if (threshold === 0) return;
const source: HTMLElement | Window = scrollContainer ?? window;
let frame = 0;
const read = () => {
frame = 0;
past = (scrollContainer ? scrollContainer.scrollTop : window.scrollY) > threshold;
};
/* Passive and read at most once a frame: scroll events fire far faster than paint. */
const schedule = () => {
if (!frame) frame = requestAnimationFrame(read);
};
read();
source.addEventListener('scroll', schedule, { passive: true });
return () => {
source.removeEventListener('scroll', schedule);
if (frame) cancelAnimationFrame(frame);
};
});
/* As the browser resolves a fragment: the literal id first, then the percent-decoded one. */
function byDecodedId(id: string) {
try {
const decoded = decodeURIComponent(id);
return decoded === id ? null : document.getElementById(decoded);
} catch {
return null;
}
}
function handleClick(event: MouseEvent) {
onclick?.(event);
if (event.defaultPrevented || !href?.startsWith('#')) return;
// A modified or middle click keeps the browser's own behaviour (new tab, copy link), and
// so does a link that opens elsewhere or downloads.
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
return;
if ((rest.target && rest.target !== '_self') || rest.download != null) return;
const id = href.slice(1);
const target = id ? (document.getElementById(id) ?? byDecodedId(id)) : null;
// #top and # mean the top of the page; any other id that is missing is left to the browser.
if (!target && id !== '' && id !== 'top') return;
event.preventDefault();
const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth';
if (target) target.scrollIntoView({ behavior, block: 'start' });
else (scrollContainer ?? window).scrollTo({ top: 0, behavior });
// Focus follows the scroll, so the next Tab starts from the target and not the corner.
const destination = target ?? scrollContainer ?? document.body;
if (destination.tabIndex < 0 && !destination.hasAttribute('tabindex'))
destination.setAttribute('tabindex', '-1');
destination.focus({ preventScroll: true });
}
</script>
<svelte:element
this={isLink ? 'a' : 'button'}
{...rest}
type={isLink ? undefined : 'button'}
{href}
inert={visible ? undefined : true}
data-state={visible ? 'visible' : 'hidden'}
data-position={position === 'bottom-start' ? 'bottom-start' : 'bottom-end'}
style:--floating-action-offset={offset}
class={[
'floating-action fixed bottom-(--_bottom) z-40 inline-flex h-12 max-w-(--_max) cursor-pointer items-center gap-3 rounded-full no-underline select-none [--_gap:1rem] sm:[--_gap:1.5rem]',
'bg-(--_fill) text-(--_on-accent) shadow-(--_shadow)',
'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
position === 'bottom-start' ? 'start-(--_inline)' : 'end-(--_inline)',
icon ? 'ps-1 pe-5' : 'px-5',
collapse.pill,
className
]}
onclick={handleClick}
onfocusin={() => (focused = true)}
onfocusout={() => (focused = false)}
>
{#if icon}
<span
class="floating-action__icon grid size-10 shrink-0 place-items-center rounded-full bg-(--_on-accent)/12"
aria-hidden="true"
>
{@render icon()}
</span>
{/if}
<span
class={[
'min-w-0 truncate text-base leading-6 font-medium tracking-[-0.011em] rtl:tracking-normal',
collapse.label
]}>{label}</span
>
</svelte:element>
<style>
/* Public tokens: set --floating-action-* on the control or any ancestor (often :root). */
.floating-action {
--_accent: var(--floating-action-accent, #1d4ed8);
--_on-accent: var(--floating-action-on-accent, #ffffff);
--_hairline: var(--floating-action-hairline, rgb(0 0 0 / 0.08));
--_offset: var(--floating-action-offset, 0px);
/* Formulas (the gutter, --_gap, is 16 px and 24 px from sm, set in the markup): the corner clears the home indicator and a landscape notch, and the pill never
runs wider than the screen less both gutters. */
--_bottom: calc(var(--_gap) + var(--_offset) + env(safe-area-inset-bottom, 0px));
--_inline: calc(
var(--_gap) + max(env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))
);
--_max: calc(100vw - 2 * var(--_inline));
--_fill: var(--_accent);
--_fill-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
/* The one elevation in the component, lit from above, with a hairline so a pale accent
still has an edge on a pale page and a top highlight that reads on a dark fill. */
--_shadow:
inset 0 1px 0 rgb(255 255 255 / 0.12), 0 0 0 1px var(--_hairline),
0 2px 4px -1px rgb(0 0 0 / 0.1), 0 8px 24px -4px rgb(0 0 0 / 0.16);
--_enter: cubic-bezier(0.16, 1, 0.3, 1);
--_exit: cubic-bezier(0.4, 0, 1, 1);
--_ease: cubic-bezier(0.2, 0, 0, 1);
}
/*
* Every state's motion lives here, because each property needs its own duration (hover 150,
* press 80, appear 220, leave 160 ms), which a single transition utility cannot say. The
* hidden state keeps visibility in the list so it stays painted while it fades out.
*/
.floating-action[data-state] {
transition:
background-color 150ms var(--_ease),
scale 80ms var(--_ease),
opacity 220ms var(--_enter),
translate 220ms var(--_enter),
visibility 220ms;
}
.floating-action[data-state='hidden'] {
opacity: 0;
translate: 0 0.5rem;
visibility: hidden;
transition:
opacity 160ms var(--_exit),
translate 160ms var(--_exit),
visibility 160ms;
}
@media (hover: hover) {
.floating-action:hover {
--_fill: var(--_fill-hover);
}
}
.floating-action:active {
scale: 0.98;
}
/* Reduced motion: no fade, no slide, no press; the hover tone still changes. */
@media (prefers-reduced-motion: reduce) {
.floating-action[data-state] {
translate: none;
transition: background-color 150ms var(--_ease);
}
.floating-action:active {
scale: none;
}
}
/* The icon is consumer markup: size its svg without beating a class the consumer sets on it. */
@layer components {
.floating-action__icon :global(:where(svg)) {
width: 1.25rem;
height: 1.25rem;
}
}
</style>
Usage#
On this pageGive it a label and either an href or an onclick, plus an icon snippet, and render it near the end of the page or layout it belongs to. It stays fixed to one bottom corner; with showAfter it waits until the page has scrolled that far and hides again above it. It does not open a chat, form or dialog itself (it triggers your action), does not detect or avoid other fixed elements such as cookie banners or footers (set offset for those), and does not change the URL hash when it scrolls to a same-page target.
- Suggested location
src/lib/components/floating-action-01- Required props
label
Limitations
- With
showAfterabove 0 the server renders it hidden, so it appears only once the page has hydrated. WithshowAfter0 a link works without script (a same-page link then jumps instead of scrolling smoothly); a button's onclick action needs hydration, like any callback. - It does not measure other fixed elements. Lift it over a cookie bar or bottom toolbar with offset (or
--floating-action-offset), and set a z-index through class if your page stacks other layers abovez-40. - A same-page link scrolls and moves focus itself, so the address bar's hash does not change. For #top with no element of that id, focus moves to the scroll container or the body. A link with target (other than _self) or download, or a modified or middle click, keeps the browser's own behaviour.
- Collapsing needs an icon; without one the label always shows. A long label is cut with an ellipsis at the screen's gutters rather than wrapping.
- Fixed positioning is relative to the viewport unless an ancestor has a transform, filter or contain, which makes that ancestor the containing block.
Example
<script lang="ts">
import FloatingAction from '$lib/components/floating-action-01/FloatingAction.svelte';
</script>
{#snippet arrowUp()}
<svg viewBox="0 0 20 20" fill="none">
<path
d="M10 15.75V4.75M5.25 9.5 10 4.75l4.75 4.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/snippet}
<main>
<h1 id="top">Changelog</h1>
<!-- … the long page … -->
</main>
<FloatingAction label="Back to top" href="#top" icon={arrowUp} showAfter={400} />The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Floating action#
One action held in a bottom corner of the viewport. Use it for the one thing a long page should always offer: Talk to sales, Book a check-up, Back to top. One per page.
Back to top#
<h1 id="top">Changelog</h1>
<!-- … -->
<FloatingAction label="Back to top" href="#top" icon={arrowUp} showAfter={400} />It stays hidden, inert and out of the tab order until the page has scrolled 400 px, and hides
again above that. A click scrolls to the element with id="top" (smoothly, unless reduced
motion is set) and moves focus to it, adding tabindex="-1" if it is not focusable. With no
such element, #top scrolls to the top and focuses the scroll container or the body. The URL
hash does not change.
Button or link#
With href it renders a link; without one it is a <button type="button"> that calls
onclick. It never opens a chat, form or dialog itself: point onclick at yours, and pass
aria-haspopup="dialog" or aria-expanded through when it opens one.
Staying clear of other fixed things#
It does not measure the page. Lift it over a cookie bar or bottom toolbar with offset, or from
CSS while the bar is showing:
body:has(.cookie-bar) {
--floating-action-offset: 5.5rem;
}Give focused elements room underneath it too (WCAG 2.4.11), for example
html { scroll-padding-bottom: 6rem; }. Set a z-index through class if your page stacks
other layers above z-40.
In an app pane#
When the page scrolls inside an element rather than the window, pass it as scrollContainer
so showAfter and #top follow that element.
Retoning#
.dark-page {
--floating-action-accent: #fafafa;
--floating-action-on-accent: #09090b;
--floating-action-hairline: rgb(255 255 255 / 0.12);
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | The action, shown beside the icon ("Talk to sales", "Back to top"). It stays the accessible name while collapsed, as visually hidden text. |
href | string | No | None | Renders a link. A same-page fragment (#top, #pricing) scrolls to its target, smoothly unless reduced motion is set, and moves focus there; #top with no matching id means the top of the page. |
onclick | (event: MouseEvent) => void | No | None | Click handler. With no href the control is a button and this is its action; on a link it runs first and can call preventDefault to stop the scroll. |
icon | Snippet | No | None | Leading icon, decorative, sized to 20 px inside a 40 px disc. Without one the control never collapses. |
position | 'bottom-end' | 'bottom-start' | No | 'bottom-end' | Corner, in logical terms: bottom-end is bottom right in a left-to-right page and bottom left in a right-to-left one. |
showAfter | number | No | 0 | Pixels scrolled before the control appears; it hides again above that. 0 shows it from the start. While hidden it is inert and invisible. |
collapseBelow | 'sm' | 'md' | 'never' | No | 'sm' | Below 640 px (sm) or 768 px (md) only the icon shows, as a 48 px circle. Ignored without an icon. |
offset | string | No | None | CSS length added to the bottom offset, on top of the 16 or 24 px gutter and the safe-area inset, for example '5.5rem' to clear a cookie bar. |
scrollContainer | HTMLElement | null | No | null | The element whose scroll drives showAfter and #top, when the page scrolls inside an app pane rather than the window. |
class | string | No | None | Extra classes, for example a z-index that suits your page's stacking. |
Customization#
On this pageChange the label, icon, corner and behaviour through props, and retone it through three --floating-action-* colour variables. --floating-action-offset lifts it from CSS, for example while a cookie bar is open.
- Accent:
--floating-action-accentfills the pill and colours the focus ring;--floating-action-on-accentis the label, the icon and the disc behind it (12% of it over the fill). Keep the pair above 4.5:1; hover mixes 12% of the label colour into the fill. - Edge:
--floating-action-hairlineis a one-pixel ring outside the fill. Raise it when the accent is close to the page colour, such as a white pill on a white page. - Worked retone for a dark page (
#09090b):--floating-action-accent:#fafafa;--floating-action-on-accent:#09090b;--floating-action-hairline: rgb(255 255 255 / 0.12). The focus ring follows the accent, so it stays visible on the dark page. - Offset: pass offset (or set
--floating-action-offseton an ancestor) to the height of anything fixed along the bottom edge. For example, body:has(.cookie-bar) {--floating-action-offset: 5.5rem; }. - Corner and stacking: position picks bottom-end or bottom-start; pass class="
z-50" (or lower) to place it in your page's stacking order. It sits 16 px from the edges on phones and 24 px from 640 px. - Behaviour:
showAfter={400} for Back to top on long pages;collapseBelow="never" when the label matters more than the space;scrollContainerwhen an app pane scrolls instead of the window. - Focus: leave room so it never hides a focused element (WCAG 2.4.11), for example html { scroll-padding-bottom: 6rem; }.
Public CSS variables
| Variable | Token |
|---|---|
--floating-action-accent | accent |
--floating-action-on-accent | onAccent |
--floating-action-hairline | hairline |
--floating-action-offset | offset |
Accessibility#
On this page- Renders a native <button type="button">, or an <a> when href is set, so Enter (and Space on the button) work with no extra handlers.
- Place it in the DOM after the content it relates to, near the end of the page or layout, not first: keyboard and screen reader users reach it in reading order and are never interrupted by it.
- Collapsed, the label is visually hidden text rather than an
aria-label, so the accessible name is the full label and matches what sighted users see at wider widths. Anaria-labeloraria-labelledbyyou pass through replaces it, so keep the visible words at its start (WCAG 2.5.3). - While hidden (before
showAfter) it carries inert and visibility: hidden, so it is not focusable and has no presence in the accessibility tree. It does not hide while it has focus. - It never traps or moves focus on its own. A same-page link moves focus to its target (adding tabindex="-1" when needed), so the next Tab continues from there.
- Under prefers-reduced-motion the appear and leave fade, the slide, the press scale and smooth scrolling are all off.
- The consumer must make sure it never covers a focused element (WCAG 2.4.11 Focus Not Obscured) or essential content such as a cookie banner's buttons: use offset and scroll-padding-bottom.
- The control is 48 px tall at every width and the collapsed circle is 48 by 48 px. The focus ring is two pixels of the accent, two pixels outside the pill, measured against the page.
Known limitations
- No automatic avoidance of footers, chat widgets or banners; position it with offset.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.