#app
View Compiled
*
*:after
*:before
box-sizing border-box
body
min-height 100vh
display grid
place-items center
overflow hidden
:root
--hue 260
--track hsl(0, 0%, 65%)
--thumb-default 'hsl(%s, 80%, 80%)' % var(--hue)
--thumb-hover 'hsl(%s, 80%, 85%)' % var(--hue)
--thumb-active 'hsl(%s, 80%, 70%)' % var(--hue)
--thumb var(--thumb-default)
--text hsl(0, 0%, 15%)
--thumb-border 'max(%s, 5px)' % 0.5vmin
--thumb-size 'max(%s, 44px)' % 5vmin
--track-width 'max(%s, 200%)' % 300px
--track-height 'max(%s, 1rem)' % 2vmin
--offset 'max(%s, 1rem)' % 2vmin
--font-size 'max(%s, 6rem)' % 12vmin
.sr-only
position absolute
width 1px
height 1px
padding 0
margin -1px
overflow hidden
clip rect(0, 0, 0, 0)
white-space nowrap
border-width 0
.ranger
display flex
flex-direction column
position relative
&__digits
display flex
justify-content center
gap 0.1vmin
align-items center
&__digit
height var(--font-size)
width calc(var(--font-size) * 0.66)
display inline-flex
position relative
overflow hidden
& > span
height 100%
width 100%
display inline-flex
align-items center
justify-content center
position absolute
bottom 100%
left 0
font-size var(--font-size)
font-weight bold
color var(--text)
font-family sans-serif
&__slider
appearance none
width var(--track-width)
height var(--track-height)
background var(--track)
position absolute
left 50%
top 150%
transform translate(-50%, 0)
border-radius calc(var(--track-height) * 0.5)
outline-color var(--thumb)
outline-offset var(--offset)
@media(max-width: 768px)
max-width 300px
&:hover
--thumb-scale 1.1
--thumb var(--thumb-hover)
--cursor -webkit-grab
&:active
--thumb-scale 0.9
--thumb var(--thumb-active)
--cursor -webkit-grabbing
&::-webkit-slider-thumb
appearance none
border-radius 50%
width var(--thumb-size)
height var(--thumb-size)
background var(--thumb, var(--thumb-default))
cursor pointer
cursor var(--cursor, pointer)
border-style solid
border-width var(--thumb-border)
border-color var(--text)
transition transform 0.1s ease-out, background 0.1s ease-out
transform scale(var(--thumb-scale, 1))
&::-moz-range-thumb
border-radius 50%
width var(--thumb-size)
height var(--thumb-size)
background var(--thumb, var(--thumb-default))
cursor pointer
cursor var(--cursor, pointer)
border-style solid
border-width var(--thumb-border)
border-color var(--text)
transition transform 0.1s ease-out, background 0.1s ease-out
transform scale(var(--thumb-scale, 1))
View Compiled
import React from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'
import gsap from 'https://cdn.skypack.dev/gsap@3.9'
import { GUI } from 'https://cdn.skypack.dev/dat.gui'
const ROOT_NODE = document.querySelector('#app')
const getDigitTimeline = (
digit,
digitIndex,
range,
sliderRef,
liveRegionRef
) => {
const TL = gsap.timeline({
paused: true,
})
const DIGITS = gsap.utils.toArray(digit.children)
// Work out if it's a single, tne, hundred, etc.
const COEFF = Math.pow(10, `${range}`.length - (digitIndex + 1))
const REPEAT = Math.pow(10, digitIndex)
const PADDED_DIGITS = [...DIGITS, ...DIGITS, ...DIGITS]
for (let i = 0; i < PADDED_DIGITS.length; i++) {
const DIGIT = PADDED_DIGITS[i]
const DIGI_TIMELINE = gsap
.timeline()
.set(DIGIT, {
yPercent: 0,
})
.to(DIGIT, {
yPercent: 100,
delay: i * COEFF,
duration: 1,
onComplete: () => {
liveRegionRef.current.innerText = sliderRef.current.value
},
})
.to(DIGIT, {
delay: COEFF - 1,
yPercent: 200,
duration: 1,
clearProps: 'all',
})
TL.add(DIGI_TIMELINE, 0)
}
// Return a timeline that animates through the repeating window.
// Let the main timeline scrub it.
const TIME_WINDOW = gsap.timeline().fromTo(
TL,
{
totalTime: 10 * COEFF,
},
{
totalTime: 20 * COEFF,
repeat: REPEAT,
ease: 'none',
duration: COEFF * 10,
}
)
return TIME_WINDOW
}
const Ranger = ({ defaultValue = 0, max = 100, min = 0, step = 1 }) => {
const digits = React.useRef(null)
const sliderRef = React.useRef(null)
const liveRegionRef = React.useRef(null)
const timeline = React.useRef(gsap.timeline({ paused: true }))
React.useEffect(() => {
// Want to set up a master timeline that we scrub with the range slider
if (timeline.current && digits.current) {
timeline.current.kill()
timeline.current = gsap.timeline({ paused: true })
// Need to iterate over the digits in reverse and create an animation for them.
// Iterate over digits.current.children in reverse.
for (let i = digits.current.children.length - 1; i >= 0; i--) {
const CURRENT_DIGIT = digits.current.children[i]
const digitTimeline = getDigitTimeline(
CURRENT_DIGIT,
i,
max,
sliderRef,
liveRegionRef
)
timeline.current.add(digitTimeline, 0)
// Chained progress to show the initial state.
timeline.current.progress(1).progress(0)
/**
* Always +1 to the slot you want to go to.
* Range is (min + 1) -> (max + 1)
*/
timeline.current.totalTime(defaultValue + 1)
// Set the slider value to make sure
sliderRef.current.value = defaultValue
}
}
}, [min, max, defaultValue])
const scrub = e => {
gsap.to(timeline.current, {
totalTime: parseInt(e.target.value, 10) + 1,
duration: 0.5,
})
}
return (
<div className="ranger">
<label htmlFor="ranger">
<span aria-live="polite" role="region" ref={liveRegionRef} className="sr-only" />
<span aria-hidden="true" ref={digits} className="ranger__digits">
{max
.toString()
.split('')
.map(d => (
<span className="ranger__digit">
{new Array(10).fill().map((c, index) => (
<span>{index}</span>
))}
</span>
))}
</span>
</label>
<input
ref={sliderRef}
id="ranger"
type="range"
className="ranger__slider"
min={min}
step={step}
max={max}
defaultValue={defaultValue}
onChange={scrub}
/>
</div>
)
}
const App = () => {
const CONFIG = {
min: 0,
max: 5000,
step: 1,
defaultValue: 1010,
hue: 260,
}
const [max, setMax] = React.useState(CONFIG.max)
const [min, setMin] = React.useState(CONFIG.min)
const [step, setStep] = React.useState(CONFIG.step)
const [defaultValue, setDefaultValue] = React.useState(CONFIG.defaultValue)
const minController = React.useRef(null)
const maxController = React.useRef(null)
const stepController = React.useRef(null)
const defaultValueController = React.useRef(null)
React.useEffect(() => {
const UPDATE = () => {
minController.current.max(CONFIG.max - 1)
maxController.current.min(CONFIG.min + 1)
stepController.current.max(CONFIG.max)
defaultValueController.current.min(CONFIG.min)
defaultValueController.current.max(CONFIG.max)
setMin(CONFIG.min)
setMax(CONFIG.max)
setStep(CONFIG.step)
setDefaultValue(CONFIG.defaultValue)
}
// Set up the controller.
const CTRL = new GUI()
minController.current = CTRL.add(CONFIG, 'min', 0, CONFIG.max - 1, 1)
.name('Min')
.onChange(UPDATE)
maxController.current = CTRL.add(CONFIG, 'max', CONFIG.min + 1, 10000, 1)
.name('Max')
.onChange(UPDATE)
stepController.current = CTRL.add(CONFIG, 'step', 1, CONFIG.max, 1)
.name('Step')
.onChange(UPDATE)
defaultValueController.current = CTRL.add(
CONFIG,
'defaultValue',
CONFIG.min,
CONFIG.max,
1
)
.name('Default Value')
.onChange(UPDATE)
CTRL.add(CONFIG, 'hue', 0, 359, 1)
.name('Hue')
.onChange(() => document.documentElement.style.setProperty('--hue', CONFIG.hue))
}, [])
return <Ranger defaultValue={defaultValue} min={min} step={step} max={max} />
}
render(<App />, ROOT_NODE)
View Compiled
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.