Skip to main content

Building a Pattern Designer: When Icons Need Space to Breathe

· By Natalie Walls

Building a Pattern Designer: When Icons Need Space to Breathe

I wanted a toy. Something that could take icons—emojis, images, whatever—and scatter them across a canvas in a way that looked organic. Not a rigid grid, not chaotic randomness. Something in between.

The result: nataliewalls.com/pattern-designer

Pattern designer with sparkling hearts

The Problem with Random Placement

First attempt was naive: pick random x/y coordinates, drop an icon there. Repeat.

It looked terrible. Icons stacked on top of each other. Some areas were dense, others empty. The human eye is very good at noticing clustering, and random point generation creates it naturally.

What I needed was evenly distributed randomness. Icons should be scattered organically, but never overlap. Each one needs space to breathe.

Enter Poisson disk sampling.

Poisson Disk Sampling

The algorithm generates points where each point is at least a minimum distance from all others. It's the same technique used in procedural generation for placing trees in video games or stippling in digital art.

Here's the simplified version:

  1. Start with one random point
  2. Try to generate new points around it (within an annulus)
  3. Only accept points that maintain minimum distance from all existing points
  4. Repeat until you can't place any more points

The trick is using a spatial grid for fast lookups. Instead of checking distance against every existing point (O(n²)), you only check nearby grid cells (O(1) average case).

export function poissonDiskSampling(
  width: number,
  height: number,
  minDistance: number,
  maxAttempts: number = 30
): Point[] {
  const cellSize = minDistance / Math.sqrt(2);
  const gridWidth = Math.ceil(width / cellSize);
  const gridHeight = Math.ceil(height / cellSize);
  
  const grid: Array<Point | null> = new Array(gridWidth * gridHeight).fill(null);
  const points: Point[] = [];
  const activeList: Point[] = [];
  
  // ... implementation details
}

The result? Icons that look hand-placed, but are actually algorithmically positioned to never overlap.

The Spacing Problem

Early feedback: "Way too many icons. Even on low settings."

The issue was my "density" slider. I thought "icons per 1000px²" made sense. It did not.

What users actually wanted was control over spacing between icons. Lower spacing = more icons. Higher spacing = fewer icons.

The fix:

// Old: confusing density calculation
const targetIcons = Math.floor(area * config.density);

// New: spacing-based minDistance
const baseIconSize = 50;
const minDistance = baseIconSize * spacing * 0.8;

Now the slider goes from 0.5x (tight) to 10.0x (very loose), with 0.1 increments for fine control in the sweet spot (1.0x-2.5x).

At spacing=1.5x on an 800×600 canvas, you get about 133 icons—nicely spaced, no overlap.

Auto-Regeneration

Originally, you had to hit "Generate Pattern" every time you changed a setting. Annoying.

Solution: debounced auto-regeneration.

When any config value changes (spacing, dimensions, colors, frequency ratios), a timer starts. If another change happens within 300ms, the timer resets. When the timer expires, the pattern regenerates automatically.

useEffect(() => {
  if (config.icons.length === 0) return;
  
  if (debounceTimer.current) {
    clearTimeout(debounceTimer.current);
  }
  
  debounceTimer.current = setTimeout(() => {
    handleGenerate();
  }, 300); // 300ms debounce
  
  return () => {
    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }
  };
}, [config.density, config.canvasWidth, config.canvasHeight, /* ... */]);

Now the pattern updates as you drag the spacing slider. Feels responsive without hammering the CPU.

Icon Sources

Three tabs for adding icons:

User Uploads - Drag-and-drop images (PNG, JPEG, SVG, GIF). Basic but functional.

Font Awesome - Placeholder for now. Will integrate properly later.

Emoji - The interesting one. I already had a SurrealDB instance with 3,944 Unicode emojis from the /counters page. Reused that connection. Search by name, browse by group (Smileys, Animals, Food, etc.). Only fully-qualified emojis (no unqualified variants that render inconsistently).

export async function searchEmojis(
  query: string,
  group?: string,
  limit: number = 50
): Promise<EmojiData[]> {
  const sql = `
    SELECT codepoints, emoji_char, description, emoji_group, subgroup
    FROM unicode_emoji_catalog
    WHERE status = 'fully-qualified'
      AND string::lowercase(description) CONTAINS string::lowercase($query)
    LIMIT $limit;
  `;
  
  const result = await connection.query(sql, { query, limit });
  return result[0] || [];
}

Frequency Ratios

You can add multiple icons and control their relative distribution. Icon A at ratio 3, Icon B at ratio 1 = roughly 75% A, 25% B.

The implementation creates a weighted pool, shuffles it, and assigns one icon per point:

const iconPool: string[] = [];

for (const icon of config.icons) {
  const ratio = config.frequencyRatios[icon.id] || 1;
  const count = Math.round((ratio / totalRatio) * points.length);
  
  for (let i = 0; i < count; i++) {
    iconPool.push(icon.id);
  }
}

// Shuffle for random distribution
for (let i = iconPool.length - 1; i > 0; i--) {
  const j = Math.floor(Math.random() * (i + 1));
  [iconPool[i], iconPool[j]] = [iconPool[j], iconPool[i]];
}

There's also an "Evenly Distribute" button that resets all ratios to 1:1.

Per-Icon Controls

Each icon can have custom settings:

  • Size range (30-60px default, prevents overlap even with variation)
  • Rotation range (0-360°, or lock it to 0° for no rotation)
  • Opacity range (0.7-1.0 default)
  • Color override (optional tint)

These are ranges, not fixed values. Every placed icon gets a random value within the range. Adds visual variety without breaking the spacing.

Export

Three formats:

PNG - Rasterized at canvas dimensions. Good for social media.

JPEG - Smaller file size, 90% quality. Best for photos/backgrounds.

SVG - Vector format. Scales infinitely, huge file if you have 500 icons.

The export uses Canvas API for raster formats:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

// Draw background
ctx.fillStyle = config.backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Draw each icon with transforms
for (const icon of placedIcons) {
  ctx.save();
  ctx.translate(icon.x, icon.y);
  ctx.rotate((icon.rotation * Math.PI) / 180);
  ctx.globalAlpha = icon.opacity;
  
  // Draw emoji as text
  ctx.font = `${icon.size}px sans-serif`;
  ctx.fillText(icon.emojiChar, 0, 0);
  
  ctx.restore();
}

canvas.toBlob((blob) => {
  downloadFile(blob, `pattern-${Date.now()}.png`);
}, 'image/png');

SVG export builds an XML string with <text> elements for each icon.

Layout Iterations

Version 1: Controls above canvas. Lots of scrolling.

Version 2: Sidebar with tabs (Icon Browser / Pattern Controls). Better, but canvas still had dimension inputs and export buttons cluttering it.

Version 3: All controls in sidebar. Canvas is just the pattern + zoom controls. Clean.

The final layout: 320px sidebar on left, full remaining width for canvas. Canvas is centered vertically with flex, scales with zoom, no distractions.

Default Experience

The page loads with a sparkling heart (💖) already selected and a pattern auto-generated. No empty state, no tutorial needed. You can immediately:

  • Drag the spacing slider → pattern updates
  • Change background color → updates
  • Add more emojis → updates
  • Click "Regenerate" → new random layout, same icons

First-time users see something working instantly. That's important.

Example Patterns

Here are a few patterns created with the tool to show the range of styles possible:

Sleep-themed pattern with sleeping emojis and beds

Heartbreak pattern with crying cats and broken hearts

Easter pattern with bunnies, eggs, baskets, and religious symbols

Each pattern uses different emoji combinations, spacing settings, and background colors. The same Poisson disk sampling algorithm ensures icons never overlap, whether you're making something cozy, emotional, or seasonal.

What I Learned

Spatial algorithms are fun. Poisson disk sampling is elegant and produces beautiful results. The implementation is more complex than "just random," but worth it.

UX is iterative. The density slider made sense to me (technically correct!), but confused users. The spacing metaphor clicked immediately. Sometimes you need to throw away the "correct" solution for the one people understand.

Debouncing saves CPU. 300ms is the sweet spot for sliders. Short enough to feel instant, long enough to avoid regenerating 10 times while dragging.

Reuse infrastructure. I already had SurrealDB with emoji data from another feature. Took 10 minutes to add emoji search to this tool. Don't rebuild what you already have.

Default to something. Empty states are intimidating. Loading with a default icon (💖) lets users immediately see what the tool does and start tweaking.

Try It

nataliewalls.com/pattern-designer

Drag the spacing slider, add some emojis, hit export. Make weird patterns. Make beautiful patterns. Make patterns that are objectively terrible but you love anyway.

It's a toy. That's the point.


Built in an afternoon with Next.js, TypeScript, and SurrealDB. All 3,944 emojis from Unicode 17.0.