From 87e0dfc262947b5036fe320d80fee86cba6c18fa Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza Date: Fri, 11 Sep 2026 16:30:02 -0300 Subject: [PATCH 1/4] Add back-to-top button to blog posts Adds BackToTopButton, a floating button shown once the reader scrolls past the hero, that smooth-scrolls back to the top of the page on click. Reuses the site's existing in-content scroll-spy signal, so no new scroll-tracking JS was needed. --- .../common/client/back_to_top_button.dart | 31 +++++++++++++++++ sites/www/lib/main.client.options.dart | 6 ++++ sites/www/lib/main.server.options.dart | 6 ++++ sites/www/lib/src/layouts/blog_layout.dart | 2 ++ sites/www/lib/styles/pages/_blog_page.scss | 33 +++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 packages/site_shared/lib/components/common/client/back_to_top_button.dart 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..90ba021a2a --- /dev/null +++ b/packages/site_shared/lib/components/common/client/back_to_top_button.dart @@ -0,0 +1,31 @@ +// 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( + classes: 'back-to-top', + attributes: {'aria-label': 'Back to top'}, + events: { + 'click': (_) { + web.window.scrollTo(web.ScrollToOptions(behavior: '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..aaeee86d40 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -106,3 +106,36 @@ 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); + } +} + +body.blog.in-content .back-to-top { + opacity: 1; + transform: none; + pointer-events: auto; +} From 837912162738f5a7840fe8cebcdba62044fd6ca6 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza Date: Fri, 11 Sep 2026 17:13:13 -0300 Subject: [PATCH 2/4] Respect prefers-reduced-motion and use explicit button type - Fall back to instant scrolling when the user has requested reduced motion, instead of always smooth-scrolling. - Disable the button's slide-up transform/transition under prefers-reduced-motion: reduce. - Set the button's type explicitly to 'button', so it can't accidentally submit a form if ever nested inside one. --- .../components/common/client/back_to_top_button.dart | 11 ++++++++++- sites/www/lib/styles/pages/_blog_page.scss | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 index 90ba021a2a..0c0a276c90 100644 --- 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 @@ -19,11 +19,20 @@ final class BackToTopButton extends StatelessComponent { @override Component build(BuildContext _) => button( + type: ButtonType.button, classes: 'back-to-top', attributes: {'aria-label': 'Back to top'}, events: { 'click': (_) { - web.window.scrollTo(web.ScrollToOptions(behavior: 'smooth', top: 0)); + 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/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss index aaeee86d40..2e42fd67aa 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -132,6 +132,11 @@ &:hover { color: var(--site-base-fgColor); } + + @media (prefers-reduced-motion: reduce) { + transform: none; + transition: none; + } } body.blog.in-content .back-to-top { From 05b9a1b93de8605a48f2be877cb0459d113e4af0 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza Date: Tue, 15 Sep 2026 00:38:13 -0300 Subject: [PATCH 3/4] Improve back-to-top accessibility - Hide the back-to-top button from the accessibility tree (visibility: hidden) until it becomes visible, instead of relying on opacity/ pointer-events alone. - Move focus to the page title on click so keyboard users don't lose focus after scrolling to the top. --- .../lib/components/common/client/back_to_top_button.dart | 2 ++ sites/www/lib/src/layouts/blog_layout.dart | 2 +- sites/www/lib/styles/pages/_blog_page.scss | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) 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 index 0c0a276c90..ed4fb8a911 100644 --- 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 @@ -24,6 +24,8 @@ final class BackToTopButton extends StatelessComponent { attributes: {'aria-label': 'Back to top'}, events: { 'click': (_) { + (web.document.getElementById('document-title') as web.HTMLElement?) + ?.focus(web.FocusOptions(preventScroll: true)); final prefersReducedMotion = web.window .matchMedia('(prefers-reduced-motion: reduce)') .matches; diff --git a/sites/www/lib/src/layouts/blog_layout.dart b/sites/www/lib/src/layouts/blog_layout.dart index 2d0c93beaa..c97b45b0a8 100644 --- a/sites/www/lib/src/layouts/blog_layout.dart +++ b/sites/www/lib/src/layouts/blog_layout.dart @@ -80,7 +80,7 @@ class BlogLayout extends DefaultLayout { ), ], ), - h1(id: 'document-title', [ + h1(id: 'document-title', attributes: const {'tabindex': '-1'}, [ if (pageData['underscore_breaker_titles'] == true) ...splitByUnderscore(pageTitle) else diff --git a/sites/www/lib/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss index 2e42fd67aa..71da08a2c1 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -128,6 +128,7 @@ transform: translateY(0.5rem); transition: opacity 0.2s var(--ui-anim-func), transform 0.2s var(--ui-anim-func); pointer-events: none; + visibility: hidden; &:hover { color: var(--site-base-fgColor); @@ -143,4 +144,5 @@ body.blog.in-content .back-to-top { opacity: 1; transform: none; pointer-events: auto; + visibility: visible; } From 5b67b85a3dbd5e44e7d67d6553ab9fe65cc72b5e Mon Sep 17 00:00:00 2001 From: Parker Lougheed Date: Wed, 16 Sep 2026 15:44:30 +0200 Subject: [PATCH 4/4] Adjust comment and reformat --- .../common/client/back_to_top_button.dart | 10 +++++----- sites/www/lib/src/layouts/blog_layout.dart | 16 ++++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) 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 index ed4fb8a911..5c7dd87020 100644 --- 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 @@ -10,16 +10,16 @@ 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. +/// Stays hidden until the page has been scrolled through 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, + type: .button, classes: 'back-to-top', attributes: {'aria-label': 'Back to top'}, events: { @@ -37,6 +37,6 @@ final class BackToTopButton extends StatelessComponent { ); }, }, - [const MaterialIcon('arrow_upward')], + const [MaterialIcon('arrow_upward')], ); } diff --git a/sites/www/lib/src/layouts/blog_layout.dart b/sites/www/lib/src/layouts/blog_layout.dart index c97b45b0a8..394a613907 100644 --- a/sites/www/lib/src/layouts/blog_layout.dart +++ b/sites/www/lib/src/layouts/blog_layout.dart @@ -80,12 +80,16 @@ class BlogLayout extends DefaultLayout { ), ], ), - h1(id: 'document-title', attributes: const {'tabindex': '-1'}, [ - if (pageData['underscore_breaker_titles'] == true) - ...splitByUnderscore(pageTitle) - else - .text(pageTitle), - ]), + h1( + id: 'document-title', + attributes: const {'tabindex': '-1'}, + [ + if (pageData['underscore_breaker_titles'] == true) + ...splitByUnderscore(pageTitle) + else + .text(pageTitle), + ], + ), if (pageData['description'] != null) p(classes: 'blog-subtitle', [ .text(pageData['description'] as String),