I’ll build this as a powerful reusable scroll-top.php component for your RX theme, including accessibility, progress ring, hide/show logic, smooth scroll, keyboard support, reduced-motion support, admin-bar offset, and safe WordPress escaping.
Below is a complete advanced template-parts/components/scroll-top.php file for your RX theme.
Create this file:
rx-theme/template-parts/components/scroll-top.php
Then paste this full code inside it.
<?php
/**
* Template Part: Scroll To Top Component
*
* File: template-parts/components/scroll-top.php
*
* Features:
* - Accessible scroll-to-top button
* - Circular scroll progress indicator
* - Smooth scroll support
* - Reduced-motion support
* - Keyboard accessible
* - Touch friendly
* - Mobile responsive
* - Admin bar safe
* - RTL safe
* - No jQuery dependency
* - WordPress filter support
* - CSS variables for easy theme customization
* - Noscript fallback
* - Optional percentage text
* - Optional bottom progress bar
* - Optional auto-hide near top
* - Optional hide on print
* - Optional pulse animation
* - Optional shadow/glass style
* - Safe escaping
*
* Usage in footer.php:
*
* get_template_part( 'template-parts/components/scroll-top' );
*
* @package RX_Theme
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Allow child theme/plugin customization.
*/
$rx_scroll_top_settings = apply_filters(
'rx_theme_scroll_top_settings',
array(
'enabled' => true,
'show_after_px' => 300,
'scroll_duration' => 650,
'button_size' => 52,
'button_size_mobile' => 46,
'bottom_offset' => 28,
'right_offset' => 24,
'mobile_bottom_offset' => 20,
'mobile_right_offset' => 18,
'z_index' => 9999,
'show_progress_ring' => true,
'show_percentage_text' => false,
'show_bottom_progress' => true,
'auto_hide' => true,
'hide_on_home_top' => false,
'hide_on_print' => true,
'respect_reduced_motion' => true,
'enable_keyboard' => true,
'enable_escape_blur' => true,
'enable_pulse' => true,
'enable_glass_effect' => true,
'enable_shadow' => true,
'aria_label' => __( 'Scroll back to top', 'rx-theme' ),
'title' => __( 'Back to top', 'rx-theme' ),
'icon_type' => 'arrow', // arrow, chevron, rocket.
'position' => is_rtl() ? 'left' : 'right',
'primary_color' => '#0f766e',
'primary_hover_color' => '#115e59',
'text_color' => '#ffffff',
'background_color' => '#0f766e',
'background_hover_color' => '#115e59',
'ring_track_color' => 'rgba(255, 255, 255, 0.28)',
'ring_progress_color' => '#ffffff',
'bottom_progress_color' => '#0f766e',
)
);
if ( empty( $rx_scroll_top_settings['enabled'] ) ) {
return;
}
$rx_scroll_top_id = 'rx-scroll-top-' . wp_unique_id();
$rx_scroll_top_classes = array(
'rx-scroll-top',
'rx-scroll-top--hidden',
'rx-scroll-top--' . sanitize_html_class( $rx_scroll_top_settings['position'] ),
);
if ( ! empty( $rx_scroll_top_settings['show_progress_ring'] ) ) {
$rx_scroll_top_classes[] = 'rx-scroll-top--has-ring';
}
if ( ! empty( $rx_scroll_top_settings['show_percentage_text'] ) ) {
$rx_scroll_top_classes[] = 'rx-scroll-top--has-percent';
}
if ( ! empty( $rx_scroll_top_settings['enable_pulse'] ) ) {
$rx_scroll_top_classes[] = 'rx-scroll-top--pulse';
}
if ( ! empty( $rx_scroll_top_settings['enable_glass_effect'] ) ) {
$rx_scroll_top_classes[] = 'rx-scroll-top--glass';
}
if ( ! empty( $rx_scroll_top_settings['enable_shadow'] ) ) {
$rx_scroll_top_classes[] = 'rx-scroll-top--shadow';
}
$rx_scroll_top_classes = implode( ' ', array_map( 'sanitize_html_class', $rx_scroll_top_classes ) );
$rx_scroll_top_json = wp_json_encode(
array(
'id' => $rx_scroll_top_id,
'showAfterPx' => absint( $rx_scroll_top_settings['show_after_px'] ),
'scrollDuration' => absint( $rx_scroll_top_settings['scroll_duration'] ),
'autoHide' => (bool) $rx_scroll_top_settings['auto_hide'],
'hideOnHomeTop' => (bool) $rx_scroll_top_settings['hide_on_home_top'],
'showProgressRing' => (bool) $rx_scroll_top_settings['show_progress_ring'],
'showPercentageText' => (bool) $rx_scroll_top_settings['show_percentage_text'],
'showBottomProgress' => (bool) $rx_scroll_top_settings['show_bottom_progress'],
'respectReducedMotion' => (bool) $rx_scroll_top_settings['respect_reduced_motion'],
'enableKeyboard' => (bool) $rx_scroll_top_settings['enable_keyboard'],
'enableEscapeBlur' => (bool) $rx_scroll_top_settings['enable_escape_blur'],
)
);
$rx_scroll_icon = '';
switch ( $rx_scroll_top_settings['icon_type'] ) {
case 'rocket':
$rx_scroll_icon = '<svg class="rx-scroll-top__icon" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 2c3.2.6 5.5 2.2 7 4.9c-2.7 1.5-4.3 3.8-4.9 7L12 16l-2.1-2.1c.6-3.2 2.2-5.5 4.9-7C14 4.8 13.2 3.4 12 2Zm-4.8 11.8l3 3l-2.4 2.4c-.8.8-2 .8-2.8 0s-.8-2 0-2.8l2.2-2.6Zm9.6-6.6c-.7-.7-1.8-.7-2.5 0s-.7 1.8 0 2.5s1.8.7 2.5 0s.7-1.8 0-2.5Z"/></svg>';
break;
case 'chevron':
$rx_scroll_icon = '<svg class="rx-scroll-top__icon" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6z"/></svg>';
break;
case 'arrow':
default:
$rx_scroll_icon = '<svg class="rx-scroll-top__icon" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.59 5.58L20 12L12 4z"/></svg>';
break;
}
?>
<div class="rx-scroll-top-wrap" data-rx-scroll-top-root>
<?php if ( ! empty( $rx_scroll_top_settings['show_bottom_progress'] ) ) : ?>
<div class="rx-scroll-page-progress" aria-hidden="true">
<span class="rx-scroll-page-progress__bar" data-rx-scroll-progress-bar></span>
</div>
<?php endif; ?>
<button
id="<?php echo esc_attr( $rx_scroll_top_id ); ?>"
class="<?php echo esc_attr( $rx_scroll_top_classes ); ?>"
type="button"
aria-label="<?php echo esc_attr( $rx_scroll_top_settings['aria_label'] ); ?>"
title="<?php echo esc_attr( $rx_scroll_top_settings['title'] ); ?>"
data-rx-scroll-top
data-rx-scroll-settings="<?php echo esc_attr( $rx_scroll_top_json ); ?>"
>
<span class="rx-scroll-top__inner">
<?php if ( ! empty( $rx_scroll_top_settings['show_progress_ring'] ) ) : ?>
<svg class="rx-scroll-top__ring" width="100%" height="100%" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<circle class="rx-scroll-top__ring-track" cx="50" cy="50" r="44"></circle>
<circle class="rx-scroll-top__ring-progress" cx="50" cy="50" r="44" data-rx-scroll-ring></circle>
</svg>
<?php endif; ?>
<span class="rx-scroll-top__icon-wrap">
<?php echo $rx_scroll_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</span>
<?php if ( ! empty( $rx_scroll_top_settings['show_percentage_text'] ) ) : ?>
<span class="rx-scroll-top__percent" data-rx-scroll-percent>0%</span>
<?php endif; ?>
</span>
</button>
<noscript>
<a class="rx-scroll-top-noscript" href="#top">
<?php echo esc_html( $rx_scroll_top_settings['title'] ); ?>
</a>
</noscript>
</div>
<style>
:root {
--rx-scroll-size: <?php echo esc_attr( absint( $rx_scroll_top_settings['button_size'] ) ); ?>px;
--rx-scroll-size-mobile: <?php echo esc_attr( absint( $rx_scroll_top_settings['button_size_mobile'] ) ); ?>px;
--rx-scroll-bottom: <?php echo esc_attr( absint( $rx_scroll_top_settings['bottom_offset'] ) ); ?>px;
--rx-scroll-right: <?php echo esc_attr( absint( $rx_scroll_top_settings['right_offset'] ) ); ?>px;
--rx-scroll-mobile-bottom: <?php echo esc_attr( absint( $rx_scroll_top_settings['mobile_bottom_offset'] ) ); ?>px;
--rx-scroll-mobile-right: <?php echo esc_attr( absint( $rx_scroll_top_settings['mobile_right_offset'] ) ); ?>px;
--rx-scroll-z: <?php echo esc_attr( absint( $rx_scroll_top_settings['z_index'] ) ); ?>;
--rx-scroll-primary: <?php echo esc_attr( $rx_scroll_top_settings['primary_color'] ); ?>;
--rx-scroll-primary-hover: <?php echo esc_attr( $rx_scroll_top_settings['primary_hover_color'] ); ?>;
--rx-scroll-text: <?php echo esc_attr( $rx_scroll_top_settings['text_color'] ); ?>;
--rx-scroll-bg: <?php echo esc_attr( $rx_scroll_top_settings['background_color'] ); ?>;
--rx-scroll-bg-hover: <?php echo esc_attr( $rx_scroll_top_settings['background_hover_color'] ); ?>;
--rx-scroll-ring-track: <?php echo esc_attr( $rx_scroll_top_settings['ring_track_color'] ); ?>;
--rx-scroll-ring-progress: <?php echo esc_attr( $rx_scroll_top_settings['ring_progress_color'] ); ?>;
--rx-scroll-page-progress: <?php echo esc_attr( $rx_scroll_top_settings['bottom_progress_color'] ); ?>;
}
html {
scroll-behavior: smooth;
}
.rx-scroll-top-wrap {
position: relative;
z-index: var(--rx-scroll-z);
}
.rx-scroll-page-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: calc(var(--rx-scroll-z) + 1);
pointer-events: none;
background: transparent;
overflow: hidden;
}
body.admin-bar .rx-scroll-page-progress {
top: 32px;
}
.rx-scroll-page-progress__bar {
display: block;
width: 0%;
height: 100%;
background: var(--rx-scroll-page-progress);
transform-origin: left center;
transition: width 120ms linear;
}
.rx-scroll-top {
position: fixed;
bottom: var(--rx-scroll-bottom);
width: var(--rx-scroll-size);
height: var(--rx-scroll-size);
border: 0;
border-radius: 999px;
padding: 0;
margin: 0;
cursor: pointer;
z-index: var(--rx-scroll-z);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rx-scroll-text);
background: var(--rx-scroll-bg);
line-height: 1;
opacity: 1;
visibility: visible;
transform: translate3d(0, 0, 0) scale(1);
transition:
opacity 220ms ease,
visibility 220ms ease,
transform 220ms ease,
background-color 180ms ease,
box-shadow 180ms ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.rx-scroll-top--right {
right: var(--rx-scroll-right);
}
.rx-scroll-top--left {
left: var(--rx-scroll-right);
}
.rx-scroll-top--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 16px, 0) scale(0.92);
}
.rx-scroll-top:hover {
background: var(--rx-scroll-bg-hover);
transform: translate3d(0, -3px, 0) scale(1.03);
}
.rx-scroll-top:active {
transform: translate3d(0, 0, 0) scale(0.97);
}
.rx-scroll-top:focus {
outline: none;
}
.rx-scroll-top:focus-visible {
outline: 3px solid color-mix(in srgb, var(--rx-scroll-primary) 50%, white);
outline-offset: 4px;
}
.rx-scroll-top--shadow {
box-shadow:
0 10px 25px rgba(0, 0, 0, 0.20),
0 4px 10px rgba(0, 0, 0, 0.12);
}
.rx-scroll-top--shadow:hover {
box-shadow:
0 14px 30px rgba(0, 0, 0, 0.24),
0 6px 14px rgba(0, 0, 0, 0.16);
}
.rx-scroll-top--glass {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: color-mix(in srgb, var(--rx-scroll-bg) 88%, transparent);
}
.rx-scroll-top__inner {
position: relative;
width: 100%;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: inherit;
overflow: hidden;
}
.rx-scroll-top__icon-wrap {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.rx-scroll-top__icon {
display: block;
width: 24px;
height: 24px;
transition: transform 180ms ease;
}
.rx-scroll-top:hover .rx-scroll-top__icon {
transform: translateY(-2px);
}
.rx-scroll-top__ring {
position: absolute;
inset: 0;
z-index: 1;
transform: rotate(-90deg);
pointer-events: none;
}
.rx-scroll-top__ring-track,
.rx-scroll-top__ring-progress {
fill: none;
stroke-width: 7;
}
.rx-scroll-top__ring-track {
stroke: var(--rx-scroll-ring-track);
}
.rx-scroll-top__ring-progress {
stroke: var(--rx-scroll-ring-progress);
stroke-linecap: round;
stroke-dasharray: 276.46;
stroke-dashoffset: 276.46;
transition: stroke-dashoffset 120ms linear;
}
.rx-scroll-top__percent {
position: absolute;
inset: auto auto 6px auto;
z-index: 3;
font-size: 9px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
pointer-events: none;
}
.rx-scroll-top--has-percent .rx-scroll-top__icon {
width: 18px;
height: 18px;
transform: translateY(-4px);
}
.rx-scroll-top--pulse::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--rx-scroll-primary);
z-index: -1;
opacity: 0;
transform: scale(1);
animation: rx-scroll-pulse 2.4s ease-out infinite;
}
.rx-scroll-top--hidden::before {
animation-play-state: paused;
}
.rx-scroll-top-noscript {
position: fixed;
right: var(--rx-scroll-right);
bottom: var(--rx-scroll-bottom);
z-index: var(--rx-scroll-z);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--rx-scroll-size);
min-height: var(--rx-scroll-size);
padding: 10px 14px;
border-radius: 999px;
color: var(--rx-scroll-text);
background: var(--rx-scroll-bg);
text-decoration: none;
font-size: 14px;
font-weight: 700;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.20);
}
body.admin-bar .rx-scroll-top {
bottom: calc(var(--rx-scroll-bottom) + 0px);
}
@keyframes rx-scroll-pulse {
0% {
opacity: 0.28;
transform: scale(1);
}
70% {
opacity: 0;
transform: scale(1.45);
}
100% {
opacity: 0;
transform: scale(1.45);
}
}
@media (max-width: 782px) {
body.admin-bar .rx-scroll-page-progress {
top: 46px;
}
}
@media (max-width: 640px) {
.rx-scroll-top {
width: var(--rx-scroll-size-mobile);
height: var(--rx-scroll-size-mobile);
bottom: var(--rx-scroll-mobile-bottom);
}
.rx-scroll-top--right {
right: var(--rx-scroll-mobile-right);
}
.rx-scroll-top--left {
left: var(--rx-scroll-mobile-right);
}
.rx-scroll-top__icon {
width: 21px;
height: 21px;
}
.rx-scroll-top-noscript {
right: var(--rx-scroll-mobile-right);
bottom: var(--rx-scroll-mobile-bottom);
min-width: var(--rx-scroll-size-mobile);
min-height: var(--rx-scroll-size-mobile);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.rx-scroll-top,
.rx-scroll-top__icon,
.rx-scroll-top__ring-progress,
.rx-scroll-page-progress__bar {
transition: none;
}
.rx-scroll-top--pulse::before {
animation: none;
}
}
<?php if ( ! empty( $rx_scroll_top_settings['hide_on_print'] ) ) : ?>
@media print {
.rx-scroll-top,
.rx-scroll-top-wrap,
.rx-scroll-page-progress,
.rx-scroll-top-noscript {
display: none !important;
}
}
<?php endif; ?>
</style>
<script>
(function () {
'use strict';
var button = document.getElementById('<?php echo esc_js( $rx_scroll_top_id ); ?>');
if (!button) {
return;
}
var settings = {};
var settingsRaw = button.getAttribute('data-rx-scroll-settings');
try {
settings = settingsRaw ? JSON.parse(settingsRaw) : {};
} catch (error) {
settings = {};
}
var doc = document.documentElement;
var body = document.body;
var ring = button.querySelector('[data-rx-scroll-ring]');
var percentText = button.querySelector('[data-rx-scroll-percent]');
var progressBar = document.querySelector('[data-rx-scroll-progress-bar]');
var ticking = false;
var isScrolling = false;
var ringLength = 276.46;
var reducedMotionQuery = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getScrollTop() {
return window.pageYOffset || doc.scrollTop || body.scrollTop || 0;
}
function getScrollableHeight() {
var documentHeight = Math.max(
body.scrollHeight,
body.offsetHeight,
doc.clientHeight,
doc.scrollHeight,
doc.offsetHeight
);
return Math.max(documentHeight - window.innerHeight, 1);
}
function getScrollPercent() {
return clamp((getScrollTop() / getScrollableHeight()) * 100, 0, 100);
}
function shouldUseReducedMotion() {
return !!(
settings.respectReducedMotion &&
reducedMotionQuery &&
reducedMotionQuery.matches
);
}
function updateProgress(percent) {
if (settings.showProgressRing && ring) {
var offset = ringLength - (ringLength * percent / 100);
ring.style.strokeDashoffset = String(offset);
}
if (settings.showPercentageText && percentText) {
percentText.textContent = String(Math.round(percent)) + '%';
}
if (settings.showBottomProgress && progressBar) {
progressBar.style.width = String(percent) + '%';
}
}
function updateVisibility() {
var scrollTop = getScrollTop();
var percent = getScrollPercent();
var showAfterPx = Number(settings.showAfterPx || 300);
var shouldShow = scrollTop >= showAfterPx;
if (settings.hideOnHomeTop && window.location.hash === '' && scrollTop <= 20) {
shouldShow = false;
}
if (settings.autoHide) {
button.classList.toggle('rx-scroll-top--hidden', !shouldShow);
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
button.tabIndex = shouldShow ? 0 : -1;
} else {
button.classList.remove('rx-scroll-top--hidden');
button.setAttribute('aria-hidden', 'false');
button.tabIndex = 0;
}
updateProgress(percent);
}
function requestUpdate() {
if (ticking) {
return;
}
ticking = true;
window.requestAnimationFrame(function () {
updateVisibility();
ticking = false;
});
}
function easeInOutCubic(t) {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function scrollToTopNative() {
window.scrollTo({
top: 0,
left: 0,
behavior: shouldUseReducedMotion() ? 'auto' : 'smooth'
});
}
function scrollToTopAnimated() {
if (shouldUseReducedMotion()) {
window.scrollTo(0, 0);
return;
}
if ('scrollBehavior' in document.documentElement.style) {
scrollToTopNative();
return;
}
if (isScrolling) {
return;
}
isScrolling = true;
var startY = getScrollTop();
var startTime = null;
var duration = Number(settings.scrollDuration || 650);
function animateScroll(timestamp) {
if (!startTime) {
startTime = timestamp;
}
var elapsed = timestamp - startTime;
var progress = clamp(elapsed / duration, 0, 1);
var eased = easeInOutCubic(progress);
var nextY = Math.round(startY * (1 - eased));
window.scrollTo(0, nextY);
if (progress < 1) {
window.requestAnimationFrame(animateScroll);
} else {
window.scrollTo(0, 0);
isScrolling = false;
button.blur();
}
}
window.requestAnimationFrame(animateScroll);
}
function handleClick(event) {
event.preventDefault();
scrollToTopAnimated();
}
function handleKeydown(event) {
if (!settings.enableKeyboard) {
return;
}
var key = event.key || event.code;
if (key === 'Enter' || key === ' ' || key === 'Spacebar') {
event.preventDefault();
scrollToTopAnimated();
}
if (settings.enableEscapeBlur && key === 'Escape') {
button.blur();
}
}
function init() {
if (!button.hasAttribute('type')) {
button.setAttribute('type', 'button');
}
if (ring) {
ring.style.strokeDasharray = String(ringLength);
ring.style.strokeDashoffset = String(ringLength);
}
button.addEventListener('click', handleClick, { passive: false });
button.addEventListener('keydown', handleKeydown, { passive: false });
window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate, { passive: true });
window.addEventListener('orientationchange', requestUpdate, { passive: true });
if (reducedMotionQuery && typeof reducedMotionQuery.addEventListener === 'function') {
reducedMotionQuery.addEventListener('change', requestUpdate);
}
updateVisibility();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
Now call it in your footer.php before wp_footer() or just before closing </body>:
<?php get_template_part( 'template-parts/components/scroll-top' ); ?>
<?php wp_footer(); ?>
</body>
</html>
You can customize it later from functions.php like this:
add_filter( 'rx_theme_scroll_top_settings', function ( $settings ) {
$settings['primary_color'] = '#2563eb';
$settings['background_color'] = '#2563eb';
$settings['background_hover_color'] = '#1d4ed8';
$settings['show_percentage_text'] = true;
$settings['icon_type'] = 'rocket';
return $settings;
} );