Question Details

No question body available.

Tags

javascript html css

Answers (3)

May 2, 2025 Score: 7 Rep: 45,290 Quality: High Completeness: 100%

Update

Example I are concentric circles, Example II is a spiral. The former can detect when a circle (ring) runs out of room it'll go to the next circle upwards. It's not perfect.

SVG

SVG Elements Needed

Element Comment
Of course
This groups SVG elements (used in Example I only)
This is the spiral or circle shape
A wrapper for...
This will mimic a its synced to
This wraps the text - text styles should be assigned here



Concentric Circles (Example I) ⭐

This is the Actual Solution

The requirements for each circle (aka ring) are as follows:

  • for the circle shape. is too simple unfortunately.
  • Two s, two s, and two s. One set of elements is hidden and used to test if a name can fit on the current circle. Two SVG properties are used to compare the size (eg circumference) of the current and the length of the text in the corresponding :
SVG Property Description
getTotalLength() returns the computed value for the total length of a .
getComputedTextLength() returns the computed value for the length of text in a SVG element.

If a name is too big then it proceeds to the next circle. The font-size of each increases for each circle as well. Aspects such as dimensions, viewBox dimensions, radius and xy position of the s, the number of circles etc. are kept in an object (cfg). As an option, the defaults² of cfg (and fSize global variable) can be changed. If cfg default settings are used (recommended), there'll be 18 circles which can contain 62 names of maximum length (20 characters).

see comments in Example I JS for details

Example I

Concentric Circles ⌾

Instructions

  1. Click anywhere within the current browser window.
  2. Enter a name.
  3. Click "Ok" button to add the name or...
  4. click "Cancel" button to exit and not add the name.
  5. Each name is also suffixed with " ❧ " as a delimiter.
  6. When all of the circles are full, the user will be notified via a popover.

Note

All of the names and the current active circle (ring) are autosaved to localStorage. Unfortunately, StackOverflow prohibits our browsers from using localStorage so the code for autosaving is commented out. For a fully functional example that autosaves, review this CodePen.

View the example in Full page mode.

:root { --cast: rgba(0, 0, 0, 0.30) 0 1.1875em 2.375em, rgba(0, 0, 0, 0.22) 0 0.9375em 0.75em; --outline: rgba(60, 64, 67, 0.3) 0 0.0625em 0.125em 0, rgba(60, 64, 67, 0.15) 0 0.125em 0.375em 0.125em; --inner: rgb(204, 219, 232) 0.1875em 0.1875em 0.375em 0 inset, rgba(255, 255, 255, 0.5) -0.1875em -0.1875em 0.375em 0.0625em inset; font-family: "Atkinson Hyperlegible Mono", sans-serif; font-optical-sizing: auto; }

html, body { width: 100%; height: 100%; margin: 0; }

main { display: grid; place-items: center; min-height: 100vh; margin: auto; }

#box { width: fit-content; }

#prompt { padding: 0; border: 0; border-radius: 8px; background: transparent; overflow: hidden; opacity: 0; transform: scaleY(0); transition: 0.7s allow-discrete; box-shadow: var(--cast);

& form { padding-top: 0.5rem; border-radius: 8px; background: transparent; }

& fieldset { padding: 0.75rem 1.25rem 1rem; border: 0; background: #DDD; }

& legend { margin: 0 0 -0.75rem -0.5rem; font-variant: small-caps; font-size: 1.2rem; user-select: none; }

& label { letter-spacing: 1px; user-select: none; }

& input { margin-top: 0.25rem; padding: 0.125rem 0 0.125rem 0.5rem; border: 0.5px inset rgb(240 240 240); border-radius: 4px; outline: 0; font: inherit; box-shadow: var(--inner); }

& footer { display: flex; justify-content: flex-end; align-items: center; gap: 0.25rem; padding: 1rem 0 0; }

& button { width: 4.5rem; padding: 0.25rem; border: 0.5px outset rgb(240 240 240); border-radius: 4px; font: inherit; cursor: pointer; box-shadow: var(--outline); } }

#prompt[open] { background: #DDD; opacity: 1; transform: scaleY(1); }

@starting-style { #prompt[open] { opacity: 0; transform: scaleY(0); } }

#prompt::backdrop { background-color: transparent; transition: 0.7s allow-discrete; }

#prompt[open]::backdrop { background-color: rgb(0 0 0 / 35%); }

@starting-style { #prompt[open]::backdrop { background-color: transparent; } }

#msg { border: 0.5px rgb(128 128 128) solid; font-size: 1.15rem; background: transparent; opacity: 0; transform: scaleY(0); transition: 0.7s allow-discrete; box-shadow: var(--cast); }

#msg:popover-open { background: #FFF; opacity: 1; transform: scaleY(1); }

@starting-style { #msg:popover-open { opacity: 0; transform: scaleY(0); } }

#msg::backdrop { background-color: transparent; transition: 0.7s allow-discrete; }

#msg:popover-open::backdrop { background-color: rgb(0 0 0 / 35%); }

@starting-style { #msg:popover-open::backdrop { background-color: transparent; } }

.info { display: inline-block; font-style: normal; color: rgb(43, 123, 237); }

tspan { font-variant: small-caps; line-height: 1.2; }

Nesting Circles Enter a Name:

Cancel Ok

let SVG, TEXT, TEST, paths, texts, tests, tspans, hide; let data = []; let state = 0; let done = false;

/𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘 data = JSON.parse(localStorage.getItem("names")) || []; state = parseInt(localStorage.getItem("ring"), 10) || 0; done = Boolean(localStorage.getItem("done")) || false;

const saveData = (data) => localStorage.setItem("names", JSON.stringify(data)); const saveState = (state, done) => { localStorage.setItem("ring", JSON.stringify(state)); let int = +done; localStorage.setItem("done", JSON.stringify(int)); };

const setData = (data) => tspans.forEach((ts, i) => ts.textContent = data[i]); 𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘
/

let last = ""; let ring = state; let over = false;

const prt = document.getElementById("prompt"); const ui = document.forms.ui; const io = ui.elements;

const NS = "http://www.w3.org/2000/svg";

/𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖 The following values can be adjusted. @param {number} fSize - The base font-size in px. @param {object} cfg - The configuration settings. @var {number} cfg.vw - viewBox width of the in px. @var {number} cfg.vh - viewBox height of the in px. @var {number} cfg.r - The increment radius of the s in px. @var {number} cfg.font - The increment font-size of the s in px. @var {number} cfg.qty - The number of circles (s) to generate. / const fSize = 18; const cfg = { vw: 1800, vh: 1800, r: 40, font: 3, qty: 18 }; /𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖𝄖/ cfg.cx = cfg.vw / 2; cfg.cy = cfg.vh / 2;

const svgPathGenerator = function
(cfg) { let i = 0; let R = 0; let f = 0; while (i < cfg.qty) { f += (cfg.font 0.25); R += cfg.r + f; let d = ` M ${cfg.cx} -${cfg.cy} m ${R}, 0 a ${R},${R} 0 1,0 -${R 2},0 a ${R},${R} 0 1,0 ${R 2},0 `; const path = document.createElementNS(NS, "path"); path.id = "path" + i; path.setAttribute("d", d); path.setAttribute("fill", "transparent"); path.setAttribute("stroke", "#000"); path.setAttribute("stroke-width", "1"); path.setAttribute("transform", "scale(1,-1)"); i++; yield path; } }; const svgTextGenerator = function(cfg, prefix) { let i = 0; while (i < cfg.qty) { const text = document.createElementNS(NS, "text"); text.setAttribute("width", cfg.cx); const textPath = document.createElementNS(NS, "textPath"); textPath.setAttribute("href", "#path" + i); const tspan = document.createElementNS(NS, "tspan"); tspan.id = prefix + i; tspan.setAttribute("font-size", ((cfg.font i) + fSize) + "px"); text.appendChild(textPath).appendChild(tspan); i++; yield text; } };

const init = (cfg) => { const base = `

`; io.box.insertAdjacentHTML("beforeend", base); SVG = document.getElementById("SVG"); TEXT = document.getElementById("TEXT"); TEST = document.getElementById("TEST"); paths = [...svgPathGenerator(cfg)].map(p => document.getElementById("PATH").appendChild(p)); texts = [...svgTextGenerator(cfg, "text")].map(t => TEXT.appendChild(t)); tests = [...svgTextGenerator(cfg, "test")].map(t => TEST.appendChild(t)); /
𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘 tspans = Array.from(TEXT.querySelectorAll("tspan")); if (state > 0) { setData(data); } 𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘/ if (!done) { prt.showModal(); io.add.focus(); } };

const findPath = () => document.getElementById("path" + ring); const getText = () => document.getElementById("text" + ring); const getTest = () => document.getElementById("test" + ring); const checkLimit = () => { return getTest().getComputedTextLength() > findPath().getTotalLength(); }; const setText = () => { let text = getText(); let test = getTest(); if (io.add.value.trim().length < 1) return; const nameTag = io.add.value + " ❧ "; last = nameTag; test.insertAdjacentText("beforeend", nameTag); over = checkLimit(); if (over) { ring++; if (ring === cfg.qty) { io.msg.textContent = "Circles are full."; io.msg.showPopover(); io.box.onclick = null; done = true; return; } return setText(); } text.insertAdjacentText("beforeend", last); over = checkLimit(); /
𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘 data = tspans.map(ts => ts.textContent || ""); saveData(data); saveState(ring, done); 𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘/ };

io.box.onclick = (e) => prt.showModal(); ui.onreset = (e) => prt.close(); prt.onclose = (e) => { setText(); io.add.value = ""; };

hide = () => setTimeout(() => io.msg.hidePopover(), 3500); const hint = (e) => { io.msg.textContent = "Click anywhere within this browser window to add a name."; io.msg.showPopover(); hide(); }; prt.addEventListener("close", hint, { once: true });

document.onkeydown = (e) => { if (e.code === "KeyC" && e.altKey) { /
𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘 localStorage.clear(); 𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘𝄘/ location.reload(); init(cfg); } };

init(cfg);



The Spiral (Example II)

Example I is the Actual Answer

This isn't what the OP wanted but I'm keeping it here for prosperity. Example I is the actual solution to OP.

  • The code that drew the SVG spiral is here.
  • The procedure of setting text along a curve is here.
  • Besides the research, my small contribution is configuring the so that the text starts outside not at the center.

The markup for the :


The [x] attribute determines where an SVG element is positioned horizontally (left/right). A value of 100% sets the at the end of its parent element normally, but because its synced to the (via ), its true size is greater. I don't know if this is mathematically correct, but this is how I got [x="700%"]:

 

In the example,

  • , , and functions getIntercept()¹, setPoint()¹, and getPath() makes path#spiral by calculating the value of its [d] attribute. (see post)

  • and syncs the text to the (see article)

  • , the rest of the markup, and the event handlers allows the user to add their name to the spiral. (see below).

function name was changed

Example II

Spiral 😵‍💫

Instructions

  1. Click the spiral.
  2. Enter a name.
  3. Click "Ok" button to add the name or...
  4. click "Cancel" button to exit and not add the name.
  5. Each name is also suffixed with " ❧ " as a delimiter.

View the example in Full page mode.

const modal = document.getElementById("modal");

const spiral = document.getElementById("spiral"); const text = document.getElementById("text");

const ui = document.forms.ui; const io = ui.elements;

const getInterept = (x1, y1, x2, y2) => { if (x1 === x2) return; const x = (y2 - y1) / (x1 - x2); return { x: x, y: x1
x + y1 }; };

const setPoint = (point) => { return ${point.x},${point.y} ; };

const getPath = (center, startRadius, spacePerLoop, startTheta, endTheta, thetaStep) => { const a = startRadius; const b = spacePerLoop / Math.PI / 2; let newTheta = startTheta Math.PI / 180; let oldTheta = newTheta; endTheta = endTheta Math.PI / 180; thetaStep = thetaStep Math.PI / 180;

let oldR, newR = a + b
newTheta;

const oldPoint = { x: 0, y: 0 }; const newPoint = { x: center.x + newR Math.cos(newTheta), y: center.y + newR Math.sin(newTheta) };

let oldslope, newSlope = (b Math.sin(oldTheta) + (a + b newTheta) Math.cos(oldTheta)) /

(b
Math.cos(oldTheta) - (a + b newTheta) Math.sin(oldTheta));

let path = "M " + setPoint(newPoint);

while (oldTheta < endTheta - thetaStep) { oldTheta = newTheta; newTheta += thetaStep;

oldR = newR; newR = a + b newTheta;

oldPoint.x = newPoint.x; oldPoint.y = newPoint.y; newPoint.x = center.x + newR
Math.cos(newTheta); newPoint.y = center.y + newR Math.sin(newTheta);

const aPlusBTheta = a + b
newTheta;

oldSlope = newSlope; newSlope = (b Math.sin(newTheta) + aPlusBTheta Math.cos(newTheta)) / (b Math.cos(newTheta) - aPlusBTheta Math.sin(newTheta));

const oldIntercept = -(oldSlope oldR Math.cos(oldTheta) - oldR Math.sin(oldTheta)); const newIntercept = -(newSlope newR Math.cos(newTheta) - newR Math.sin(newTheta));

const controlPoint = getInterept(oldSlope, oldIntercept, newSlope, newIntercept);

controlPoint.x += center.x; controlPoint.y += center.y; path += "Q " + setPoint(controlPoint) + setPoint(newPoint); } return path; };

const setText = () => { const nameTag = io.add.value; if (nameTag.trim().length < 1) return; text.insertAdjacentText("beforeend", nameTag + " ❧ "); };

io.box.onclick = (e) => modal.showModal();

ui.onreset = (e) => { modal.close(); };

modal.onclose = (e) => { setText(); io.add.value = ""; };

const path = getPath({ x: 400, y: 400 }, 0, 50, 0, 6 * 360, 30);

spiral.setAttribute("d", path);
:root {
  font: 2ch/1.5 "Segoe UI";
}

html, body { width: 100%; height: 100%; margin: o; overflow-x: hidden; }

main { display: grid; place-items: center; min-height: 100vh; margin: auto; }

dialog { padding: 0; border: 0; border-radius: 8px; background: transparent; overflow: hidden; opacity: 0; transform: scaleY(0); transition: 0.7s allow-discrete; box-shadow: rgb(38, 57, 77) 0 1.25rem 1.875rem -0.625rem;

& form { padding-top: 0.5rem; border-radius: 8px; background: transparent; }

& fieldset { padding: 0.75rem 1.25rem 1rem; border: 0; background: #DDD; }

& legend { margin: 0 0 -0.75rem -0.5rem; font-variant: small-caps; font-size: 1.2rem; user-select: none; }

& label { letter-spacing: 1px; user-select: none; }

& input { margin-top: 0.25rem; padding: 0.125rem 0 0.125rem 0.5rem; border: 0.5px inset rgb(240 240 240); border-radius: 4px; outline: 0; font: inherit; box-shadow: rgb(204, 219, 232) 0.1875rem 0.1875rem 0.375rem 0 inset, rgba(255, 255, 255, 0.5) -0.1875rem -0.1875rem 0.375rem 0.0625rem inset; }

& footer { display: flex; justify-content: flex-end; align-items: center; gap: 0.25rem; padding: 1rem 0 0; }

& button { width: 4rem; padding: 0.25rem; border: 0.5px outset rgb(240 240 240); border-radius: 4px; font: inherit; cursor: pointer; box-shadow: box-shadow: rgba(60, 64, 67, 0.3) 0 0.0625rem 0.125rem 0, rgba(60, 64, 67, 0.15) 0 0.125rem 0.375rem 0.125rem; } }

dialog[open] { background: #DDD; opacity: 1; transform: scaleY(1); }

@starting-style { dialog[open] { opacity: 0; transform: scaleY(0); } }

dialog::backdrop { background-color: transparent; transition: 0.7s allow-discrete; }

dialog[open]::backdrop { background-color: rgb(0 0 0 / 35%); }

@starting-style { dialog[open]::backdrop { background-color: transparent; } }

#text { font-variant: small-caps; font-size: 2.5rem; line-height: 1; }

Downward Uzumaki Enter a Name:

Ok Cancel

April 29, 2025 Score: 1 Rep: 77 Quality: Low Completeness: 80%

It can be done utilizing an SVG, as shown in this answer: How to make spiral text in html using css or javascript

or you can play with the concept in this pen: https://codepen.io/geoffgraham/pen/NgwWBj

You could use the same concept for concentric circles instead of a spiral

  

Dangerous Curves Ahead

May 2, 2025 Score: 1 Rep: 187 Quality: Low Completeness: 80%

Using the @Squishy idea with some more code:

const startFontSize=22;
const minFontSize=5;
const fontSizeUnit="px";
const fontName="Segoe UI, Arial";
const fontStyle="bold";

const startRadius=210;//biggest circle radius const rate=7/9;//font-size decreasing rate let names=[['Mary','John','Paul','Lisa','Richard','Steve'],['Cristhian','Alex']]; const tsvg="http://www.w3.org/2000/svg";//required by 'path','text' and 'textPath'

const txlink='http://www.w3.org/1999/xlink';//required by 'xlink:href' in 'textPath' const pathRadius=250;//radius of circlePath

//Generate font string function buildFontString(fontSize){ return fontStyle+ ' ' +fontSize +fontSizeUnit+' '+fontName; //font string }

document.addEventListener("DOMContentLoaded",()=>{ const svg=document.querySelector("svg"); const radius=calcRadius();//all radius array

//Get string height function calcStringHeight(fontString) { let text = document.createElementNS(tsvg,"text"); text.style.setProperty("font", fontString); text.textContent = "AAA"; svg.appendChild(text); let bb=text.getBBox(); svg.removeChild(text); return bb.height; }

//Get string width function calcStringWidth(str, fontString) { let text = document.createElementNS(tsvg,"text"); text.style.setProperty("font", fontString); text.textContent = str; svg.appendChild(text); let bb=text.getBBox(); svg.removeChild(text); return bb.width; }

//Calcs all possible circle radius function calcRadius(){ let radius=[]; let fontSize=startFontSize; let stringHeight=calcStringHeight(buildFontString(fontSize)); let currRadius=startRadius-stringHeight; while(fontSize>minFontSize&&currRadius>stringHeightrate) { radius.push(currRadius); fontSize=rate; stringHeight=calcStringHeight(buildFontString(fontSize)); currRadius-=stringHeight; } return radius; }

//Draw one text circle function drawTextCircle(str,index){ const scale=pathRadius/radius[index]; const fontSize=startFontSizeMath.pow(rate,index); const template=document.querySelector("template").content.cloneNode(true); const circle=template.querySelector("g"); circle.id=textCircle${index}; const path=circle.querySelector("path"); path.id=circle${index}; path.setAttribute("transform",translate(${radius[index](scale-1)},${radius[index](scale-1)}),scale(${1/scale})); const text=circle.querySelector("text"); text.style.font=buildFontString(fontSize); const textPath=circle.querySelector("textPath"); textPath.setAttributeNS(txlink,"xlink:href",#circle${index}); textPath.setAttribute("textLength",2Math.PIradius[index]); textPath.textContent=str; svg.appendChild(circle); }

//Insert a name in 'names' array function insertName(name){ const len=names.length; const maxCircles=radius.length; const currRadius=radius[len-1]; const fontSize=startFontSizeMath.pow(rate,len-1); let str="",strWidth=0; if(len>0){//one circle already exist names[len-1].forEach((name) => str+=name+" "); str+=name; strWidth=calcStringWidth(str,buildFontString(fontSize)); //try to insert in the last circle if(strWidth svg.removeChild(circle)); }

drawNames() });

.gratitude1{font:bold 28px Segoe UI, Arial;fill:#7bbf8e;}
.gratitude2{font:bold 32px Segoe UI, Arial;fill:#4a8c6b;}
.gratitude3{font:28px Segoe UI, Arial;fill:#7bbf8e;}

button { padding: 10px 20px; border-radius: 20px; border: none; background: #7bbf8e; color: #fff; cursor: pointer; transition: background 0.2s; } input { padding: 10px 16px; border-radius: 20px; border: 1px solid #b2b2b2; outline: none; }

Thank you for being part of our story.


Insert Clear

Tested with circles up to 250 in radius. Above this, it may need a function for generating circle paths.

Thanks to SVG, Text does not render on textPath when I create it dynamically, SVG get text element width and Refresh/Reload/Restart for SVG on click .

More information in textPath could be found in https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/textPath