- const PATH = "M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544"
h1 Original scale
.container
  svg(xmlns='http://www.w3.org/2000/svg', viewbox='0 0 79.375 79.375')
    path(d='M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544', fill='none', stroke='#000', stroke-width='.265')
  .element(style=`--path: "${PATH}"`)
h1 Scaling container w/ non-scaled path 😭
.container
  svg(xmlns='http://www.w3.org/2000/svg', viewbox='0 0 79.375 79.375')
    path(d='M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544', fill='none', stroke='#000', stroke-width='.265')
  .element(style=`--path: "${PATH}"`)
h1 Scaling container w/ scaled path 🎉🔥
.container.result
  svg
    path
  .element
View Compiled
:root {
  --size: 75;
}
* {
  box-sizing: border-box;
}
body {
  display: -webkit-box;
  display: flex;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  -webkit-box-align: center;
          align-items: center;
  -webkit-box-pack: center;
          justify-content: center;
  min-height: 100vh;
  background: #222;
  padding: 1rem 0;
  -webkit-box-orient: vertical;
  -webkit-box-direction: normal;
          flex-direction: column;
}

.container {
  border: 2px solid #ddd;
  height: calc(var(--size) * 0.5vmin);
  min-height: 200px;
  min-width: 200px;
  position: relative;
  width: calc(var(--size) * 0.5vmin);
}

.container:first-of-type {
  height: 79.375px;
  min-height: 79.375px;
  min-width: 79.375px;
  width: 79.375px;
}

.container:first-of-type .element {
  height: 20px;
  width: 20px;
}

.element {
  height: 40px;
  width: 40px;
  background: rgba(128,191,255,0.5);
  border: 2px #80bfff solid;
  position: absolute;
  top: 0%;
  left: 0%;
  offset-path: path(var(--path));
  -webkit-animation: travel 2s infinite alternate linear;
          animation: travel 2s infinite alternate linear;
}
svg {
  position: absolute;
  opacity: 0.5;
  height: 100%;
  width 100%;
}

svg path {
  fill: none;
  stroke: red;
  stroke-width: 4px;
}

h1 {
  color: #fafafa;
  font-size: 1.5rem;
}

.result path {
  stroke: green;
  stroke-width: 12px;
}

@-webkit-keyframes travel {
  from {
    offset-distance: 0%;
  }
  to {
    offset-distance: 100%;
  }
}
@keyframes travel {
  from {
    offset-distance: 0%;
  }
  to {
    offset-distance: 100%;
  }
}
View Compiled
const { d3 } = window
class Meanderer {
  container
  height
  path
  threshold
  width
  constructor({ height, path, threshold = 0.2, width }) {
    this.height = height
    this.path = path
    this.threshold = threshold
    this.width = width
    // With what we are given create internal references
    this.aspect_ratio = width / height
    // Convert the path into a data set
    this.path_data = this.convertPathToData(path)
    this.maximums = this.getMaximums(this.path_data)
    this.range_ratios = this.getRatios(this.maximums, width, height)
  }
  // This is relevant for when we want to interpolate points to
  // the container scale. We need the minimum and maximum for both X and Y
  getMaximums = data => {
    const X_POINTS = data.map(point => point[0])
    const Y_POINTS = data.map(point => point[1])
    return [
      Math.max(...X_POINTS), // x2
      Math.max(...Y_POINTS), // y2
    ]
  }
  // Generate some ratios based on the data points and the path width and height
  getRatios = (maxs, width, height) => [maxs[0] / width, maxs[1] / height]

  /**
   * Initially convert the path to data. Should only be required
   * once as we are simply scaling it up and down. Only issue could be upscaling??
   * Create high quality paths initially
   */
  convertPathToData = path => {
    // To convert the path data to points, we need an SVG path element.
    const svgContainer = document.createElement('div')
    // To create one though, a quick way is to use innerHTML
    svgContainer.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">
                              <path d="${path}"/>
                            </svg>`
    const pathElement = svgContainer.querySelector('path')
    // Now to gather up the path points using the SVGGeometryElement API 👍
    const DATA = []
    // Iterate over the total length of the path pushing the x and y into
    // a data set for d3 to handle 👍
    for (let p = 0; p < pathElement.getTotalLength(); p++) {
      const { x, y } = pathElement.getPointAtLength(p)
      DATA.push([x, y])
    }
    return DATA
  }

  /**
   * This is where the magic happens.
   * Use ratios etc. to interpolate our data set against our container bounds.
   */
  generatePath = (containerWidth, containerHeight) => {
    const {
      height,
      width,
      aspect_ratio: aspectRatio,
      path_data: data,
      maximums: [maxWidth, maxHeight],
      range_ratios: [widthRatio, heightRatio],
      threshold,
    } = this
    const OFFSETS = [0, 0]
    // Get the aspect ratio defined by the container
    const newAspectRatio = containerWidth / containerHeight
    // We only need to start applying offsets if the aspect ratio of the container is off 👍
    // In here we need to work out which side needs the offset. It's whichever one is smallest in order to centralize.
    // What if the container matches the aspect ratio...
    if (Math.abs(newAspectRatio - aspectRatio) > threshold) {
      // We know the tolerance is off so we need to work out a ratio
      // This works flawlessly. Now we need to check for when the height is less than the width
      if (width < height) {
        const ratio = (height - width) / height
        OFFSETS[0] = (ratio * containerWidth) / 2
      } else {
        const ratio = (width - height) / width
        OFFSETS[1] = (ratio * containerHeight) / 2
      }
    }
    // Create two d3 scales for X and Y
    const xScale = d3
      .scaleLinear()
      .domain([0, maxWidth])
      .range([OFFSETS[0], containerWidth * widthRatio - OFFSETS[0]])
    const yScale = d3
      .scaleLinear()
      .domain([0, maxHeight])
      .range([OFFSETS[1], containerHeight * heightRatio - OFFSETS[1]])
    // Map our data points using the scales
    const SCALED_POINTS = data.map(POINT => [
      xScale(POINT[0]),
      yScale(POINT[1]),
    ])
    return d3.line()(SCALED_POINTS)
  }
}

const path =
  'M10.362 18.996s-6.046 21.453 1.47 25.329c10.158 5.238 18.033-21.308 29.039-18.23 13.125 3.672 18.325 36.55 18.325 36.55l12.031-47.544'
const height = 79.375
const width = 79.375
const container = document.querySelector('.result')

const responsive = new Meanderer({
  path,
  height,
  width,
})

const setPath = () => {
  const scaledPath = responsive.generatePath(container.offsetWidth, container.offsetHeight)
  container.style.setProperty('--path', `"${scaledPath}"`)
  d3.select('.result path').attr('d', scaledPath)
}

const SizeObserver = new ResizeObserver(setPath)
SizeObserver.observe(container)
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/d3/5.15.0/d3.min.js