Animation & motionLiquid vinyl record
Liquid record
Pink lacquer grooves turn and ripple around a small record label.

Change ripple intensity or pause the record motion
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
"use client"
import { useEffect, useId, useRef, useState } from "react"
function useReducedMotion() {
const [reduced, setReduced] = useState(true)
useEffect(() => {
const query = matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduced(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
return reduced
}
function ripplePoint(x: number, y: number, time: number, amount: number) {
return [
x + Math.sin(y * 0.036 + time * 0.48) * Math.cos(x * 0.024 - time * 0.3) * amount * 0.3,
y + Math.sin(x * 0.031 - time * 0.38) * Math.cos(y * 0.029 + time * 0.2) * amount * 0.3,
]
}
// Deform vector paths directly so enlarged grooves retain smooth edges.
function groovePath(radius: number, time: number, amount: number) {
return (
Array.from({ length: 161 }, (_, index) => {
const angle = (index / 160) * Math.PI * 2
const [x, y] = ripplePoint(150 + Math.cos(angle) * radius, 150 + Math.sin(angle) * radius, time, amount)
return `${index ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}`
}).join(" ") + "Z"
)
}
export default function LiquidRecord({ active = true }: { active?: boolean }) {
const artId = `sf-liquid-${useId().replace(/:/g, "")}`
const groovesRef = useRef<SVGGElement>(null)
const time = useRef(0)
const reduced = useReducedMotion()
const [paused, setPaused] = useState(false)
const [ripple, setRipple] = useState(18)
useEffect(() => {
const grooves = groovesRef.current?.querySelectorAll<SVGPathElement>("[data-radius]")
const paint = () =>
grooves?.forEach((path) => {
path.setAttribute("d", groovePath(Number(path.dataset.radius), time.current, ripple))
})
paint()
if (!active || paused || reduced) return
let raf = 0,
last = 0
const tick = (now: number) => {
if (last) time.current += Math.min((now - last) / 1000, 0.04)
last = now
paint()
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [active, paused, reduced, ripple])
return (
<section className="sf-liquid-record" data-running={active && !paused && !reduced}>
<svg
className="sf-liquid-object"
viewBox="0 0 300 300"
role="img"
aria-label="Pink lacquer record with rippling grooves"
>
<defs>
<linearGradient id={`${artId}-sheen`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#ffc8eb" stopOpacity="0" />
<stop offset=".24" stopColor="#ffc8eb" stopOpacity=".42" />
<stop offset=".42" stopColor="#ffc8eb" stopOpacity="0" />
<stop offset=".7" stopColor="#fbabda" stopOpacity=".3" />
<stop offset="1" stopColor="#fbabda" stopOpacity="0" />
</linearGradient>
</defs>
<g ref={groovesRef}>
<path
data-radius="149"
d={groovePath(149, time.current, ripple)}
fill="#d2368e"
stroke="#f875c0"
strokeWidth="2"
/>
<g>
{Array.from({ length: 48 }, (_, index) => (
<path
key={index}
data-radius={55 + index * 1.94}
d={groovePath(55 + index * 1.94, time.current, ripple)}
fill="none"
stroke={index % 2 ? "#94184f" : "#ef61b5"}
strokeWidth=".8"
/>
))}
<path data-radius="52.5" d={groovePath(52.5, time.current, ripple)} fill="#ffcadd" />
<g className="sf-liquid-grooves">
<path d="M124 126H176M124 140H176" stroke="#730c48" strokeWidth="4" transform="rotate(-12 150 132)" />
<circle cx="150" cy="150" r="4.5" fill="#350a29" stroke="#ffd7e8" strokeWidth="2" />
</g>
</g>
<path data-radius="148" d={groovePath(148, time.current, ripple)} fill={`url(#${artId}-sheen)`} />
</g>
</svg>
<div className="sf-liquid-title" aria-hidden="true">
Slow
<br />
<i>signal.</i>
</div>
<div className="sf-liquid-controls">
<button
type="button"
aria-label={paused ? "Play record motion" : "Pause record motion"}
aria-pressed={paused}
disabled={reduced}
onClick={() => setPaused(!paused)}
>
{reduced ? "Still" : paused ? "Play" : "Pause"}
</button>
<label>
Ripple
<input
aria-label="Record ripple"
type="range"
min="0"
max="38"
value={ripple}
onChange={(e) => setRipple(Number(e.target.value))}
/>
</label>
</div>
<style>{`.sf-liquid-record{position:relative;container-type:inline-size;width:100%;height:100%;min-height:280px;overflow:hidden;background:#350a29;color:#ffbee4;font-family:Arial,sans-serif;isolation:isolate}.sf-liquid-record *{box-sizing:border-box}.sf-liquid-title{position:absolute;left:7%;top:21%;font:clamp(40px,9cqw,63px)/.86 Georgia,serif;letter-spacing:-3px;z-index:2;pointer-events:none}.sf-liquid-title i{font-weight:400}.sf-liquid-object{position:absolute;right:5%;top:47%;width:clamp(228px,51cqw,300px);aspect-ratio:1;transform:translateY(-50%);overflow:visible;filter:drop-shadow(0 25px 17px #19051680)}.sf-liquid-grooves{transform-origin:150px 150px;animation:sf-liquid-spin 14s linear infinite;animation-play-state:paused}.sf-liquid-record[data-running=true] .sf-liquid-grooves{animation-play-state:running}.sf-liquid-controls{position:absolute;inset:auto 25px 8px;display:flex;justify-content:space-between;align-items:center;gap:20px;font:12px Arial}.sf-liquid-controls button{min-width:52px;min-height:44px;border:1px solid #ffbee440;border-radius:30px;background:transparent;color:inherit;font:inherit;cursor:pointer}.sf-liquid-controls label{display:flex;align-items:center;gap:12px}.sf-liquid-controls input{width:96px;min-height:44px;accent-color:#ffbee4}.sf-liquid-record :focus-visible{outline:2px solid #fff;outline-offset:3px}@container(max-width:400px){.sf-liquid-title{font-size:28px;left:18px;top:18px;letter-spacing:-1px}.sf-liquid-object{width:220px;right:5%;top:47%}.sf-liquid-controls{inset:auto 18px 6px}}@keyframes sf-liquid-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.sf-liquid-grooves{animation:none!important}}`}</style>
</section>
)
}
Where it works well
Music artwork, material experiments, and motion states for a media interface.
Reduced motion
Both rotation and vector ripple updates stop. Ripple intensity remains adjustable.
Things to know
Visual record motion only; no audio, track playback or audio-reactive analysis.