#app
View Compiled
*
*:after
*:before
  box-sizing border-box
  transform-style preserve-3d

:root
  --spin-speed 0
  --border-width 1.8vmin
  --depth 20vmin
  --stack-height 3vmin
  --scene-size 16vmin
  --ring-size calc(var(--scene-size) * 0.6)
  --plane radial-gradient(hsla(0, 0%, 0%, 0.1) 50%, transparent 65%)
  --ring-shadow hsla(0, 0%, 0%, 0.5)
  --hue-one 320
  --hue-two 210
  --blur 10px
  --speed 1.5s
  --bg hsl(0, 0%, 98%)
  --ring-filter brightness(1) drop-shadow(0 0 0 var(--accent))
  
  @media(prefers-color-scheme dark)
    --bg hsl(0, 0%, 15%)
    --ring-shadow 'hsla(%s, 100%, 50%, 1)' % var(--hue-one)
    --plane 'radial-gradient(hsla(%s, 90%, 60%, 0.1) 50%, transparent 65%)' % var(--hue-one)
    --ring-filter brightness(1.75) drop-shadow(0 0 1vmin var(--accent))

body
  background var(--bg)
  min-height 100vh
  display grid
  place-items center
  overflow hidden
  
.scene
  height var(--scene-size)
  width var(--scene-size)
  animation step-up var(--speed) infinite linear
  
  &__shadow
    position absolute
    top 0
    left 100%
    height 100%
    width 100%
    opacity var(--opacity)
    animation fade-in var(--speed) infinite linear
    background var(--plane)
    transform scale(1.25)
    filter blur(var(--blur))

.flipper
  height var(--scene-size)
  width var(--scene-size)
  animation flip-flop calc(var(--speed) * 2) infinite steps(1)

.plane
  height 100%
  width 100%
  transform translate3d(0, 0, var(--depth))
  position relative
  
  &__shadow
    content ''
    height 100%
    width 100%
    position absolute
    top 50%
    left 50%
    background var(--plane)
    filter blur(var(--blur))
    transform translate(-50%, -50%) scale(1.25)
    animation fade-out var(--speed) infinite linear  

.scene__shadow
.plane__shadow    
  &:after
    content ''
    height var(--ring-size)
    width var(--ring-size)
    position absolute
    top 50%
    left 50%
    border var(--border-width) solid var(--ring-shadow)
    border-radius 50%
    transform translate(-50%, -50%) scale(0.8)
      
.container
  transform translateZ(100vmin) rotateX(-12deg) rotateY(0deg) rotateX(90deg) translateZ(calc(var(--depth) * -1.5)) rotate(0deg)
  animation rotate-scene calc(var(--speed) * var(--spin-speed)) infinite linear
  
.ring
  --origin-z calc(var(--stack-height) - (var(--stack-height) / var(--ring-count)) * var(--index))
  --destination-z calc(((var(--depth) + var(--origin-z)) - (var(--stack-height) - var(--origin-z))) * -1)
  --hue var(--hue-one)
  --accent 'hsl(%s 100% 55%)' % var(--hue)
  --ring-filter brightness(1) drop-shadow(0 0 0 var(--accent))
  height var(--ring-size)
  width var(--ring-size)
  border-radius 50%
  border var(--border-width) solid var(--accent)
  position absolute
  top 50%
  left 50%
  transform-origin calc(100% + (var(--scene-size) * 0.2)) 50%
  animation-name var(--name)
  animation-duration var(--speed)
  animation-iteration-count infinite
  animation-timing-function cubic-bezier(.25,0,1,1)
  filter var(--ring-filter)
  
  &:nth-of-type(odd)
    --hue var(--hue-two)
    
  @media(prefers-color-scheme dark)
    --ring-filter brightness(2) drop-shadow(0 0 calc(var(--border-width) * 0.5) var(--accent))
View Compiled
import React from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'
import { GUI } from 'https://cdn.skypack.dev/dat.gui'

const ROOT_NODE = document.querySelector('#app')

const DEFAULT_RING_COUNT = 39
const DEFAULT_ANIMATION_WINDOW = 50

const Slinky = () => {
	const styleRef = React.useRef(null)
	const [animationWindow, setAnimationWindow] = React.useState(
		DEFAULT_ANIMATION_WINDOW
	)
	const [flip, setFlip] = React.useState(true)
	const [count, setCount] = React.useState(DEFAULT_RING_COUNT)

	React.useEffect(() => {
		const CONFIG = {
			animationWindow,
			count,
			'hue-one': 210,
			'hue-two': 320,
			'border-width': 1.2,
			depth: 30,
			'stack-height': 6,
			'scene-size': 30,
			'spin-speed': 50,
			speed: 1.15,
			'flip-flop': true,
		}
		const CTRL = new GUI({
			width: 300,
		})

		const UPDATE = () => {
			Object.keys(CONFIG)
				.filter(key => key !== 'animationWindow' && key !== 'count')
				.forEach(key => {
					document.documentElement.style.setProperty(
						`--${key}`,
						`${CONFIG[key]}${
							key.indexOf('hue') === -1 && key.indexOf('spin-speed') === -1
								? 'vmin'
								: ''
						}`
					)
				})
			document.documentElement.style.setProperty(
				'--flip-flop',
				CONFIG['flip-flop'] ? 1 : 0
			)
			document.documentElement.style.setProperty(
				'--speed',
				`${CONFIG['speed']}s`
			)
		}

		const RINGS = CTRL.addFolder('Rings')
		RINGS.add(CONFIG, 'animationWindow', 5, 50, 1)
			.name('Animation Window')
			.onFinishChange(setAnimationWindow)
		RINGS.add(CONFIG, 'count', 5, 50, 1)
			.name('Ring Count')
			.onFinishChange(setCount)
		RINGS.add(CONFIG, 'border-width', 0.1, 4, 0.1)
			.name('Width (vmin)')
			.onFinishChange(UPDATE)
		RINGS.add(CONFIG, 'scene-size', 5, 30, 1)
			.name('Size (vmin)')
			.onFinishChange(UPDATE)
		const STACK = CTRL.addFolder('Stack')
		STACK.add(CONFIG, 'stack-height', 1, 20, 1)
			.name('Height (vmin)')
			.onFinishChange(UPDATE)
		STACK.add(CONFIG, 'depth', 0, 40, 1)
			.name('Drop (vmin)')
			.onFinishChange(UPDATE)
		STACK.add(CONFIG, 'spin-speed', 0, 60, 1)
			.name('Spin Speed (s)')
			.onFinishChange(UPDATE)
		STACK.add(CONFIG, 'speed', 0.2, 10, 0.1)
			.name('Slink Speed (s)')
			.onFinishChange(UPDATE)
		const COLORS = CTRL.addFolder('Colors')
		COLORS.add(CONFIG, 'hue-one', 0, 360, 1)
			.name('Hue One')
			.onFinishChange(UPDATE)
		COLORS.add(CONFIG, 'hue-two', 0, 360, 1)
			.name('Hue Two')
			.onFinishChange(UPDATE)
		CTRL.add(CONFIG, 'flip-flop')
			.name('Flip Flop?')
			.onChange(flip => {
				UPDATE()
				setFlip(flip)
			})
		UPDATE()
	}, [])

	React.useEffect(() => {
		if (styleRef.current) styleRef.current.remove()
		const STYLE = document.createElement('style')
		document.head.appendChild(STYLE)

		const ANIMATION_STEP = animationWindow / count

		// Loop over the count and generate the keyframes and insert them at each index.
		for (let i = 0; i < count; i++) {
			const START = ANIMATION_STEP * (i + 1)
			const SLINKS = `
				0%, ${START}%   { 
          transform: translate3d(-50%, -50%, var(--origin-z)) translateZ(0) rotateY(0deg);
				}
        ${START + animationWindow * 0.75}% {
          transform: translate3d(-50%, -50%, var(--origin-z)) translateZ(0) rotateY(180deg);
        }
					${START + animationWindow}%, 100% {
            transform: translate3d(-50%, -50%, var(--origin-z)) translateZ(var(--destination-z)) rotateY(180deg);
					}
			`
			STYLE.sheet.insertRule(
				`
				@-webkit-keyframes slink-${i} {
					${SLINKS}
				}
			`
			)
			STYLE.sheet.insertRule(
				`
				@keyframes slink-${i} {
					${SLINKS}
				}
			`
			)
		}
		const FADE_IN = `
			${animationWindow}%, 0% {
		    opacity: 0;
		  }
		  ${ANIMATION_STEP + animationWindow}%, 100% {
		    opacity: 1;
		  }
		`
		STYLE.sheet.insertRule(
			`
			@-webkit-keyframes fade-in {
				${FADE_IN}
			}
		`
		)
		STYLE.sheet.insertRule(
			`
			@keyframes fade-in {
				${FADE_IN}
			}
		`
		)
		
		const FADE_OUT = `
			${ANIMATION_STEP * (count + 1)}%, 0% {
		    opacity: 1;
		  }
		  ${ANIMATION_STEP * (count + 1) + animationWindow * 0.375}%, 100% {
		    opacity: 0;
		  }
		`
		STYLE.sheet.insertRule(
			`
			@-webkit-keyframes fade-out {
				${FADE_OUT}
			}
		`
		)
		STYLE.sheet.insertRule(
			`
			@keyframes fade-out {
				${FADE_OUT}
			}
		`
		)

		const STEP = `
			0% {
        transform: translate(${flip ? -50 : 0}%, 0) translateZ(0);
      }
			100% {
        transform: translate(${flip ? -50 : -100}%, 0) translateZ(var(--depth));
			}
		`

		STYLE.sheet.insertRule(`
			@-webkit-keyframes step-up {
        ${STEP}
			}
		`)
		STYLE.sheet.insertRule(`
			@keyframes step-up {
        ${STEP}
			}
		`)
		
		const ROTATE = `
			to {
        transform: translateZ(100vmin) rotateX(-12deg) rotateY(0deg) rotateX(90deg) translateZ(calc(var(--depth) * -1.5)) rotate(360deg);
			}
		`
		STYLE.sheet.insertRule(`
			@-webkit-keyframes rotate-scene {
				${ROTATE}
			}
		`)
		STYLE.sheet.insertRule(`
			@keyframes rotate-scene {
				${ROTATE}
			}
		`)

		if (flip) {
			const FLIP = `
				0% {
			    transform: rotate(0deg);
			  }
			  50% {
			    transform: rotate(180deg);
			  }
			  100% {
			    transform: rotate(360deg);
			  }
			`
			STYLE.sheet.insertRule(`
				@-webkit-keyframes flip-flop {
					${FLIP}	  
				}
			`)
			STYLE.sheet.insertRule(`
				@keyframes flip-flop {
					${FLIP}	  
				}
			`)
		}

		styleRef.current = STYLE
	}, [count, animationWindow, flip])

	return (
		<div className="container" key={new Date().getTime()}>
			<div className="flipper">
				<div className="scene">
					<div className="scene__shadow"></div>
					<div className="plane" style={{ '--ring-count': count }}>
						<div className="plane__shadow"></div>
						{new Array(count).fill().map((_, index) => (
							<div
								className="ring"
								key={`ring--${index}`}
								style={{ '--index': index, '--name': `slink-${index}` }}></div>
						))}
					</div>
				</div>
			</div>
		</div>
	)
}

render(<Slinky />, ROOT_NODE)
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.