<input type="checkbox" id="toggle" hidden />
  <label for="toggle" data-app-elm="toggle">
    <svg viewBox="0 0 24 24">
      <circle cx="14" cy="6" r="2" />
      <line x1="4" y1="6" x2="12" y2="6" />
      <line x1="16" y1="6" x2="20" y2="6" />
      <circle cx="8" cy="12" r="2" />
      <line x1="4" y1="12" x2="6" y2="12" />
      <line x1="10" y1="12" x2="20" y2="12" />
      <circle cx="17" cy="18" r="2" />
      <line x1="4" y1="18" x2="15" y2="18" />
      <line x1="19" y1="18" x2="20" y2="18" />
    </svg>
    <svg viewBox="0 0 24 24">
      <line x1="18" y1="6" x2="6" y2="18" />
      <line x1="6" y1="6" x2="18" y2="18" />
    </svg>
  </label>

  <svg data-app-elm="svg" id="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000" style="background-color:hsl(248, 50%, 25%)">
    <g id="g"></g>
  </svg>

  <main>
    <form id="app" data-app="dotring">
      <details open>
        <summary>dots & rings</summary>
        <div>
          <label>
            number of rings
            <input type="range" id="numRings" min="1" max="30" data-random />
          </label>
          <label>
            dot size
            <input type="range" id="dotSize" min="1" max="30" data-random />
          </label>
          <label>
            dots per ring
            <input type="range" id="dotsPerRing" min="1" max="30" data-random />
          </label>
          <label>
            spread
            <input type="range" id="spread" min="1" max="50" data-random />
          </label>
          <label data-app-elm="checkbox">
            <input type="checkbox" id="randomRadius" data-random />
            <span>random radius</span>
          </label>
          <label data-app-elm="checkbox">
            <input type="checkbox" id="randomDotSize" data-random />
            <span>random dot-size</span>
          </label>
        </div>
      </details>

      <details>
        <summary>color ranges</summary>
        <div>
          <label>
            hue min
            <input type="range" id="hueMin" min="0" max="360" data-random />
          </label>
          <label>
            hue max
            <input type="range" id="hueMax" min="0" max="360" data-random />
          </label>
          <label>
            saturation min
            <input type="range" id="satMin" min="0" max="100" data-suffix="%" data-random />
          </label>
          <label>
            saturation max
            <input type="range" id="satMax" min="0" max="100" data-suffix="%" data-random />
          </label>
          <label>
            lightness min
            <input type="range" id="lightMin" min="0" max="100" data-suffix="%" data-random />
          </label>
          <label>
            lightness max
            <input type="range" id="lightMax" min="0" max="100" data-suffix="%" data-random />
          </label>
          <label>
            custom color-array:
            <input type="text" id="colorArray" placeholder="#FFF|white|hsl(0,0%,100%)" />
          </label>
        </div>
      </details>

      <details>
        <summary>background / canvas</summary>
        <div>
          <label>
            hue
            <input type="range" id="hueBg" min="0" max="360" data-random data-attr />
          </label>
          <label>
            saturation
            <input type="range" id="satBg" min="0" max="100" data-suffix="%" data-random data-attr />
          </label>
          <label>
            lightness
            <input type="range" id="lightBg" min="0" max="100" data-suffix="%" data-random data-attr />
          </label>
          <label>
            canvas y:x ratio
            <input type="range" id="canvasRatio" min="25" max="150" value="100" />
          </label>
          <label>
            rotate
            <input type="range" min="0" max="360" value="0" id="rotate" data-attr />
          </label>
          <label>
            scale-x
            <input type="range" min="0.01" max="3" step="0.01" value="1" id="scaleX" data-attr />
          </label>
          <label>
            scale-y
            <input type="range" min="0.01" max="3" step="0.01" value="1" id="scaleY" data-attr />
          </label>
          <label>
            translate-x
            <input type="range" min="-1000" max="1000" value="0" id="translateX" data-attr />
          </label>
          <label>
            translate-y
            <input type="range" min="-1000" max="1000" value="0" id="translateY" data-attr />
          </label>
        </div>
      </details>

      <details>
        <summary>export</summary>
        <div>
          <fieldset>
            <legend>file format</legend>
            <label data-app-elm="radio">
              <input type="radio" name="fileFormat" value="svg" data-ignore>
              <span>svg</span>
            </label>
            <label data-app-elm="radio">
              <input type="radio" name="fileFormat" value="" data-ignore checked>
              <span>png</span>
            </label>
            <label data-app-elm="radio">
              <input type="radio" name="fileFormat" value="jpg" data-ignore>
              <span>jpg</span>
            </label>
            <label data-app-elm="radio">
              <input type="radio" name="fileFormat" value="webp" data-ignore>
              <span>webp</span>
            </label>
          </fieldset>
          <button type="button" onclick="saveFile(svg, app.elements.fileFormat.value)">Save To Image</button>
        </div>
      </details>
      <br />
      <button type="button" onclick="randomPreset()">Random!</button>
    </form>
  </main>
* { 
  box-sizing: border-box;
	margin: unset;
}

html {
  block-size: 100%;
  inline-size: 100%;
}

body {
  --app-w: 20rem;
  --bgc: hsla(200, 30%, 85%, 0.95);
  --bg-w: 100%;
  background-color: var(--bgc);
  min-block-size: 100%;
  min-inline-size: 100%;
}

[data-app] {
  --accent: hsl(200, 50%, 50%);
	--accent-bg: hsl(200, 30%, 55%);
  --border: hsl(200, 30%, 30%);
  --bdrs: 0.15rem;
  --gap: 1rem;
  --rng-h: 2rem;

  background: var(--bgc);
  border-inline-start: 2px solid var(--border);
  bottom: 0;
  font-family: ui-monospace, monospace;
  height: 100vh;
  left: var(--app-l, 100%);
  overflow-y: auto;
  padding-inline: var(--gap);
  position: fixed;
  right: 0;
  top: 0;
  transition: left 0.5s cubic-bezier(.35, .92, 1, 1);
  width: var(--app-w);
  z-index: 1;
}

[data-app] button {
  background-color: var(--accent-bg);
  border: 1px solid var(--border);
  box-shadow: 5px 5px 0 0 var(--border);
  font-family: inherit;
  padding: calc(var(--gap) / 2) var(--gap);
}

button[data-app-elm="preset"] {
  background-color: hsl(200, 30%, 20%);
  border: 0;
  border-radius: 0.75rem;
  box-shadow: none;
  color: hsl(200, 30%, 95%);
  font-family: inherit;
  font-size: x-small;
  margin: 0 0.25rem .5rem 0;
  padding: 0.25rem 0.75rem
}

[data-app-elm="checkbox"],
[data-app-elm="radio"] {
  display: block;
  font-family: ui-monospace, monospace;
  margin-block-end: var(--gap);
}

[data-app-elm="checkbox"] span,
[data-app-elm="radio"] span {
  align-items: center;
  display: flex;
  position: relative;
}

[data-app-elm="checkbox"] span::before,
[data-app-elm="radio"] span::before {
  background-color: var(--accent-bg);
  border: 2px solid var(--border);
  border-radius:50%;
  content: '';
  display: inline-block;
  height: 1.5rem;
  margin-inline-end: 0.5rem;
  width: 1.5rem;
}

[data-app-elm="checkbox"] input,
[data-app-elm="radio"] input {
  clip: rect(0 0 0 0); 
  clip-path: inset(50%);
  height: 1px;
  left: 0;
  overflow: hidden;
  position: absolute;
  white-space: nowrap; 
  width: 1px;
}

[data-app-elm] input:checked + span::before {
  background-color: var(--border);
  box-shadow: inset 0 0 0 6px var(--accent-bg);
}

[data-app-elm="checkbox"] span::before {
  border-radius: 0.25rem;
}

[data-app-elm="svg"] {
  transition: width 0.5s cubic-bezier(.35, .92, 1, 1);
  width: var(--bg-w);
}

[data-app-elm="toggle"] {
  background-color: hsl(200, 15%, 15%);
  border-color: hsl(200, 15%, 95%);
  border-style: solid;
  border-width: 0 1px 1px 0;
  height: 44px;
  position: absolute;
  width: 44px;
}

[data-app-elm="toggle"] svg {
  fill: none;
  position: absolute;
  stroke: hsl(200, 15%, 95%);
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 1;
  transition: opacity 1s cubic-bezier(.35, .92, 1, 1);
}

[data-app-elm="toggle"] svg:last-of-type {
  opacity: 0;
}

[data-app] fieldset {
  border: 1px solid var(--accent-bg);
  border-radius: var(--bdrs);
  margin-block-end: var(--gap);
  padding: var(--gap);
}

[data-app] label {
  display: block;
  position: relative;
}

[data-app] label::after {
  content: attr(data-value);
  display: inline-block;
  font-size: x-small;
  position: absolute;
  right: 0;
  top: 0.5em;
}

[data-app] summary {
	border-bottom: 2px dashed var(--accent-bg);
	cursor: pointer;
	padding-block: var(--gap);
	user-select: none;
}

[data-app] summary + div {
	border-bottom: 2px dashed var(--accent-bg);
	padding-block: var(--gap);
}

[data-app] [type="range"] {
  --rng-bdrs: .5rem;
  --rng-bgi: linear-gradient(to right, var(--accent-bg), var(--accent-bg));
  --rng-h: 0.5rem;
  --rng-m: var(--gap) 0;
  --rng-thumb-bdrs: 50%;
  --rng-thumb-bgc: var(--accent);
  --rng-thumb-bxsh: inset 0 0 0 0.125rem var(--border);
  --rng-thumb-bxsh--focus: inset 0 0 0 0.125rem var(--border), 0 0 0 0.125rem  rgba(255, 255, 255, 0.8);
  --rng-thumb-h: 2rem;
  --rng-thumb-w: 2rem;

  background-image: var(--rng-bgi, inherit);
  border-radius: var(--rng-bdrs, 0);
  font-family: inherit;
  height: var(--rng-h);
  margin: var(--rng-m, 0);
  outline: 0.25rem solid transparent;
  position: relative;
  touch-action: none;
  width: 100%;
}

[data-app] [type="range"][max="360"] {
	--s: 60%;
  --rng-bgi: linear-gradient(to right, 
  hsla(0, var(--s), 50%, 0.8), 
  hsla(30, var(--s), 50%, 0.8), 
  hsla(60, var(--s), 50%, 0.8), 
  hsla(90, var(--s), 50%, 0.8), 
  hsla(120, var(--s), 50%, 0.8), 
  hsla(150, var(--s), 50%, 0.8), 
  hsla(180, var(--s), 50%, 0.8), 
  hsla(210, var(--s), 50%, 0.8), 
  hsla(240, var(--s), 50%, 0.8), 
  hsla(270, var(--s), 50%, 0.8), 
  hsla(300, var(--s), 50%, 0.8), 
  hsla(330, var(--s), 50%, 0.8),
  hsla(360, var(--s), 50%, 0.8)
  );
}

[data-app] [type="range"]::-moz-range-thumb {
  background-color: var(--rng-thumb-bgc);
  border-radius: var(--rng-thumb-bdrs);
  box-shadow: var(--rng-thumb-bxsh);
  color: #000;
  cursor: ew-resize;
  height: var(--rng-thumb-h);  
  margin-top: calc(0px - ((var(--rng-thumb-h) - var(--rng-h)) / 2));
  position: relative;
  width: var(--rng-thumb-w);
}

[data-app] [type="range"]::-webkit-slider-thumb {
  background-color: var(--rng-thumb-bgc);
  border-radius: var(--rng-thumb-bdrs);
  box-shadow: var(--rng-thumb-bxsh);
  cursor: ew-resize;
  height: var(--rng-thumb-h);  
  margin-top: calc(0px - ((var(--rng-thumb-h) - var(--rng-h)) / 2));
  position: relative;
  width: var(--rng-thumb-w);
}

[data-app] [type="range"]:focus-visible::-webkit-slider-thumb {
  box-shadow: var(--rng-thumb-bxsh--focus);
}

[data-app] [type="range"]::-moz-range-track {
  background: transparent;
  background-size: 100%;
  height: var(--rng-h);
}

[data-app] [type="range"]::-webkit-slider-runnable-track {
  background: transparent;
  background-size: 100%;
  height: var(--rng-h);
}

[data-app] [type="range"],
[data-app] [type="range"]::-webkit-slider-runnable-track,
[data-app] [type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
}

[data-app] [type="text"] {
  border: 1px solid var(--border);
  border-radius: var(--bdrs);
  font-family: inherit;
  margin-block-end: var(--gap);
  margin-block-start: calc(var(--gap) / 4);
  padding: calc(var(--gap) / 2);
  width: 100%;
}

/* STATE */
#toggle:checked ~ [data-app] { --app-l: 25vw; }
#toggle:checked + [data-app-elm="toggle"] svg:first-of-type { opacity: 0; }
#toggle:checked + [data-app-elm="toggle"] svg:last-of-type { opacity: 1; }
#toggle:checked ~ [data-app-elm="svg"] {
  --bg-w: calc(100% - var(--app-w));
}
#toggle:checked ~ main [data-app] {
  --app-l: calc(100% - var(--app-w));
}
const apps = {
	dotring: () => {
		/* Returns an array of points (`number`) placed on a circle with `radius` */
		const coords = (number, arr = []) => {
			const frags = 360 / number;
			for (let i = 0; i <= number; i++) {
					arr.push((frags / 180) * i * Math.PI);
			}
			return arr;
		}
		const colors = colorArray.value.split('|');
		const useColorArray = colors.length > 1;

		const fill = () => useColorArray ? colors[R(colors.length)] : `hsl(${R(hueMax.valueAsNumber, hueMin.valueAsNumber)}, ${R(satMax.valueAsNumber, satMin.valueAsNumber)}%, ${R(lightMax.valueAsNumber, lightMin.valueAsNumber)}%)`;
		let s = `<circle cx="500" cy="500" r="${dotSize.valueAsNumber}" fill="${fill()}" />`;

		for (let i = 1; i <= numRings.valueAsNumber; i++ ) {
			const r = randomRadius.checked ? R(500,1) : spread.valueAsNumber * i;
			const theta = coords(dotsPerRing.valueAsNumber * i);
			for (let j = 0; j < theta.length; j++) {
				const x = 500 - Math.round(r * (Math.cos(theta[j])));
				const y = 500 - Math.round(r * (Math.sin(theta[j])));
				s+= `<circle cx="${x}" cy="${y}" r="${randomDotSize.checked ? R(35,2) : dotSize.valueAsNumber}" fill="${fill()}" />`
			}
		}
		g.innerHTML = s;
	}
}

function render(input) {
	if (input?.type === 'range') {
		input.parentNode.dataset.value = `${input.valueAsNumber}${input.dataset.suffix||''}`;
		if (input.name) document.body.style.setProperty(input.name, `${input.valueAsNumber}${input.dataset.suffix||''}`)
	}
	if (input?.hasAttribute('data-ignore')) return;
	if (input?.hasAttribute('data-attr')) {
		setBG();
		g.setAttribute('transform', `rotate(${rotate.valueAsNumber}, 500, ${canvasRatio.valueAsNumber * 5}) scale(${scaleX.valueAsNumber}, ${scaleY.valueAsNumber}) translate(${translateX.valueAsNumber}, ${translateY.valueAsNumber})`);
		return;
	}
	svg.setAttribute('viewBox', `0 0 1000 ${canvasRatio.valueAsNumber * 10}`);
	apps[app.dataset.app]();
}

/**
 * @function loadPreset
 * @description Loads a preset, renders preview
 * @param {Object} preset
*/
function loadPreset(preset) {
	Object.entries(preset).forEach(entry => {
		const [key, value] = [...entry];
		if (app.elements[key]?.type === 'checkbox') { 
			app.elements[key].checked = value === 1;
		}
		else {
			app.elements[key].value = value;
			app.elements[key].parentNode.dataset.value = value;
		}
	});
	render();
}

/**
 * @function R
 * @description returns a random number between max and min
 * @param {Number} max
 * @param {Number} [min]
 * @param {Boolean} [f]
*/
function R(max, min = 0, f = true) {
	return f ? Math.floor(Math.random() * (max - min) + min) : Math.random() * max;
};

/**
 * @function randomPreset
 * @description Creates a random preset
*/
function randomPreset() {
	app.reset();
	[...app.elements].forEach(input => {
		if (input.hasAttribute('data-random')) {
			if (input.type === 'checkbox') {
				input.checked = R(10, 0) > 5;
			}
			if (input.type === 'range') {
				input.value = R(input.max-0, input.min-0);
				input.parentNode.dataset.value = `${input.value}${input.dataset.suffix||''}`;
			}
		}
	});
	setBG();
	render();
}

/**
 * @function saveFile
 * @description Exports canvas to either svg, png, jpg or webp
 * @param {Node} svg
 * @param {String} [ext]
*/
function saveFile(svg, ext = 'png') {
	const download = (href, name) => {
		const L = document.createElement('a');
		L.download = name;
		L.style.opacity = "0";
		document.body.append(L);
		L.href = href;
		L.click();
		L.remove()
	}
	const {width, height} = svg.getBBox(); 
	let clone = svg.outerHTML;
	const blob = new Blob([clone],{type:'image/svg+xml;charset=utf-8'});
	const format = { png: '', jpg: 'image/jpg', webp: 'image/webp' };
	if (ext === 'svg') {
		download(URL.createObjectURL(blob), 'image.svg');
		return;
	}
	const img = new Image();
	img.onload = () => {
		const C = document.createElement('canvas');
		C.width = width;
		C.height = height;
		const X = C.getContext('2d');
		X.drawImage(img, 0, 0, width, height);
		download(C.toDataURL(format[ext], 1), `image.${ext}`)
	};
	img.src = URL.createObjectURL(blob);
}

/**
 * @function setBG
 * @description Sets canvas-background
*/
function setBG() {
	svg.setAttribute('style', `background-color: hsl(${hueBg.valueAsNumber}, ${satBg.valueAsNumber}%, ${lightBg.valueAsNumber}%)`);
}

/**
 * @function toggle
 * @description toggle a menu-group
 * @param {Event} event
*/
function toggle(event) {
	summary.forEach(node => {
		if (node !== event.target) node.parentNode.open = false;
	});
}

/* Init */
const summary = app.querySelectorAll('summary');
summary.forEach(node => node.addEventListener('click', toggle));
app.addEventListener('input', (event) => { if (event.target) render(event.target); });

const preset = {
  numRings: 12,
  dotSize: 12,
  dotsPerRing: 7,
  spread: 36,
  randomRadius: 0,
  randomDotSize: 0,
  hueMin: 25,
  hueMax: 50,
  satMin: 50,
  satMax: 90,
  lightMin: 60,
  lightMax: 90,
  hueBg: 248,
  satBg: 50,
  lightBg: 25
}
loadPreset(preset);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.