# 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.

- ID: `cmp_floating_action_01`
- Slug: `floating-action-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T01:57:26Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/floating-action-01
- Preview: https://pagesugar.com/preview/floating-action-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-5d07433e755dd2ddb400966ef5c6cb1ee6ac0afe57ad6102c6a74a564d536da1` |
| `blue` | Blue accent | no | `sha256-315f0ba6363ae53b0ddd0f6ff18343dfb825112c5a6e423789c99ae1b4db8676` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/floating-action-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Give 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.

Required props: `label`

```svelte
<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} />
```

Limitations:

- With showAfter above 0 the server renders it hidden, so it appears only once the page has hydrated. With showAfter 0 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 above z-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.

## Usage guide

### 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

```svelte
<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:

```css
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

```css
.dark-page {
	--floating-action-accent: #fafafa;
	--floating-action-on-accent: #09090b;
	--floating-action-hairline: rgb(255 255 255 / 0.12);
}
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | 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 |  | 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 |  | 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 |  | 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 |  | 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 |  | Extra classes, for example a z-index that suits your page's stacking. |

## Customization

Change 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-accent fills the pill and colours the focus ring; --floating-action-on-accent is 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-hairline is 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-offset on 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; scrollContainer when 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; }.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--floating-action-accent` |
| `onAccent` | `--floating-action-on-accent` |
| `hairline` | `--floating-action-hairline` |
| `offset` | `--floating-action-offset` |

## Accessibility

- 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. An aria-label or aria-labelledby you 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `FloatingAction.svelte`
- Suggested directory: `src/lib/components/floating-action-01`
- Files: 1
- Artifact digest: `sha256-5d07433e755dd2ddb400966ef5c6cb1ee6ac0afe57ad6102c6a74a564d536da1`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `FloatingAction.svelte`

Role: entry · 9457 bytes · SHA-256 `59fb9d611547307c24b8929b72f740cc712c142e9589a7df34814ca33c006ab8`

```svelte
<!--
	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, #18181b);
		--_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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-5d07433e755dd2ddb400966ef5c6cb1ee6ac0afe57ad6102c6a74a564d536da1`
- Entry: `FloatingAction.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_floating_action_01/1.0.0/neutral/sha256-5d07433e755dd2ddb400966ef5c6cb1ee6ac0afe57ad6102c6a74a564d536da1/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_floating_action_01/1.0.0/neutral/sha256-5d07433e755dd2ddb400966ef5c6cb1ee6ac0afe57ad6102c6a74a564d536da1/bundle.zip (7562 bytes, sha256 `509c610d97576bf4ae462112397670fadd249fa76712b74925d465fee36531f3`)

Files:

- `FloatingAction.svelte` (entry, 9457 bytes): https://pagesugar.com/artifacts/cmp_floating_action_01/1.0.0/neutral/sha256-5d07433e755dd2ddb400966ef5c6cb1ee6ac0afe57ad6102c6a74a564d536da1/source/FloatingAction.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-315f0ba6363ae53b0ddd0f6ff18343dfb825112c5a6e423789c99ae1b4db8676`
- Entry: `FloatingAction.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_floating_action_01/1.0.0/blue/sha256-315f0ba6363ae53b0ddd0f6ff18343dfb825112c5a6e423789c99ae1b4db8676/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_floating_action_01/1.0.0/blue/sha256-315f0ba6363ae53b0ddd0f6ff18343dfb825112c5a6e423789c99ae1b4db8676/bundle.zip (7564 bytes, sha256 `e9326805e1695c870b705d0e8f178f53b4463ddcd1a6413a3836dc2845c3c91c`)

Files:

- `FloatingAction.svelte` (entry, 9457 bytes): https://pagesugar.com/artifacts/cmp_floating_action_01/1.0.0/blue/sha256-315f0ba6363ae53b0ddd0f6ff18343dfb825112c5a6e423789c99ae1b4db8676/source/FloatingAction.svelte
