Canvas Clock
This beginner guide walks you through building a clock with the Canvas API.
In this guide you will learn how to:
- Create custom elements for digital and analog clocks
- Use the
canvas
API to draw the hands of the analog clock
The final widget looks like:
See the Pen Canvas Clock (Simple) [Finished] by Bitovi (@bitovi) on CodePen.
The following sections are broken down into the following parts:
- The problem — A description of what the section is trying to accomplish.
- What you need to know — Information about CanJS that is useful for solving the problem.
- How to verify it works - How to make sure the solution works (if it’s not obvious).
- The solution — The solution to the problem.
Setup
START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED:
See the Pen Canvas Clock (Simple) [Starter] by Bitovi (@bitovi) on CodePen.
This CodePen has initial prototype HTML, CSS, and JS to bootstrap a basic CanJS application.
What you need to know
There’s nothing to do in this step. The CodePen is already setup with:
- A basic CanJS setup.
- A
<clock-controls>
custom element that:- updates a
time
property every second - passes the
time
value to<digital-clock>
and<analog-clock>
components that will be defined in future sections.
- updates a
Please read on to understand the setup.
A Basic CanJS Setup
A basic CanJS setup is usually a custom element. In the HTML
tab, you’ll find a <clock-controls>
element. The following code in the JS
tab
defines the behavior of the <clock-controls>
element:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
class ClockControls extends StacheElement {
static view = `
<p>{{ this.time }}</p>
<digital-clock time:from="this.time"/>
<analog-clock time:from="this.time"/>
`;
static props = {
time: {
get default() {
return new Date();
}
}
};
connected() {
setInterval(() => {
this.time = new Date();
}, 1000);
}
}
customElements.define("clock-controls", ClockControls);
can-stache-element is used to define the behavior of the <clock-controls>
element. Elements are configured with two main properties that define their
behavior:
- view is used as the HTML content within the custom element; by default, it is a can-stache template.
- props provides values to the
view
.
Here, a time
property is defined using the value behavior.
This uses resolve
to set the value of time
to be an instance of a
Date
and then update the value every second to be a new Date
.
Finally, the name of the custom element (e.g. clock-controls
) is added to the
custom element registry so the browser can render the <clock-controls>
element properly.
Create a digital clock component
The problem
In this section, we will:
- Create a
<digital-clock>
custom element. - Pass the
time
from the<clock-controls>
element to the<digital-clock>
element. - Write out the time in the format:
hh:mm:ss
.
What you need to know
- Use can-stache-element to create a custom element.
- view is used as the HTML content within the custom
element; by default, it is a can-stache template (hint:
"Your {{content}}"
). - props provides values to the
view
like:class MyElement extends StacheElemet { static view = `...`; static props = { property: Type, // hint -> time: Date }; method() { return // ... } }
- view is used as the HTML content within the custom
element; by default, it is a can-stache template (hint:
- can-stache can insert the return value from function calls into the page like:
These methods are often functions on the element.{{method()}}
- Date has methods that give you details about that date and time:
- Use padStart
to convert a string like
"1"
into"01"
like.padStart(2, "00")
. - Use customElements.define to
specify the name of the custom element (hint:
"digital-clock"
).
The solution
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
class DigitalClock extends StacheElement {
static view = `{{ hh() }}:{{ mm() }}:{{ ss() }}`;
static props = {
time: Date
};
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
}
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
}
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
customElements.define("digital-clock", DigitalClock);
class ClockControls extends StacheElement {
static view = `
<p>{{ this.time }}</p>
<digital-clock time:from="this.time"/>
<analog-clock time:from="this.time"/>
`;
static props = {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve(new Date());
}, 1000);
resolve(new Date());
return () => clearInterval(intervalID);
}
}
};
}
customElements.define("clock-controls", ClockControls);
Draw a circle in the analog clock component
The problem
In this section, we will:
- Create a
<analog-clock>
custom element. - Draw a circle inside the
<canvas/>
element within the<analog-clock>
.
What you need to know
- Use another custom element to define a
<analog-clock>
component. - Define the component’s view to write out a
<canvas>
element (hint:<canvas id="analog" width="255" height="255"></canvas>
). - An element connected hook will be called when the component is inserted into the page.
- Pass an element reference to the scope, like the following:
<div this:to="key">...</div>
- To get the canvas rendering context
from a
<canvas>
element, usecanvas = canvasElement.getContext("2d")
. - To draw a line (or curve), you generally set different style properties of the rendering context like:
Then you start path with:this.canvas.lineWidth = 4.0 this.canvas.strokeStyle = "#567"
Then make arcs and lines for your path like:this.canvas.beginPath()
Then close the path like:this.canvas.arc(125, 125, 125, 0, Math.PI * 2, true)
Finally, use stroke to actually draw the line:this.canvas.closePath()
this.canvas.stroke();
- The following variables will be useful for coordinates:
this.diameter = 255; this.radius = this.diameter/2 - 5; this.center = this.diameter/2;
The solution
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
class AnalogClock extends StacheElement {
static view = `
<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>
`;
static props = {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
}
};
connected() {
const diameter = 255;
const radius = diameter / 2 - 5;
const center = diameter / 2;
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
}
}
customElements.define("analog-clock", AnalogClock);
class DigitalClock extends StacheElement {
static view = "{{ hh() }}:{{ mm() }}:{{ ss() }}";
static props = {
time: Date
};
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
}
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
}
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
customElements.define("digital-clock", DigitalClock);
class ClockControls extends StacheElement {
static view = `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`;
static props = {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve(new Date());
}, 1000);
resolve(new Date());
return () => clearInterval(intervalID);
}
}
};
}
customElements.define("clock-controls", ClockControls);
Draw the second hand
The problem
In this section, we will:
- Draw the second hand needle when the
time
value changes on the element. - The needle should be
2
pixels wide, red (#FF0000
), and 85% of the clock’s radius.
What you need to know
this.listenTo can be used in a component’s connected hook to listen to changes in the element
props
like:import { StacheElement } from "//unpkg.com/can@6/core.mjs"; class AnalogClock extends StacheElement { static props = { time: Date }, connected() { this.listenTo("time", (event, time) => { // ... }); } }
Use canvas.moveTo(x1,y1) and canvas.lineTo(x2,y2) to draw a line from one position to another.
To get the needle point to move around a “unit” circle, you’d want to make the following calls given the number of seconds:
0s -> .lineTo(.5,0) 15s -> .lineTo(1,.5) 30s -> .lineTo(.5,1) 45s -> .lineTo(0,.5)
Our friends Math.sin and Math.cos can help here… but they take radians.
Use the following
base60ToRadians
method to convert a number from 0–60 to one between 0 and 2π:// 60 = 2π const base60ToRadians = (base60Number) => 2 * Math.PI * base60Number / 60;
The solution
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
// 60 = 2π
const base60ToRadians = base60Number => 2 * Math.PI * base60Number / 60;
class AnalogClock extends StacheElement {
static view = `
<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>
`;
static props = {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
}
};
connected() {
const diameter = 255;
const radius = diameter / 2 - 5;
const center = diameter / 2;
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
this.listenTo("time", (ev, time) => {
this.canvas.clearRect(0, 0, diameter, diameter);
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
Object.assign(this.canvas, {
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
});
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
const size = radius * 0.85;
const x = center + size * Math.sin(base60ToRadians(seconds));
const y = center + size * -1 * Math.cos(base60ToRadians(seconds));
this.canvas.beginPath();
this.canvas.moveTo(center, center);
this.canvas.lineTo(x, y);
this.canvas.closePath();
this.canvas.stroke();
});
}
}
customElements.define("analog-clock", AnalogClock);
class DigitalClock extends StacheElement {
static view = "{{ hh() }}:{{ mm() }}:{{ ss() }}";
static props = {
time: Date
};
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
}
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
}
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
customElements.define("digital-clock", DigitalClock);
class ClockControls extends StacheElement {
static view = `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`;
static props = {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve(new Date());
}, 1000);
resolve(new Date());
return () => clearInterval(intervalID);
}
}
};
}
customElements.define("clock-controls", ClockControls);
Clear the canvas and create a drawNeedle
method
The problem
In this section, we will:
- Clear the canvas before drawing the circle and needle.
- Refactor the needle drawing code into a
drawNeedle(length, base60Distance, styles)
method where:length
is the length in pixels of the needle.base60Distance
is a number between 0–60 representing how far around the clock the needle should be drawn.styles
is an object of canvas context style properties and values like:{ lineWidth: 2.0, strokeStyle: "#FF0000", lineCap: "round" }
What you need to know
- Move the draw circle into the
this.listenTo("time", /* ... */)
event handler so it is redrawn when the time changes. - Use clearRect(x, y, width, height) to clear the canvas.
- Add a function inside the [can-stache-element/lifecycle-methods.connected connected hook] that will have access to all the variables created above it like:
class AnalogClock extends StacheElement { static view = "..."; static props = {}; drawNeedle(length, base60Distance, styles, center) { // ... } }
The solution
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
// 60 = 2π
const base60ToRadians = base60Number => 2 * Math.PI * base60Number / 60;
class AnalogClock extends StacheElement {
static view = `
<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>
`;
static props = {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
}
};
drawNeedle(length, base60Distance, styles, center) {
Object.assign(this.canvas, styles);
const x = center + length * Math.sin(base60ToRadians(base60Distance));
const y = center + length * -1 * Math.cos(base60ToRadians(base60Distance));
this.canvas.beginPath();
this.canvas.moveTo(center, center);
this.canvas.lineTo(x, y);
this.canvas.closePath();
this.canvas.stroke();
}
connected() {
const diameter = 255;
const radius = diameter / 2 - 5;
const center = diameter / 2;
this.listenTo("time", (ev, time) => {
this.canvas.clearRect(0, 0, diameter, diameter);
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
this.drawNeedle(
radius * 0.85,
seconds,
{
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
},
center
);
});
}
}
customElements.define("analog-clock", AnalogClock);
class DigitalClock extends StacheElement {
static view = "{{ hh() }}:{{ mm() }}:{{ ss() }}";
static props = {
time: Date
};
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
}
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
}
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
customElements.define("digital-clock", DigitalClock);
class ClockControls extends StacheElement {
static view = `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`;
static props = {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve(new Date());
}, 1000);
resolve(new Date());
return () => clearInterval(intervalID);
}
}
};
}
customElements.define("clock-controls", ClockControls);
Draw the minute and hour hand
The problem
In this section, we will:
- Draw the minute hand
3
pixels wide, dark gray (#423
), and 65% of the clock’s radius. - Draw the minute hand
4
pixels wide, dark blue (#42F
), and 45% of the clock’s radius.
What you need to know
You know everything at this point. You got this!
The solution
Update the JavaScript tab to:
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
// 60 = 2π
const base60ToRadians = base60Number => 2 * Math.PI * base60Number / 60;
class AnalogClock extends StacheElement {
static view = `
<canvas this:to="this.canvasElement" id="analog" width="255" height="255"></canvas>
`;
static props = {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
}
};
drawNeedle(length, base60Distance, styles, center) {
Object.assign(this.canvas, styles);
const x = center + length * Math.sin(base60ToRadians(base60Distance));
const y = center + length * -1 * Math.cos(base60ToRadians(base60Distance));
this.canvas.beginPath();
this.canvas.moveTo(center, center);
this.canvas.lineTo(x, y);
this.canvas.closePath();
this.canvas.stroke();
}
connected() {
const diameter = 255;
const radius = diameter / 2 - 5;
const center = diameter / 2;
this.listenTo("time", (ev, time) => {
this.canvas.clearRect(0, 0, diameter, diameter);
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
this.drawNeedle(
radius * 0.85,
seconds,
{
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
},
center
);
// draw minute hand
const minutes = time.getMinutes() + seconds / 60;
this.drawNeedle(
radius * 0.65,
minutes,
{
lineWidth: 3.0,
strokeStyle: "#423",
lineCap: "round"
},
center
);
// draw hour hand
const hoursInBase60 = time.getHours() * 60 / 12 + minutes / 60;
this.drawNeedle(
radius * 0.45,
hoursInBase60,
{
lineWidth: 4.0,
strokeStyle: "#42F",
lineCap: "round"
},
center
);
});
}
}
customElements.define("analog-clock", AnalogClock);
class DigitalClock extends StacheElement {
static view = "{{ this.hh() }}:{{ this.mm() }}:{{ this.ss() }}";
static props = {
time: Date
};
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
}
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
}
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
customElements.define("digital-clock", DigitalClock);
class ClockControls extends StacheElement {
static view = `
<p>{{ this.time }}</p>
<digital-clock time:from="this.time"/>
<analog-clock time:from="this.time"/>
`;
static props = {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve(new Date());
}, 1000);
resolve(new Date());
return () => clearInterval(intervalID);
}
}
};
}
customElements.define("clock-controls", ClockControls);
Result
When finished, you should see something like the following CodePen:
See the Pen Canvas Clock (Simple) [Finished] by Bitovi (@bitovi) on CodePen.