<u7n-polygon polygon-segments="round 15, 0 0, 100 0, 100 100, 80 100, sharp 100 140, 30 100, 0 100" polygon-resize="20 20" class="bubble">
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Blanditiis culpa autem non, in corporis fugiat adipisci natus sunt iste atque id earum numquam architecto, excepturi obcaecati velit provident veniam iure?</p>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Voluptate, assumenda quisquam similique reiciendis reprehenderit porro et maxime impedit. Quaerat, aspernatur harum. Accusantium reiciendis, quo provident illum assumenda tempora eveniet laborum.</p>
</u7n-polygon>
body {
  background: linear-gradient(-45deg, #008, #800);
  min-height: 100vh;
  margin: 0;
  display: grid;
  align-items: start;
}
.bubble {
  margin: 10px;
  background: linear-gradient(-10deg, red, yellow);
  padding: 20px 20px 60px;
  clip-path: path(var(--u7n-polygon-path));
}

:root { font-family: system-ui; }
p { margin-block: 0; }
p + p { margin-block-start: 0.5em; }
// https://gist.github.com/ivan-u7n/153356f16f708d605eea0390efe70286
/*
the <u7n-polygon> custom element provides the extended polygon with rounded corners and resizing

the polygon data is set via the 'polygon-segments' attribute, segments are separated with a semicolon and points within a segment are separated with a comma
each point (except the first on of a segment) must have two numbers for X and Y coordinates respectively
numbers can have optional units however all calculations are performed in pixels regardless of specified units
besides coordinates, points can have modifiers: setting a radius of rounding and setting a variant of rounding
a radius (not the ideal term, but nothing more compelling comes to mind) is set by the keywords 'radius' or 'round' followed by a number, the default radius is 0
a variant is specified by one of the keywords:
'arc' a circular arc between segments, the default variant;
'sharp' a sharp turn between segments without any rounding;
'concave' an inlaid circular arc between segments, not a rounding by itself and kinda an inverse of 'arc';
'quadratic' a quadratic Bezier curve between segments with the point itself acting as the control point;
'cubic' a cubic Bezier curve with the control points halfway between the point itself and the "radius" along the segments
if the first point of a segment has no coordinates but includes a radius and/or a variant, then these values are used to the end of the attribute as the defaults
example: round 10, concave 0 0 round 20, sharp 80 0, 81 19, sharp 100 20, quadratic 100 100, cubic 0 100; cubic, 30 30, arc 30 70, 70 70, arc 70 30

the presence of the 'polygon-resize' attribute makes the polygon resizable to stretch to the size of the element
a resize range is specified by a pair of numbers: points with coordinates equal to or greater than this number pair (ending at the next pair) are shifted linearly
when shifting, a proportionally calculated scaling factor is applied to fill up the size of the element
the calculated scale can be overridden with the keyword 'scale' followed by a number in the range between 0 and 1
example: 35 35 scale 0.4, 65 65

the result is an SVG path set to the --u7n-polygon-path CSS property, and thus available to any children
also on every SVG path recalculation the 'u7n-polygon.path' event is emitted on the element
the event is bubbling and has the SVG path as its 'detail' property
*/
(() => {

const Point = class {
	x = 0;
	y = 0;

	constructor(x = 0, y = 0) {
		this.x = x;
		this.y = y;
	}

	toLength() {
		return Math.hypot(this.x, this.y);
	}
	toAngle() {
		return Math.atan2(this.y, this.x);
	}

	asVector(destinationX, destinationY) {
		return new this.constructor(destinationX - this.x, destinationY - this.y);
	}
	asUnit() {
		const length = this.toLength();
		return new this.constructor(this.x / length, this.y / length);
	}

	atDistance(distance, direction) {
		return new this.constructor(this.x + distance * direction.x, this.y + distance * direction.y);
	}
};

const U7nPolygonElement = class extends HTMLElement {
	static get _VARIANT_ARC() { return 'arc'; }
	static get _VARIANT_SHARP() { return 'sharp'; }
	static get _VARIANT_CONCAVE() { return 'concave'; }
	static get _VARIANT_QUADRATIC() { return 'quadratic'; }
	static get _VARIANT_CUBIC() { return 'cubic'; }

	static get _PATTERN_NUMBER() {
		return '[+\\-]?(?:[0-9]+(?!\\.)|[0-9]*\\.[0-9]+)(?:[eE][+\\-]?[0-9]+)?';
	}
	static get _PATTERN_UNIT() {
		return '%|[a-zA-Z_\\-][0-9a-zA-Z_\\-]*';
	}

	static _parseSegmentsString(segmentsString) {
		if (segmentsString == null) return null;

		const context = {
			radius: 0,
			variant: this._VARIANT_ARC,
		};
		const segments = [];

		for (const pointsString of segmentsString.split(';')) {
			const points = this._parsePointsString(context, pointsString);
			if (points === null) return null;
			segments.push(points);
		}

		return segments;
	}

	static _parsePointsString(context, pointsString) {
		const tokenPattern = new RegExp(
			[
				'\\b(?<radius>radius|round)\\b',
				'\\b(?<variant>' + [
					this._VARIANT_ARC,
					this._VARIANT_SHARP,
					this._VARIANT_CONCAVE,
					this._VARIANT_QUADRATIC,
					this._VARIANT_CUBIC,
				].join('|') + ')\\b',
				'(?<number>' + this._PATTERN_NUMBER + ')(?<unit>' + this._PATTERN_UNIT + ')?',
				'(?<unknown>\\S+)'
			].join('|'),
			'gi',
		);
		const points = [];

		for (const item of pointsString.split(',')) {
			let state = 0;
			let xy = [];
			let radius = null;
			let variant = null;
			for (const match of item.matchAll(tokenPattern)) {
				if (state === 0 && match.groups.radius != null) {
					if (xy.length === 1) {
						state = -1;
						break;
					}
					state = 1;
				}
				else if (state === 1 && match.groups.number != null) {
					radius = Math.max(0, parseFloat(match.groups.number));
					state = 0;
				}
				else if (state === 0 && match.groups.variant != null) {
					if (xy.length === 1) {
						state = -1;
						break;
					}
					variant = match.groups.variant;
				}
				else if (state === 0 && match.groups.number != null && xy.length < 2) {
					xy.push(parseFloat(match.groups.number));
				}
				else {
					state = -1;
					break;
				}
			}
			if (state !== 0) return null;
			if (points.length === 0 && xy.length === 0) {
				if (radius !== null) {
					context.radius = radius;
				}
				if (variant !== null) {
					context.variant = variant;
				}
				continue;
			}
			if (xy.length !== 2) return null;

			points.push({
				x: xy[0],
				y: xy[1],
				radius: radius ?? context.radius,
				variant: variant ?? context.variant,
			});
		}

		return points;
	}

	static _buildPath(segments) {
		const path = [];
		for (const points of segments) {
			if (points.length < 3) continue;
			for (const [ idx, item, ] of points.entries()) {
				const from = points[idx - 1 >= 0 ? idx - 1 : points.length - 1];
				const next = points[idx + 1 < points.length ? idx + 1 : 0];

				if (false
					|| item.radius == 0
					|| item.variant === this._VARIANT_SHARP
					|| item.x === from.x && item.y === from.y
					|| item.x === next.x && item.y === next.y
				) {
					path.push([ !idx ? 'M' : 'L', item.x, item.y, ]);
					continue;
				}

				const itemPoint = new Point(item.x, item.y);
				const fromVector = itemPoint.asVector(from.x, from.y);
				const nextVector = itemPoint.asVector(next.x, next.y);
				const fromLength = fromVector.toLength();
				const nextLength = nextVector.toLength();
				const fromDirection = fromVector.asUnit();
				const nextDirection = nextVector.asUnit();

				if (item.variant === this._VARIANT_ARC) {
					let angle = nextDirection.toAngle() - fromDirection.toAngle();
					if (angle < -Math.PI || angle > Math.PI) {
						angle -= Math.sign(angle) * 2 * Math.PI;
					}
					if (Math.abs(angle) < Math.PI / 180 || Math.abs(angle) > Math.Pi / 180 * 179) {
						path.push([ !idx ? 'M' : 'L', item.x, item.y, ]);
						continue;
					}

					const angleTangent = Math.tan(Math.abs(angle) / 2);
					const distance = Math.min(fromLength / 2, nextLength / 2, item.radius / angleTangent);
					const fromPoint = itemPoint.atDistance(distance, fromDirection);
					const nextPoint = itemPoint.atDistance(distance, nextDirection);
					const radius = distance * angleTangent;

					path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
					path.push([ 'A', radius, radius, 0, false, angle < 0, nextPoint.x, nextPoint.y, ]);
				}
				else if (item.variant === this._VARIANT_CONCAVE) {
					let angle = nextDirection.toAngle() - fromDirection.toAngle();
					if (angle < -Math.PI || angle > Math.PI) {
						angle -= Math.sign(angle) * 2 * Math.PI;
					}

					const distance = Math.min(fromLength / 2, nextLength / 2, item.radius);
					const fromPoint = itemPoint.atDistance(distance, fromDirection);
					const nextPoint = itemPoint.atDistance(distance, nextDirection);

					path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
					path.push([ 'A', distance, distance, 0, false, angle > 0, nextPoint.x, nextPoint.y, ]);
				}
				else if (item.variant === this._VARIANT_QUADRATIC) {
					const distance = Math.min(fromLength / 2, nextLength / 2, item.radius);
					const fromPoint = itemPoint.atDistance(distance, fromDirection);
					const nextPoint = itemPoint.atDistance(distance, nextDirection);

					path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
					path.push([ 'Q', item.x, item.y, nextPoint.x, nextPoint.y, ]);
				}
				else if (item.variant === this._VARIANT_CUBIC) {
					const distance = Math.min(fromLength / 2, nextLength / 2, item.radius);
					const fromPoint = itemPoint.atDistance(distance, fromDirection);
					const nextPoint = itemPoint.atDistance(distance, nextDirection);
					const fromControl = itemPoint.atDistance(distance / 2, fromDirection);
					const nextControl = itemPoint.atDistance(distance / 2, nextDirection);

					path.push([ !idx ? 'M' : 'L', fromPoint.x, fromPoint.y, ]);
					path.push([ 'C', fromControl.x, fromControl.y, nextControl.x, nextControl.y, nextPoint.x, nextPoint.y, ]);
				}
			}
			path.push([ 'Z', ]);
		}
		return path;
	}

	static _stringifyPath(path, precision = 2) {
		precision = Math.max(Math.round(precision), 0);
		const result = [];
		for (const command of path) {
			if (result.length) {
				result.push(' ');
			}
			for (const [ idx, item, ] of command.entries()) {
				if (typeof item === 'string') {
					result.push(item);
				}
				else if (typeof item === 'boolean') {
					result.push(item ? ',1' : ',0');
				}
				else if (typeof item === 'number') {
					let value = item.toFixed(precision);
					if (item >= 0) {
						value = '+' + value;
					}
					if (precision) {
						value = value.replace(/\.?0+$/, '');
					}
					result.push(value);
				}
			}
		}
		return result.join('');
	}

	static _parseResizeString(resizeString) {
		if (resizeString == null) return null;

		const tokenPattern = new RegExp(
			[
				'(?<scale>scale)',
				'(?<number>' + this._PATTERN_NUMBER + ')(?<unit>' + this._PATTERN_UNIT + ')?',
				'(?<unknown>\\S+)'
			].join('|'),
			'gi',
		);
		const resize = {
			ranges: [],
			minX: null, 
			minY: null,
			maxX: null,
			maxY: null,
			get sizeX() { return this.maxX == null || this.minX == null ? null : this.maxX - this.minX; },
			get sizeY() { return this.maxY == null || this.minY == null ? null : this.maxY - this.minY; },
			x: null,
			y: null,
		};
		if (resizeString === '') return resize;

		for (const item of resizeString.split(',')) {
			let state = 0;
			let xy = [];
			let scale = null;
			for (const match of item.matchAll(tokenPattern)) {
				if (state === 0 && match.groups.scale != null) {
					if (xy.length === 1) {
						state = -1;
						break;
					}
					state = 1;
				}
				else if (state === 1 && match.groups.number != null) {
					scale = Math.max(0, Math.min(parseFloat(match.groups.number), 1));
					state = 0;
				}
				else if (state === 0 && match.groups.number != null && xy.length < 2) {
					xy.push(parseFloat(match.groups.number));
				}
				else {
					state = -1;
					break;
				}
			}
			if (state !== 0) return null;
			if (xy.length !== 2) return null;

			resize.ranges.push({
				x: xy[0],
				y: xy[1],
				scale: scale,
			});
		}

		return resize;
	}

	static _resizeSegments(segments, resize) {
		if (resize.x == null || resize.y == null) return segments;

		if (resize.minX == null || resize.minY == null || resize.maxX == null || resize.maxY == null) {
			resize.minX = +Infinity;
			resize.minY = +Infinity;
			resize.maxX = -Infinity;
			resize.maxY = -Infinity;
			for (const points of segments) {
				for (const item of points) {
					resize.minX = Math.min(resize.minX, item.x);
					resize.minY = Math.min(resize.minY, item.y);
					resize.maxX = Math.max(resize.maxX, item.x);
					resize.maxY = Math.max(resize.maxY, item.y);
				}
			}

			if (!resize.ranges.length) {
				resize.ranges.push({
					x: resize.minX + resize.sizeX / 2,
					y: resize.minY + resize.sizeY / 2,
					scale: null,
				});
			}

			for (const item of resize.ranges) {
				item.x = Math.max(resize.minX, Math.min(item.x, resize.maxX));
				item.y = Math.max(resize.minY, Math.min(item.y, resize.maxY));
			}
			resize.ranges.sort((a, b) => {
				const deltaX = a.x - b.x;
				if (deltaX != 0) return deltaX;
				return a.y - b.y;
			});

			const perItem = 1 / resize.ranges.length;
			for (const [ idx, item, ] of resize.ranges.entries()) {
				item.scale ??= (idx + 1) * perItem;
			}
			resize.ranges.at(-1).scale = 1;
		}

		const resizedSegments = [];
		for (const points of segments) {
			const resizedPoints = [];
			for (const item of points) {
				let deltaX = 0;
				for (const range of resize.ranges) {
					if (range.x > item.x) break;
					deltaX = (resize.x - resize.sizeX) * range.scale;
				}

				let deltaY = 0;
				for (const range of resize.ranges) {
					if (range.y > item.y) break;
					deltaY = (resize.y - resize.sizeY) * range.scale;
				}
				
				resizedPoints.push({
					x: item.x + deltaX,
					y: item.y + deltaY,
					radius: item.radius,
					variant: item.variant,
				});
			}
			resizedSegments.push(resizedPoints);
		}
		return resizedSegments;
	}

	static get _PROPERTY_PATH() { return '--u7n-polygon-path'; }
	static get _EVENT_PATH() { return 'u7n-polygon.path'; }

	_segments = [];
	_resize = null;

	_updatePath() {
		let segments = this._segments;
		if (this._resize) {
			segments = this.constructor._resizeSegments(segments, this._resize);
		}
		const path = this.constructor._buildPath(segments);
		const pathString = this.constructor._stringifyPath(path);
		this.style.setProperty(this.constructor._PROPERTY_PATH, `"${pathString}"`);
		this.dispatchEvent(new CustomEvent(this.constructor._EVENT_PATH, {
			bubbles: true,
			cancelable: false,
			detail: pathString,
		}));
		return this;
	}

	static _observerResizeCallback(entries, observer) {
		for (const entry of entries) {
			entry.target._resize.x = entry.borderBoxSize[0].inlineSize;
			entry.target._resize.y = entry.borderBoxSize[0].blockSize;
			entry.target._updatePath();
		}
	}
	static _observerResize = new ResizeObserver(this._observerResizeCallback);

	static get _ATTRIBUTE_SEGMENTS() { return 'polygon-segments'; }
	static get _ATTRIBUTE_RESIZE() { return 'polygon-resize'; }
	//static get _ATTRIBUTE_PROPERTY() { return 'polygon-property'; }

	static get observedAttributes() {
		return [
			this._ATTRIBUTE_SEGMENTS,
			this._ATTRIBUTE_RESIZE,
			//this._ATTRIBUTE_PROPERTY,
		];
	}

	constructor() {
		super();
		this.attachShadow({ mode: 'open', });
	}

	_connected = false;

	attributeChangedCallback(name, oldValue, newValue) {
		if (newValue === oldValue) return;
		if (name === this.constructor._ATTRIBUTE_SEGMENTS) {
			this._segments = this.constructor._parseSegmentsString(newValue) ?? [];
			if (this._connected) {
				this._updatePath();
			}
		}
		else if (name === this.constructor._ATTRIBUTE_RESIZE) {
			if (this._resize && this._connected) {
				this.constructor._observerResize.unobserve(this);
			}
			this._resize = this.constructor._parseResizeString(newValue);
			if (this._resize && this._connected) {
				this.constructor._observerResize.observe(this);
			}
		}
	}

	connectedCallback() {
		this.shadowRoot.innerHTML = '<style>:host { display: block; }</style><slot></slot>';
		this._connected = true;
		if (this._resize) {
			this.constructor._observerResize.observe(this);
		}
		else {
			this._updatePath();
		}
	}

	disconnectedCallback() {
		if (this._resize) {
			this.constructor._observerResize.unobserve(this);
		}
		this._connected = false;
		this.shadowRoot.innerHTML = null;
	}
};

customElements.define('u7n-polygon', U7nPolygonElement);

})();

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.