diff --git a/packages/site_shared/lib/components/common/client/back_to_top_button.dart b/packages/site_shared/lib/components/common/client/back_to_top_button.dart
new file mode 100644
index 0000000000..0c0a276c90
--- /dev/null
+++ b/packages/site_shared/lib/components/common/client/back_to_top_button.dart
@@ -0,0 +1,40 @@
+// Copyright 2026 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/web.dart' as web;
+
+import '../material_icon.dart';
+
+/// A floating button that scrolls the page back to the top when clicked.
+///
+/// Stays hidden until the page has been scrolled, via CSS rules that key
+/// off of the `in-content` class already toggled on `
` by the site's
+/// scroll-spy logic.
+@client
+final class BackToTopButton extends StatelessComponent {
+ const BackToTopButton({super.key});
+
+ @override
+ Component build(BuildContext _) => button(
+ type: ButtonType.button,
+ classes: 'back-to-top',
+ attributes: {'aria-label': 'Back to top'},
+ events: {
+ 'click': (_) {
+ final prefersReducedMotion = web.window
+ .matchMedia('(prefers-reduced-motion: reduce)')
+ .matches;
+ web.window.scrollTo(
+ web.ScrollToOptions(
+ behavior: prefersReducedMotion ? 'auto' : 'smooth',
+ top: 0,
+ ),
+ );
+ },
+ },
+ [const MaterialIcon('arrow_upward')],
+ );
+}
diff --git a/sites/www/lib/main.client.options.dart b/sites/www/lib/main.client.options.dart
index 6fdfce6fc9..1f8f128ec1 100644
--- a/sites/www/lib/main.client.options.dart
+++ b/sites/www/lib/main.client.options.dart
@@ -40,6 +40,8 @@ import 'package:site_shared/components/blog/client/blog_categories.dart'
deferred as _blog_categories;
import 'package:site_shared/components/blog/client/share_button.dart'
deferred as _share_button;
+import 'package:site_shared/components/common/client/back_to_top_button.dart'
+ deferred as _back_to_top_button;
import 'package:site_shared/components/common/client/collapse_button.dart'
deferred as _collapse_button;
import 'package:site_shared/components/common/client/copy_button.dart'
@@ -206,6 +208,10 @@ ClientOptions get defaultClientOptions => ClientOptions(
),
loader: _share_button.loadLibrary,
),
+ 'site_shared:back_to_top_button': ClientLoader(
+ (p) => _back_to_top_button.BackToTopButton(),
+ loader: _back_to_top_button.loadLibrary,
+ ),
'site_shared:collapse_button': ClientLoader(
(p) => _collapse_button.CollapseButton(
classes: (p['classes'] as List).cast(),
diff --git a/sites/www/lib/main.server.options.dart b/sites/www/lib/main.server.options.dart
index c68f3a7351..3a2002b0ab 100644
--- a/sites/www/lib/main.server.options.dart
+++ b/sites/www/lib/main.server.options.dart
@@ -36,6 +36,8 @@ import 'package:site_shared/components/blog/client/blog_categories.dart'
as _blog_categories;
import 'package:site_shared/components/blog/client/share_button.dart'
as _share_button;
+import 'package:site_shared/components/common/client/back_to_top_button.dart'
+ as _back_to_top_button;
import 'package:site_shared/components/common/client/collapse_button.dart'
as _collapse_button;
import 'package:site_shared/components/common/client/copy_button.dart'
@@ -130,6 +132,10 @@ ServerOptions get defaultServerOptions => ServerOptions(
'site_shared:share_button',
params: __share_buttonShareButton,
),
+ _back_to_top_button.BackToTopButton:
+ ClientTarget<_back_to_top_button.BackToTopButton>(
+ 'site_shared:back_to_top_button',
+ ),
_collapse_button.CollapseButton:
ClientTarget<_collapse_button.CollapseButton>(
'site_shared:collapse_button',
diff --git a/sites/www/lib/src/layouts/blog_layout.dart b/sites/www/lib/src/layouts/blog_layout.dart
index 26e051b561..2d0c93beaa 100644
--- a/sites/www/lib/src/layouts/blog_layout.dart
+++ b/sites/www/lib/src/layouts/blog_layout.dart
@@ -11,6 +11,7 @@ import 'package:site_shared/blog.dart';
import 'package:site_shared/components/blog/blog_next_posts.dart';
import 'package:site_shared/components/blog/post_info.dart';
import 'package:site_shared/components/common/breadcrumbs.dart';
+import 'package:site_shared/components/common/client/back_to_top_button.dart';
import 'package:site_shared/util.dart';
import '../utils/scroll_spy.dart';
@@ -97,6 +98,7 @@ class BlogLayout extends DefaultLayout {
],
),
]),
+ if (isPost) const BackToTopButton(),
]),
);
}
diff --git a/sites/www/lib/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss
index 90117f297a..2e42fd67aa 100644
--- a/sites/www/lib/styles/pages/_blog_page.scss
+++ b/sites/www/lib/styles/pages/_blog_page.scss
@@ -106,3 +106,41 @@
letter-spacing: normal;
}
}
+
+.blog .back-to-top {
+ position: fixed;
+ right: 1.5rem;
+ bottom: 1.5rem;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3rem;
+ height: 3rem;
+ padding: 0;
+ color: var(--site-base-fgColor-alt);
+ background: var(--site-base-bgColor);
+ border: 1px solid var(--site-outline-variant);
+ border-radius: 50%;
+ box-shadow: var(--ui-drop-shadow-sm);
+ cursor: pointer;
+ opacity: 0;
+ transform: translateY(0.5rem);
+ transition: opacity 0.2s var(--ui-anim-func), transform 0.2s var(--ui-anim-func);
+ pointer-events: none;
+
+ &:hover {
+ color: var(--site-base-fgColor);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transform: none;
+ transition: none;
+ }
+}
+
+body.blog.in-content .back-to-top {
+ opacity: 1;
+ transform: none;
+ pointer-events: auto;
+}