<div class="wrapper">
	<header>
		<h1>Pixel Art Studio</h1>
		<p>Make your own single-div pixel art with CSS.</p>
	</header>
	<div class="controls">
		<label for="colorPicker">Color picker</label>
		<input type="color" id="colorPicker">
		<label for="cellSize">Columns</label>
		<input type="range" min="10" max="60" id="columns" step="10" value="30">
		<button data-undo>Undo</button>
		<button data-clear>Clear</button>
		<button data-save>Save</button>
	</div>
	<div class="canvas">
		<div class="draw-area">
			<div class="marker"></div>
		</div>
	</div>
	<div class="css">
		<label for="css">CSS:</label>
		<textarea name="cssCode" id="css" rows="10"></textarea>
		<button data-copy>Copy to clipboard</button>
	</div>
</div>
* {
	box-sizing: border-box;
}

body {
	--space: clamp(1rem, 2vw, 2.5rem);
	--shadow: 0.6rem 0.6rem 0;
	font-family: 'VT323', monospace;
	font-size: 1.2rem;
	margin: 0;
	padding: calc(var(--space) * 2) var(--space);
	background: lightblue;
	accent-color: turquoise;
	min-height: 100vh;
	display: flex;
	align-items: center;
	justify-content: center;
}

button {
	font-family: inherit;
	font-size: inherit;
	background: orchid;
	border: 0.22rem solid;
	border-radius: 0.3rem;
	padding: 0.2rem 1rem;
	box-shadow: 0.2rem 0.2rem 0;
}

button:hover,
button:focus-visible {
	background: orange;
}

input[type="color"] {
	border: 0.22rem solid;
	border-radius: 0.3rem;
	box-shadow: 0.2rem 0.2rem 0;
	margin-right: 2rem;
}

h1 {
	font-family: 'Press Start 2P', cursive;
	font-weight: 400;
	margin: 0 0 var(--space);
	text-shadow: 0.1em 0.1em 0 orange;
}

p {
	margin: 0;
}

header {
	outline: 0.3rem solid;
	padding: var(--space);
	margin-bottom: calc(var(--space) * 2);
	box-shadow: var(--shadow);
	background: peachpuff;
}

.wrapper {
	width: 100%;
	max-width: 800px;
	margin: 0 auto;
}

@media (min-width: 70em) {
	.wrapper {
		max-width: 1200px;
		display: grid;
		grid-template-columns: 2fr 1fr;
		gap: 0 2rem;
	}
}

.controls {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 1rem;
	margin-bottom: calc(var(--space) * 1.5);
	grid-column: 1;
}

.canvas {
	--sizeX: var(--cellSizeCol, calc(100% / var(--colCount, 40)));
	--sizeY: var(--cellSizeRow, calc(100% / var(--rowCount, 30)));
	
	grid-column: 1;
	position: relative;
	outline: 0.3rem solid;
	box-shadow: var(--shadow);
	margin-bottom: calc(var(--space) * 2);
	background: linear-gradient(to right, transparent calc(100% - 1px), lightgrey 0),
		linear-gradient(to bottom, transparent calc(100% - 1px), lightgrey 0), white;
	background-size: var(--sizeX) 100%, 100% var(--sizeY);
}

.draw-area {
	position: relative;
	aspect-ratio: 4 / 3;
	background-repeat: no-repeat;
	background-size: var(--sizeX) var(--sizeY);
}

.marker {
	position: absolute;
	width: 100%;
	height: 100%;
	top: 0;
	left: 0;
	background-image: linear-gradient(to right, var(--bg, black), var(--bg, black));
	aspect-ratio: 1;
	background-size: var(--sizeX) var(--sizeY);
	background-repeat: no-repeat;
	opacity: 0;
}

.css label {
	display: block;
}

textarea {
	display: block;
	width: 100%;
	border: 0.3rem 0.3rem 0;
	box-shadow: var(--shadow);
	margin-bottom: var(--space);
	padding: 0 1rem;
}

[data-undo] {
	background: orange;
}
const canvas = d3.select('.canvas')
const colorInput = d3.select('#colorPicker')
const marker = d3.select('.marker')
const clearButton = d3.select('[data-clear]')
const saveButton = d3.select('[data-save]')
const undoButton = d3.select('[data-undo]')
const copyButton = d3.select('[data-copy]')
const textArea = d3.select('#css')
const drawArea = d3.select('.draw-area')
const range = d3.select('#columns')

const width = drawArea.node().offsetWidth
const height = drawArea.node().offsetHeight
let columns = 30
const multiplier = height / width
let rows = columns * multiplier
let cellSize = 100 / columns
let rowSize = 100 / rows
let isPressed = false

const bisect = d3.bisector((d) => d)

const dx = (posX) => {
	const stepX = 1 / (columns - 1)
	const dataX = d3.range(0, 100, stepX)
	const indexX = bisect.center(dataX, posX / width)
	return (dataX[indexX] * 100).toFixed(2)
}

const dy = (posY) => {
	const stepY = 1 / (rows - 1)
	const dataY = d3.range(0, 1, stepY)
	const indexY = bisect.center(dataY, posY / height)
	return (dataY[indexY] * 100).toFixed(2)
}

let bg = []
let bgPosition = []

const draw = () => {
	drawArea
		.style('background-image', bg.join(','))
		.style('background-position', bgPosition.join(','))
}

const updateText = () => {
	textArea.html(`
aspect-ratio: 4 / 3;
background-image: ${bg.join(',')};
background-size: calc(100% / ${columns}) calc(100% / ${rows});
background-position: ${bgPosition.join(',')};
background-repeat: no-repeat;
	`)
}

const shouldDraw = (newBgValue, newBgPositionValue) => {
	if (!isPressed) return false
	if (!bg.length) return true
	if (bg[0] !== newBgValue) return true
	return bgPosition[0] !== newBgPositionValue
}

canvas.on('click', (e) => {
	const color = colorInput.node().value
	const [posX, posY] = d3.pointer(e)
	const x = dx(posX)
	const y = dy(posY)
	
	bg = [ `linear-gradient(${color}, ${color})`, ...bg ]
	bgPosition = [ `${x}% ${y}%`, ...bgPosition]
	
	draw()
	updateText()
})

canvas
	.on('mouseover', () => {
		marker.style('opacity', 1)
	})
	.on('mouseout', () => {
		marker.style('opacity', 0)
	})
	.on('mousedown', () => {
		isPressed = true
	})
	.on('mouseup', () => {
		isPressed = false
	})

canvas.on('mousemove', (e) => {
	const color = colorInput.node().value
	const [posX, posY] = d3.pointer(e)
	const x = dx(posX)
	const y = dy(posY)
	
	marker
		.style('background-position', `${x}% ${y}%`)
	
	const newBgValue = `linear-gradient(${color}, ${color})`
	const newBgPositionValue = `${x}% ${y}%`
	
	if (!shouldDraw(newBgValue, newBgPositionValue)) return
	
	bg = [ newBgValue, ...bg ]
	bgPosition = [ newBgPositionValue, ...bgPosition]

	draw()
	updateText()
})

colorInput.on('input', () => {
	marker.style('--bg', colorInput.node().value)
})

const clear = () => {
	bg = []
	bgPosition = []
	textArea.html('')
	drawArea
		.style('background-image', '')
		.style('background-position', '')
		.style('background-size', '')
}

clearButton.on('click', clear)

saveButton.on('click', () => {
	const art = textArea.node().value
	localStorage.setItem('art', art)
})

const restoreSavedArt = () => {
	const savedArt = localStorage.getItem('art')
	
	if (!savedArt) return
	drawArea.attr('style', savedArt)
	textArea.html(savedArt)
}

const setColumnCount = () => {
	columns = range.node().value
	rows = columns * multiplier
	cellSize = 100 / columns
	rowSize = 100 / rows
	canvas.style('--cellSizeCol', `${cellSize}%`)
	canvas.style('--cellSizeRow', `${rowSize}%`)
	canvas.style('--colCount', columns)
}

setColumnCount()
restoreSavedArt()

window.addEventListener('resize', () => {
	if (window.innerWidth > width + 100) return
	setColumnCount()
})

range.on('input', setColumnCount)

copyButton.on('click', (e) => {
	const copyText = textArea.node()

  copyText.select()
  copyText.setSelectionRange(0, 99999)
	navigator.clipboard.writeText(copyText.value)
	copyButton.text('Copied!')
	
	setTimeout(() => {
		copyButton.text('Copy to clipboard')
	}, 3000)
})

undoButton.on('click', () => {
	bg.splice(0, 1)
	bgPosition.splice(0, 1)
	
	draw()
	updateText()
})

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

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