Ray of light

I am pleased to introduce Ray, the first and most delightful mascot for this very website. Charming in its simplicity, it hides a whimsical interaction and, what's more, it works to showcase the fitting union of SVG and Svelte.

If you are intrigued in how this lovable celestial body came to be, you are in luck, we are going to recreate the mascot one step at a time.

Drawing with SVG

Every respectable vector graphic begins with an <svg> element.

<svg viewBox="0 0 100 100">
	<!-- drawing -->
</svg>

With the viewBox attribute you set a width and a height of 100, creating a squared canvas in the process.

I like to fiddle with the viewBox to modify the origin of the graphics which follow.

-viewBox="0 0 100 100"
+viewBox="-50 -50 100 100"

You are essentially translating the origin as if you were to use a group element.

<svg viewBox="0 0 100 100">
	<g transform="translate(50 50)">
		<!-- drawing -->
	</g>
</svg>

Either approach works. Either approach lets you draw from the very center of the canvas. Tinkering with the viewBox just saves the hassle of an additional element, and a few keystrokes as well.

Body

A <circle> element with a nice shade of yellow seems like a good fit.

<circle r="28" fill="#ffdb47" />

Enough to get started with the body, at least.

Cheeks

Smaller circles with a stronger red hue help to refine the picture.

Group elements prove to be helpful once more, so to avoid repeating attributes on multiple visuals:

  1. paint the elements with a lovable color

    <g fill="#ff877a">
    	<!-- circles -->
    </g>
  2. move the shapes slightly off center, slightly down

    <g fill="#ff877a">
    	<g transform="translate(0 5)">
    		<!-- circles -->
    	</g>
    </g>

For the circles themselves, place one on either side.

<circle r="4" cx="-9" /> <circle r="4" cx="9" />

Mouth

In between the cheeks, and slightly below the connected circles, add a joyful expression with the ever-flexible <path> element.

<path d="M -1.5 0 v 1.5 a 1.5 1.5 0 0 0 3 0 v -1.5z" />

The d attribute might look complex, but it becomes clearer once you break down the individual commands:

  • move 1.5 to the left

  • draw a vertical line 1.5 down

  • draw an arc — a semicircle — 3 to the right

  • retrace the vertical shift 1.5 up

  • close the path

Perhaps an interactive picture is worth more than a few words, however.

d=""

Add a stroke with rounded edges.

<g stroke="#ff877a" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
	<!-- path -->
</g>

And there you have it, a welcoming, soft mug.

Eyes

Just like for the cheeks, add two circles on either side of the body. A dark color and a smaller radius make enough of a difference.

<g fill="#38311e">
	<circle r="2" cx="-5" />
	<circle r="2" cx="5" />
</g>

Ultimately, I decided to add a small stroke matching the yellow shade of the body.

<g fill="#38311e" stroke="#ffdb47" stroke-width="1">
	<!-- eyes -->
</g>

You may not notice the outline — immediately.

As later elements are drawn on top of previous ones, however, the stroke works to have the eyes pop above the cheeks.

Rays

Around the body draw the rays as straight lines, matching the circle in color.

<g fill="none" stroke="#ffdb47" stroke-width="3" stroke-linecap="round">
	<!-- rays -->
</g>

For one ray add a <path> outside of the circle’s area.

<path d="M 35 0 h 7" />

Since we moved the origin of the canvas, you can actually rotate the visual and have it move around the center.

<path transform="rotate(15)" d="M 35 0 h 7" />

It’s almost magical when you try a different value.

Rotate 15 degrees.

Repeat the line with different rotations.

<path transform="rotate(0)" d="M 35 0 h 7" />
<path transform="rotate(15)" d="M 35 0 h 7" />
<path transform="rotate(30)" d="M 35 0 h 7" />
<!-- ... -->

And the drawing is complete.

Drawing with Svelte

15, 30, 45. Adding all those lines around the center one by one works, but thankfully, Svelte offers the {#each} block to make life slightly easier.

What you need is an array of angles. You then loop through the collection adding the <path> with the appropriate rotation.

{#each angles as angle}
	<path transform="rotate({angle})" d="M 35 0 h 7" />
{/each}

With a bit of JavaScript you can recreate the 15 degrees increment with an array of 24 items.

const angles = Array(24)
	.fill()
	.map((_, i, { length }) => (360 / length) * i);

If 24 rays are too many, you then change one line. One number.

-const angles = Array(24)
+const angles = Array(16)

What’s 360 / 16? You don’t really need to know now. And you can skip rotating lines at increments of 22.5 degrees. Oopsie.

Interacting with Svelte

Even if I take a lot of enjoyment out of writing SVG by hand, I can see how people might be thrown off by the imperative syntax. Luckily we now turn the fun part: adding character with spring-based motion.

The mascot is updated in at least two ways, and following mouse interaction:

  • tap the <svg> element to have the drawing bounce

  • move the cursor in the scope of the same element to have the visual follow its position

With this in mind import spring from the svelte/motion module.

import { spring } from 'svelte/motion

Bounce

Initialize a scale value with the helpful spring function.

const scale = spring(1);

You can explore stiffness and damping values to modify the physics of the spring — a nice way to make the mascot more sensible, more reactive.

const scale = spring(1, {
	stiffness: 0.1,
	damping: 0.2
});

Svelte makes it exceedingly fun to play around with the values, but ultimately, it is a matter of preference.

What is less up to preference is how to use the scale value, directly in the markup.

Wrap the drawing in a nice group element and change its scale with the transform attribute. Since spring is technically a store preface the variable with the dollar sign.

<g transform="scale({$scale})">
	<!-- mascot -->
</g>

What is left is then actually animating the scale.

Listen to two events — the goal is to have the scale increase as you tap on the element and resume its original value as you tap away from it.

<svg
	on:mousedown={handleStart}
	on:mouseup={handleEnd}>

In the script update the value with the respective functions.

const handleStart = () => {
	scale.set(1.1);
};

const handleEnd = () => {
	scale.set(1);
};

Quite entertaining already.

Offsets

To follow the cursor initialize a spring with an object, tracking the position on both axes.

const offset = spring({ x: 0, y: 0 });

It would be possible to wrap the entire mascot in a group element, and have the position change with the transform attribute — just with a different value.

<g transform="translate({$offset.x} {$offset.y})">
	<!-- mascot -->
</g>

The effect is however far from ideal.

The mascot follows the cursor — we’ll get to how in a second — but in its entirety. We’re almost spoiling all the work done so far.

The eyes, the cheeks and mouth should move, and certainly more than the body and rays.

<g transform="translate({$offset.x * 0.2} {$offset.y * 0.2})">
	<!-- rays -->
</g>

<g transform="translate({$offset.x * 0.3} {$offset.y * 0.3})">
	<!-- body -->
</g>

<g transform="translate({$offset.x} {$offset.y})">
	<!-- face -->
</g>

You could move only the face, but I prefer animating the entire lot, just with different offset values.

Tracking

The offset object gives us one of the most exciting parts of the demo, but you first need to find the x and y coordinates.

Call a function as the mouse hovers on the <svg> element.

<svg on:mousemove={handleMove}>

The event provides a heaping of values, among which offsetX and offsetY. The two describe the position from the top left corner of the element, but relative to the pixel width and height.

const handleMove = (e) => {
	const { offsetX, offsetY } = e;
	// offsetX [0, width]
	// offsetY [0, height]
};

You need to know the actual size of the <svg> as rendered on the page. With Svelte, this means you need to:

  1. bind a variable to the element

    <script>
    	let svg;
    </script>
    
    <svg bind:this={svg} />
  2. extract the width and height as the component is added to the page — mounted as it were

    let w, h;
    
    onMount(() => {
    	const { width, height } = svg.getBoundingClientRect();
    	w = width;
    	h = height;
    });

Just remember to import the lifecycle function from Svelte.

import { onMount } from 'svelte';

You can now compute the offset in a 0 to 1 range.

const handleMove = ({ offsetX, offsetY }) => {
	const x = offsetX / w; // [0-1]
};

Subtract 0.5 and you finally get a value we can all use.

const x = offsetX / w - 0.5; // [-0.5, 0.5]

We are tracking from the center of the element after all.

const x = offsetX / w - 0.5;
const y = offsetY / h - 0.5;

offset.set({ x, y });

The [-0.5, 0.5] range turned out to give a very small value, so I decided to multiply both coordinates by an arbitrary amount.

const m = 20;
const x = (offsetX / w - 0.5) * m;
const y = (offsetY / h - 0.5) * m;

A lot of work, admittedly. But I’ll go ahead and presume you appreciate the end result — just once more.

Finishing touches

The mascot is essentially done. That being said, there are a couple more tweaks I’m sure you won’t mind reading.

Bounce more

The mouth is wrapped in a group element, itself scaled with the matching value.

<g transform="scale({$scale})">
	<!-- mascot -->
	<g transform="scale({$scale ** 2})">
		<!-- mouth -->
	</g>
</g>

This is but a personal tweak, to have the visual grow more than the larger, surrounding shapes.

A small touch which somebody might notice.

Shrink down

What more people will notice is that the interactive mascot shrank in size midway through. I soon realized the animation potentially pushed the rays outside of the canvas, cropping the elements out of sight.

The viewBox attribute came back to help to scale the entire drawing down.

-viewBox="-50 -50 100 100"
+viewBox="-55 -55 110 110"

Handle reset

The scale is reset when the mouse leaves the element as well — you can’t just leave the mascot stuck in a state of surprise.

<svg
	on:mousedown={handleStart}
	on:mouseup={handleEnd}
+	on:mouseleave={handleEnd}>

The offset is also reset as the mouse leaves the area, so that it is better to invoke a separate function handling both values.

const handleReset = () => {
	scale.set(1);
	offset.set({ x: 0, y: 0 });
};

Handle size

Scalable vector graphics are inherently scalable — who would have guessed it — and in this instance the <svg> stretches to cover available width. This is an incredible asset, but has one unfortunate side effect: resize the window and width and height are no longer accurate.

One way to cope with this change is to extract the sizing logic in a dedicated function.

const handleSize = () => {
	const { width, height } = svg.getBoundingClientRect();
	w = width;
	h = height;
};

Call the function as the component is mounted.

onMount(() => {
	handleSize();
});

Call it once more as the window changes in size — Svelte luckily provides a special element for this express purpose.

<svelte:window on:resize={handleSize} />

Consider touch events, perhaps add a bit of animation, but otherwise give yourself a treat. This article was cheerful, but quite a handful as well.