By
Raqueebuddin Aziz
September 23, 2022
Freelance Web Designer & Developer
By
September 23, 2022
Freelance Web Designer & Developer
A guide on how to implement custom pull to refresh for svelte apps. This guide assumes you are familiar with svelte stores. If not feel free to check first half of this article explaining svelte stores.
I’ll be using sveltekit to build this example for convenience but all of this generally applies to svelte.
Here is a direct link to the built app and here is the link to the repo.
There is some basic setup before we get started.
The sveltekit starter template src/app.html
has a div wrapping sveltekit body contents you can either make it have 100% height and width or remove that div, for this example I will remove the div.
<!-- src/app.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body>
<!-- <div>%sveltekit.body%</div> -->
%sveltekit.body%
</body>
</html>
The only two bits that are functional and not just to make this look pretty is height: 100%
this will make sure our scroll-area gets the scrollbar and overscroll-behavior-y: none
which disables browser’s own pull to refresh or any other browser animations on scroll.
/* src/style.css */
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
body,
html {
font-family: sans-serif;
/* don't make this min-height otherwise the #scroll-area won't get the scrollbar */
height: 100%;
/* this disables browser's native pull to refresh or any other browser animations on scroll */
overscroll-behavior-y: none;
}
The markup is pretty simple we import our global styles. There is a div.container
element to lay out our nav
and main#scroll-area
. The nav
itself is just there to look pretty and the main
element is our #scroll-area
with lots of lorem ipsum to make the element very big and get a scrollbar for demonstration. It also contains our div#pull-to-refresh
element this is our spinner that we will manipulate with JS later on.
Then we have some styles to make sure the main#scroll-area
element takes the remaining space after nav
is laid out. The #scroll-area
is position: relative
so that we can position the #pull-to-refresh
spinner relative to it, and then we define --offset
& --angle
CSS custom properties to later on manipulate the position and rotation of our spinner with JS.
<!-- src/routes/+page.svelte -->
<script>
import '../style.css'
</script>
<div class="container">
<nav class="nav">
<div class="nav__logo">Pull To Refresh</div>
</nav>
<!-- this will be the scroll area -->
<main class="main" id="scroll-area">
<!-- this is the pull to refresh spinner -->
<div id="pull-to-refresh">
<div class="ptr-icon">
<svg width="1em" height="1em" viewBox="0 0 8 8"
><path
fill="currentColor"
d="M4 0C1.8 0 0 1.8 0 4s1.8 4 4 4c1.1 0 2.12-.43 2.84-1.16l-.72-.72c-.54.54-1.29.88-2.13.88c-1.66 0-3-1.34-3-3s1.34-3 3-3c.83 0 1.55.36 2.09.91L4.99 3h3V0L6.8 1.19C6.08.47 5.09 0 3.99 0z"
/></svg
>
</div>
</div>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum magnam
dolor est velit optio eaque perferendis, suscipit voluptatum sit accusamus
eius illo cumque laboriosam. Non est quia totam ipsam dignissimos.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum magnam
dolor est velit optio eaque perferendis, suscipit voluptatum sit accusamus
eius illo cumque laboriosam. Non est quia totam ipsam dignissimos.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum magnam
dolor est velit optio eaque perferendis, suscipit voluptatum sit accusamus
eius illo cumque laboriosam. Non est quia totam ipsam dignissimos.
</p>
<!-- Lots of lorem ipsum to make the page long -->
....
</main>
</div>
<style>
/* create a container to layout our nav and scroll area */
.container {
--padding: 1rem;
display: grid;
grid-template-columns: 1fr;
/* the navbar is auto sized and the scroll area takes the remaining height */
grid-template-rows: auto 1fr;
/* needed to make the scroll area have the scrollbar */
height: 100%;
}
/* pretty styles not necessary for this to be functional */
.nav {
background-color: black;
color: white;
padding: var(--padding);
}
.nav__logo {
text-transform: uppercase;
}
.main {
padding: var(--padding);
display: grid;
place-content: start;
gap: 1em;
}
.ptr-icon {
padding: 0.75rem;
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 9999px;
background-color: black;
color: white;
}
/* functional styles needed to make this work */
#scroll-area {
/* the #scroll-area must have position relative so we can position the #pull-to-refresh element relative to this */
position: relative;
/* attach scrollbar on overflow */
overflow-y: auto;
}
#pull-to-refresh {
/* we define some css custom properties that we will later use to style the pull-to-refresh spinner */
/* this is the initial offset of the spinner we translate it by -100% to place it behind the navbar */
--initial: -100%;
/* these are the custom properties we will manipulate with js later on --offset is the amount the icon is dragged and --angle is it's rotation */
--y: calc(var(--offset, 0px) + var(--initial));
--angle: 0deg;
/* take the spinner out of document flow and place it relative to scroll area */
position: absolute;
inset: auto 0;
/* use the custom properties to rotate and drag the spinner */
transform: translateY(var(--y)) rotate(var(--angle, 0deg));
/* center the containing icon */
display: grid;
place-content: center;
}
</style>
The following code is written in src/mountHooks.js
So the pull to refresh implementation we will be creating will
document.getElementById
to target the #scroll-area
and #pull-to-refresh
spinner so that this function can be called in one place like a root +layout.svelte
as we don’t use bind:this
to get element references.onRefresh
that takes four optional paramaters.
scrollAreaId
(default: scroll-area
): This is the id of the element to attach touch handlers on.pullToRefreshId
(default: pull-to-refresh
): This is the id of the spinner element.thresholdDistance
(default: 100
): the minimum distance the user needs to swipe before a refresh triggers.callback
(default: (refreshing) => refreshing.set(false)
): the function that is called when a refresh is triggered./* src/mountHooks.js */
export function onRefresh({
scrollAreaId = 'scroll-area',
pullToRefreshId = 'pull-to-refresh',
thresholdDistance = 100,
callback = (refreshing) => refreshing.set(false)
} = {}) {}
Here we get the element references with id,
set the initial swipe Y coordinate startY
to 0
and touchId
to -1
representing no touch currently linked to pull to refresh state.
Then we create two svelte spring
stores (basically stores that don’t immediately change their value but change their value gradually creating a natural feel to the state change Read More at Svelte Docs) named offset
and angle
that represents the Y offset of our spinner and the angle of rotation, we link their values to the css custom properties we defined earlier.
/* src/mountHooks.js */
import { spring } from 'svelte/motion'
import { writable } from 'svelte/store'
export function onRefresh({
scrollAreaId = 'scroll-area',
pullToRefreshId = 'pull-to-refresh',
thresholdDistance = 100,
callback = (refreshing) => refreshing.set(false)
} = {}) {
// this represents refreshing state if we are currently refreshing or not, this will be returned to the caller of this function
const refreshing = writable(false)
// is true if threshold distance swiped else false used to figure out if a refresh is needed on touchend
let shouldRefresh = false
// these will be set in an onMount call alter to the element references
let scrollArea
let pullToRefresh
// start Y coordinate of swipe
let startY = 0
// touch ID used to start pullToRefresh, -1 is used to represent no pullToRefresh started yet
let touchId = -1
// will be linked to css properties later on
const offset = spring(0)
const angle = spring(0)
}
This is the most straight forward one we check if another touch is already linked to pull to refresh state and if not then we link this touch to the state.
function onTouchStart(e) {
// return if another touch is already registered for pull to refresh
if (touchId > -1) return
const touch = e.touches[0]
startY = touch.screenY + scrollArea.scrollTop
touchId = touch.identifier
}
On touch move we check if the touch moved was the registered touch in the above onTouchStart
handler and if it is,
shouldRefresh
flag depending on if the distance swiped is greater than or equal to the thresholdDistance
.scrollArea
unscrollable.offset
and angle
stores and limit them to thresholdDistance
using Math.min
. Multiplying the angle
value by 2 is just a stylistic choice here.function onTouchMove(e) {
// pull to refresh should only trigger if user is at top of the scroll area
if (scrollArea.scrollTop > 0) return
const touch = Array.from(e.changedTouches).find((t) => t.identifier === touchId)
if (!touch) return
const distance = touch.screenY - startY
shouldRefresh = distance >= thresholdDistance
if (distance > 0) {
scrollArea.style.overflowY = 'hidden'
}
offset.set(Math.min(distance, thresholdDistance))
angle.set(Math.min(distance, thresholdDistance) * 2)
}
Again we check if this event was triggered with our registered touch otherwise we return.
touchId
.refreshing
store state.isRefreshing
that is linked to our refreshing
store state. This avoids using get(refreshing)
in the requestAnimationFrame
callback which is an expensive operation.refreshing
store state to false.offset
and angle
to 0
.function onTouchEnd(e) {
// needed so this doesn't trigger if some other touch ended
const touch = Array.from(e.changedTouches).find((t) => t.identifier === touchId)
if (!touch) return
scrollArea.style.overflowY = 'auto'
// reset touchId
touchId = -1
// run callback if refresh needed
if (shouldRefresh) {
refreshing.set(true)
callback(refreshing)
}
// create a proxy value for the store to avoid using get(refreshing) in while loop
let isRefreshing: boolean = true
const unsubscribe = refreshing.subscribe((state) => (isRefreshing = state))
// spin the loader while refreshing
function spin() {
if (isRefreshing) {
angle.update(($angle) => $angle + 5)
requestAnimationFrame(spin)
} else {
offset.set(0)
angle.set(0)
unsubscribe()
}
}
requestAnimationFrame(spin)
}
We have two choices here we can call onMount
inside our function or return a function that registers the handlers and let the caller decide when to handle the registration. I decided to call onMount
inside the function and return the refreshing
store. This decision makes it so that the caller needs to call onRefresh
during component registration as onMount
can only be called during component registration.
/* src/mountHooks.js */
import { onMount } from 'svelte'
import { spring } from 'svelte/motion'
import { writable } from 'svelte/store'
export function onRefresh({
scrollAreaId = 'scroll-area',
pullToRefreshId = 'pull-to-refresh',
thresholdDistance = 100,
callback = (refreshing) => refreshing.set(false)
} = {}) {
/* code written till now here */
/* .... */
// set element references, link css properties to stores & register touch handlers
onMount(() => {
scrollArea = document.getElementById(scrollAreaId)
pullToRefresh = document.getElementById(pullToRefreshId)
if (!scrollArea) throw new Error(`no element with id ${scrollAreaId} found`)
if (!pullToRefresh) throw new Error(`no element with id ${pullToRefreshId} found`)
// link offset to css properties
const offsetUnsub = offset.subscribe((val) => {
requestAnimationFrame(() => {
pullToRefresh.style.setProperty('--offset', `${val}px`)
})
})
// link angle to css properties
const angleUnsub = angle.subscribe((val) => {
requestAnimationFrame(() => {
pullToRefresh.style.setProperty('--angle', `${val}deg`)
})
})
scrollArea?.addEventListener('touchstart', onTouchStart)
scrollArea?.addEventListener('touchmove', onTouchMove)
scrollArea?.addEventListener('touchend', onTouchEnd)
scrollArea?.addEventListener('touchcancel', onTouchEnd)
return () => {
offsetUnsub()
angleUnsub()
scrollArea?.removeEventListener('touchstart', onTouchStart)
scrollArea?.removeEventListener('touchmove', onTouchMove)
scrollArea?.removeEventListener('touchend', onTouchEnd)
scrollArea?.removeEventListener('touchcancel', onTouchEnd)
}
})
// return refreshing state store
return refreshing
}
/* src/mountHooks.js */
import { writable } from 'svelte/store'
import { spring } from 'svelte/motion'
import { onMount } from 'svelte'
export function onRefresh({
scrollAreaId = 'scroll-area',
pullToRefreshId = 'pull-to-refresh',
thresholdDistance = 100,
callback = (refreshing) => refreshing.set(false)
} = {}) {
// this represents refreshing state if we are currently refreshing or not, this will be return to the caller of this function
const refreshing = writable(false)
// is true if threshold distance swiped else false used to figure out if a refresh is needed on touchend
let shouldRefresh = false
// will be set to element references later on
let scrollArea
let pullToRefresh
// start Y coordinate of swipe
let startY = 0
// touch ID used to start pullToRefresh, -1 is used to represent no pullToRefresh started yet
let touchId = -1
// will be linked to css properties later on
const offset = spring(0)
const angle = spring(0)
function onTouchStart(e) {
// return if another touch is already registered for pull to refresh
if (touchId > -1) return
const touch = e.touches[0]
startY = touch.screenY + scrollArea.scrollTop
touchId = touch.identifier
}
function onTouchMove(e) {
// pull to refresh should only trigger if user is at top of the scroll area
if (scrollArea.scrollTop > 0) return
const touch = Array.from(e.changedTouches).find((t) => t.identifier === touchId)
if (!touch) return
const distance = touch.screenY - startY
shouldRefresh = distance >= thresholdDistance
if (distance > 0) {
scrollArea.style.overflowY = 'hidden'
}
offset.set(Math.min(distance, thresholdDistance))
angle.set(Math.min(distance, thresholdDistance) * 2)
}
function onTouchEnd(e) {
// needed so this doesn't trigger if some other touch ended
const touch = Array.from(e.changedTouches).find((t) => t.identifier === touchId)
if (!touch) return
scrollArea.style.overflowY = 'auto'
// reset touchId
touchId = -1
// run callback if refresh needed
if (shouldRefresh) {
shouldRefresh = false
refreshing.set(true)
callback(refreshing)
}
// create a proxy value for the store to avoid using get(refreshing) in the spin loop
let isRefreshing: boolean = true
const unsubscribe = refreshing.subscribe((state) => (isRefreshing = state))
// spin the loader while refreshing
function spin() {
if (isRefreshing) {
angle.update(($angle) => $angle + 5)
requestAnimationFrame(spin)
} else {
offset.set(0)
angle.set(0)
unsubscribe()
}
}
requestAnimationFrame(spin)
}
// set element references, link css properties to stores & register touch handlers
onMount(() => {
scrollArea = document.getElementById(scrollAreaId)
pullToRefresh = document.getElementById(pullToRefreshId)
if (!scrollArea) throw new Error(`no element with id ${scrollAreaId} found`)
if (!pullToRefresh) throw new Error(`no element with id ${pullToRefreshId} found`)
// link offset to css properties
const offsetUnsub = offset.subscribe((val) => {
requestAnimationFrame(() => {
pullToRefresh.style.setProperty('--offset', `${val}px`)
})
})
// link angle to css properties
const angleUnsub = angle.subscribe((val) => {
requestAnimationFrame(() => {
pullToRefresh.style.setProperty('--angle', `${val}deg`)
})
})
scrollArea?.addEventListener('touchstart', onTouchStart)
scrollArea?.addEventListener('touchmove', onTouchMove)
scrollArea?.addEventListener('touchend', onTouchEnd)
scrollArea?.addEventListener('touchcancel', onTouchEnd)
return () => {
offsetUnsub()
angleUnsub()
scrollArea?.removeEventListener('touchstart', onTouchStart)
scrollArea?.removeEventListener('touchmove', onTouchMove)
scrollArea?.removeEventListener('touchend', onTouchEnd)
scrollArea?.removeEventListener('touchcancel', onTouchEnd)
}
})
// return refreshing state store
return refreshing
}
Let’s just hide our p
elements when refreshing and emulate a refresh using setTimeout
. We call the onRefresh
function with a callback that sets refreshing
state to false after 3 seconds.
<!-- src/routes/+page.svelte -->
<script>
import '../style.css'
import { onRefresh } from '../mountHooks.js'
const refreshing = onRefresh({
callback(state) {
setTimeout(() => {
state.set(false)
}, 3000)
},
})
</script>
<div class="container">
<!-- nav here -->
<!-- ... -->
<main class="main" id="scroll-area">
<!-- this is the pull to refresh spinner -->
<div id="pull-to-refresh">
<div class="ptr-icon">
<svg width="1em" height="1em" viewBox="0 0 8 8"
><path
fill="currentColor"
d="M4 0C1.8 0 0 1.8 0 4s1.8 4 4 4c1.1 0 2.12-.43 2.84-1.16l-.72-.72c-.54.54-1.29.88-2.13.88c-1.66 0-3-1.34-3-3s1.34-3 3-3c.83 0 1.55.36 2.09.91L4.99 3h3V0L6.8 1.19C6.08.47 5.09 0 3.99 0z"
/></svg
>
</div>
</div>
{#if !$refreshing}
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum magnam
dolor est velit optio eaque perferendis, suscipit voluptatum sit
accusamus eius illo cumque laboriosam. Non est quia totam ipsam
dignissimos.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum magnam
dolor est velit optio eaque perferendis, suscipit voluptatum sit
accusamus eius illo cumque laboriosam. Non est quia totam ipsam
dignissimos.
</p>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum magnam
dolor est velit optio eaque perferendis, suscipit voluptatum sit
accusamus eius illo cumque laboriosam. Non est quia totam ipsam
dignissimos.
</p>
<!-- Lots of lorem ipsum to make the page long -->
....
{/if}
</main>
</div>
<style>
/* ... */
</style>
The beauty of this implementation is that svelte spring stores does all the animation related heavy lifting for us combined with css custom properties. We don’t have to worry about animation logic and thus can simplify and focus our approach to pure logic related to pull to refresh semantics.
If you think this is cool, Leave a comment down below
© 2024 Raqueebuddin Aziz. All rights reserved.