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