<text-packing></text-packing>
body {
  margin: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
}

text-packing {
  transform: scale(3, 3);
  border-radius: 4px;
  width: 120px;
  height: 120px;
  border-radius: 50%;
  box-shadow: 0 0 1px 1px rgba(0,0,0,0.15);
  overflow: hidden;
}
class Circle {
    constructor(x, y, maxWidth, maxHeight) {
        this.x = x
        this.y = y
        this.maxWidth = maxWidth
        this.maxHeight = maxHeight
        this.zoom = 1 //+ Math.round(Math.random() * 10)
        this.r = Math.random() * this.zoom
        this.valid = true
        this.padding = 0.9 //Math.round(Math.random() * 10) / 10
        this.growthRate = 0.001 //+ Math.round(Math.random() * 10) / 10
    }

    create() {
        this.el = document.createElement("div")
        this.update()
    }

    update() {
        this.el.style.transform = `translate(${this.x}px, ${this.y}px) 
          scale(${this.r * 2 * this.padding}, ${this.r * 2 * this.padding})`
    }

    grow() {
        this.r = Math.round((this.r + this.growthRate) * 1E4) / 1E4;
    }

    edges() {
        return (
            this.x - this.r < 0 ||
            this.x + this.r > this.maxWidth ||
            this.y - this.r < 0 ||
            this.y + this.r > this.maxHeight
        );
    }
}

const textPackingTemplate = document.createElement('template')
textPackingTemplate.innerHTML = `
<style>
  :host {
    display: block;
    --color: black;
  }
  div {
    --size: 1px;
    position: absolute;
    left: 0;
    top: 0;
    width: var(--size);
    height: var(--size);
    border-radius: 50%;
  }
</style>
`
class TextPacking extends HTMLElement {
    static get colorCombinations() {
        return [
            [{ h: 35, s: 90, l: 50 }, { h: 110, s: 50, l: 40 }],
            [{ h: 80, s: 50, l: 40 }, { h: 25, s: 90, l: 70 }],
            [{ h: 100, s: 20, l: 40 }, { h: 355, s: 80, l: 70 }]
        ]
    }

    get delta() {
        return [
            Math.round(-10 + Math.random() * 20),
            Math.round(-20 + Math.random() * 40),
            Math.round(-20 + Math.random() * 40)
        ]
    }
    constructor() {
        super()
        this.attachShadow({ mode: 'open' })
        this.shadowRoot.appendChild(textPackingTemplate.content.cloneNode(true))
        const canvas = document.createElement('canvas')
        this.context = canvas.getContext('2d')
        const idx = Math.floor(TextPacking.colorCombinations.length * Math.random())
        this.colorCombination = TextPacking.colorCombinations[idx]
    }

    getPixels() {
        this.context.font = 'italic bold 75px Times'
        this.context.textAlign = 'center'
        this.context.textBaseline = 'middle'
        const number = Math.round(Math.random()*100)
        const text = this.textContent.length > 0 ? this.textContent : number
        this.context.fillText(text, this.width / 2, this.height / 2)
        const pixels = this.context.getImageData(0, 0, this.width, this.height)
        const data = pixels.data
        let textCoords = []
        let bgdCoords = []
        for (let i = 0; i < data.length; i += 4) {
            const r = data[i]
            const g = data[i + 1]
            const b = data[i + 2]
            const a = data[i + 3]
            const index = i / 4
            const y = Math.floor(index / this.width)
            const x = index - (y * this.width)
            if (r + g + b === 0 && a > 0) {
                textCoords.push([x, y])
            } else {
                bgdCoords.push([x, y])
            }
        }
        return [textCoords, bgdCoords]
    }

    getRandomCoord(idx) {
        const index = Math.round(Math.random() * this.pixels[idx].length)
        const coord = this.pixels[idx][index]
        this.pixels[idx].splice(index, 1)
        return coord
    }

    checkValidity(circle, idx) {
        const circles = idx === 0 ? this.textCircles : this.bgdCircles
        for (const c of circles) {
            if (c !== circle) {
                const dist = Math.sqrt(
                    Math.pow(circle.x - c.x, 2) + Math.pow(circle.y - c.y, 2)
                );
                if (dist < c.r + circle.r) {
                    circle.valid = false;
                    break;
                }
            }
        }
    }

    newCircle(idx) {
        const [x, y] = this.getRandomCoord(idx)
        const circle = new Circle(x, y, this.width, this.height)
        this.checkValidity(circle, idx)
        return circle
    }

    getColor(hsl) {
        const delta = this.delta
        return `hsl(
                  ${hsl.h + delta[0]},
                  ${hsl.s + delta[1]}%,
                  ${hsl.l + delta[2]}%
              )`
    }

    circlePacking() {
        let i = 0
        while (i < 5) {
            const textCircle = this.newCircle(0)
            if (textCircle.valid) {
                textCircle.create()
                textCircle.el.style.backgroundColor = this.getColor(this.colorCombination[1])
                this.shadowRoot.appendChild(textCircle.el)
                this.textCircles.push(textCircle)
            }

            for (const c of this.textCircles) {
                this.checkValidity(c)
                if (!c.edges() && c.valid) {
                    c.grow()
                    c.update()
                }
            }
            i++
        }
        let j = 0
        while (j < 15) {
            const bgdCircle = this.newCircle(1)
            if (bgdCircle.valid) {
                bgdCircle.create()
                bgdCircle.el.style.backgroundColor = this.getColor(this.colorCombination[0])
                this.shadowRoot.appendChild(bgdCircle.el)
                this.bgdCircles.push(bgdCircle)
            }

            for (const c of this.bgdCircles) {
                this.checkValidity(c)
                if (!c.edges() && c.valid) {
                    c.grow()
                    c.update()
                }
            }
            j++
        }

        if (this.textCircles.length < 1000 && this.bgdCircles.length < 3000)
            requestAnimationFrame(this.circlePacking)
    }

    init() {
        this.textCircles = []
        const [textX, textY] = this.getRandomCoord(0)
        this.textCircles.push(new Circle(textX, textY, this.width, this.height))
        this.textCircles[0].create()
        this.shadowRoot.appendChild(this.textCircles[0].el)

        this.bgdCircles = []
        const [bgdX, bgdY] = this.getRandomCoord(1)
        this.bgdCircles.push(new Circle(bgdX, bgdY, this.width, this.height))
        this.bgdCircles[0].create()
        this.shadowRoot.appendChild(this.bgdCircles[0].el)
    }

    connectedCallback() {
        this.width = this.context.canvas.width = this.shadowRoot.host.clientWidth
        this.height = this.context.canvas.height = this.shadowRoot.host.clientHeight
        this.pixels = this.getPixels()

        this.init()
        // this.newCircle = this.newCircle.bind(this)
        this.getRandomCoord = this.getRandomCoord.bind(this)
        this.circlePacking = this.circlePacking.bind(this)
        this.circlePacking()
    }
}
customElements.define('text-packing', TextPacking)
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.2.0/webcomponents-sd-ce.js