Skip to content
Download ZIP

Blue accent palette · 7.4 KB ZIP File receipt View as Markdown View code

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.
cmp_floating_action_01 · version 1.0.0 · Blue accent palette
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
FloatingAction.svelte Svelte · 9.2 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/floating-action-01
Required props
label

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.

Example

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

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#

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneThe action, shown beside the icon ("Talk to sales", "Back to top"). It stays the accessible name while collapsed, as visually hidden text.
hrefstringNoNoneRenders 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) => voidNoNoneClick 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.
iconSnippetNoNoneLeading 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.
showAfternumberNo0Pixels 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.
offsetstringNoNoneCSS 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.
scrollContainerHTMLElement | nullNonullThe element whose scroll drives showAfter and #top, when the page scrolls inside an app pane rather than the window.
classstringNoNoneExtra classes, for example a z-index that suits your page's stacking.

Customization#

On this page

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; }.

Public CSS variables

VariableToken
--floating-action-accentaccent
--floating-action-on-accentonAccent
--floating-action-hairlinehairline
--floating-action-offsetoffset

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

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.