Why Port a Vanilla Timer to Vue
A vanilla JavaScript countdown timer works, but it has two structural weaknesses that Vue addresses directly. First, all UI updates live inside the interval function: every tick, the code manually locates the time label, the progress ring, and other elements, then updates each one. Vue's declarative template syntax binds the DOM to component data, eliminating that manual sync work. Second, the original relies on element IDs, so adding a second timer means duplicating IDs and breaking independent behavior. A Vue component encapsulates all logic per instance, letting you drop in 10, 100, or 1,000 timers without touching the component.
The same timer from the original article, rebuilt as a reusable Vue component, looks like this.
Template and Styles
Vue uses an HTML-based template syntax that allows you to declaratively bind the rendered DOM to the underlying Vue instance's data. All Vue.js templates are valid HTML that can be parsed by spec-compliant browsers and HTML parsers.
Start by creating BaseTimer.vue with the standard single-file component structure:
// Our template markup will go here
<template>
// ...
</template>
// Our functional scripts will go here
<script>
// ...
</script>
// Our styling will go here
<style>
// ...
</style>
The <template> and <style> sections hold the exact same SVG markup and CSS from the vanilla version. Within that markup, three parts control the countdown behavior:
stroke-dasharray: sets the visible length of the progress ring's remaining time.remainingPathColor: a class that changes the ring's color as time runs low.formatTime(timeLeft): renders the remaining time as text.
Constants, Data, and Computed Values
Moving into the <script> section, Vue allows constants to stay scoped to the component. The tuned stroke-dasharray value, the color thresholds (orange at 10 seconds, red at five), and the color constants all move up top:
<script>
// A value we had to play with a bit to get right
const FULL_DASH_ARRAY = 283;
// When the timer should change from green to orange
const WARNING_THRESHOLD = 10;
// When the timer should change from orange to red
const ALERT_THRESHOLD = 5;
// The actual colors to use at the info, warning and alert threshholds
const COLOR_CODES = {
info: {
color: "green"
},
warning: {
color: "orange",
threshold: WARNING_THRESHOLD
},
alert: {
color: "red",
threshold: ALERT_THRESHOLD
}
};
// The timer's starting point
const TIME_LIMIT = 20;
</script>
The variables fall into two categories that map to different Vue features:
- Directly reassigned:
timerIntervalandtimePassedchange each second when the timer runs. - Derived:
timeLeftdepends ontimePassed;remainingPathColordepends ontimeLeftbreaching a threshold.
Directly Assigned State
To make a property reactive, declare it on the component's data object. Vue then attaches getters and setters that track and respond to changes:
<script>
// Same as before
export default {
data() {
return {
timePassed: 0,
timerInterval: null
};
}
</script>
Two rules apply here. First, declare every reactive variable up front in data—even with a placeholder value—because a variable added later without declaration won't be reactive. Second, data must always return a new object via return; otherwise, all instances of the component share the same properties.
Derived State with Computed Properties
Derived values like timeLeft belong in computed. A computed property is a function that returns a value, tracks the dependencies it references, and recalculates only when those dependencies change. Results are cached, so unchanged dependencies return the cached value instead of recalculating.
<script>
// Same as before
computed: {
timeLeft() {
return TIME_LIMIT - this.timePassed;
}
}
}
</script>
Computed functions must be pure: no side effects, always return a value, and only depend on their inputs. Several helpers from the vanilla version become computed properties:
circleDasharray— replaces thesetCircleDasharraymethod.formattedTimeLeft— replaces theformatTimemethod.timeFraction— an abstraction ofcalculateTimeFraction.remainingPathColor— an abstraction ofsetRemainingPathColor.
<script>
// Same as before
computed: {
circleDasharray() {
return `${(this.timeFraction * FULL_DASH_ARRAY).toFixed(0)} 283`;
},
formattedTimeLeft() {
const timeLeft = this.timeLeft;
const minutes = Math.floor(timeLeft / 60);
let seconds = timeLeft % 60;
if (seconds < 10) {
seconds = `0${seconds}`;
}
return `${minutes}:${seconds}`;
},
timeLeft() {
return TIME_LIMIT - this.timePassed;
},
timeFraction() {
const rawTimeFraction = this.timeLeft / TIME_LIMIT;
return rawTimeFraction - (1 / TIME_LIMIT) * (1 - rawTimeFraction);
},
remainingPathColor() {
const { alert, warning, info } = COLOR_CODES;
if (this.timeLeft <= alert.threshold) {
return alert.color;
} else if (this.timeLeft <= warning.threshold) {
return warning.color;
} else {
return info.color;
}
}
}
</script>
Binding the Template
The template starts as a direct copy of the original markup:
<template>
<div class="base-timer">
<svg class="base-timer__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g class="base-timer__circle">
<circle class="base-timer__path-elapsed" cx="50" cy="50" r="45"></circle>
<path
id="base-timer-path-remaining"
stroke-dasharray="283"
class="base-timer__path-remaining ${remainingPathColor}"
d="
M 50, 50
m -45, 0
a 45,45 0 1,0 90,0
a 45,45 0 1,0 -90,0
"
></path>
</g>
</svg>
<span
id="base-timer-label"
class="base-timer__label"
>
${formatTime(timeLeft)}
</span>
</div>
</template>
Data and computed properties are accessible in the template. Text values render with Mustache interpolation ({{ }}):
<span
id="base-timer-label"
class="base-timer__label"
>
{{ formattedTimeLeft }}
</span>
Attributes can't use Mustache; they need the v-bind directive instead:
<path v-bind:stroke-dasharray="circleDasharray"></path>
Which also has a shorthand:
<path :stroke-dasharray="circleDasharray"></path>
Class bindings on elements use the same v-bind directive:
<path :class="remainingPathColor"></path>
Here's the fully bound template:
<template>
<div class="base-timer">
<svg class="base-timer__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g class="base-timer__circle">
<circle class="base-timer__path-elapsed" cx="50" cy="50" r="45"></circle>
<path
:stroke-dasharray="circleDasharray"
class="base-timer__path-remaining"
:class="remainingPathColor"
d="
M 50, 50
m -45, 0
a 45,45 0 1,0 90,0
a 45,45 0 1,0 -90,0
"
></path>
</g>
</svg>
<span class="base-timer__label">{{ formattedTimeLeft }}</span>
</div>
</template>
With the template and state in place, all that remains is starting the timer. In the vanilla version, the interval handled value changes and DOM updates together:
function startTimer() {
timerInterval = setInterval(() => {
timePassed = timePassed += 1;
timeLeft = TIME_LIMIT - timePassed;
document.getElementById("base-timer-label").innerHTML = formatTime(
timeLeft
);
setCircleDasharray();
setRemainingPathColor(timeLeft);
if (timeLeft === 0) {
onTimesUp();
}
}, 1000);
}
Now the interval only needs to increment timePassed; the computed properties handle everything downstream:
<script>
// Same as before
methods: {
startTimer() {
this.timerInterval = setInterval(() => (this.timePassed += 1), 1000);
}
}
</script>
Component lifecycle hooks provide the trigger point. Since the timer should start on load, the mounted hook is correct:
<script>
// Same as before
mounted() {
this.startTimer();
},
// Same methods as before
</script>
To use the component elsewhere, import it, register it locally, then instantiate it in the template:
// App.vue
import BaseTimer from "./components/BaseTimer"
export default {
components: {
BaseTimer
}
};
Reusable Result
Porting the timer to Vue yields a self-contained component where markup, logic, and styling are isolated. It won't conflict with other elements, and it works as a child inside larger components—a form, a card, or a dashboard—where the parent can pass properties down and trigger behavior. A live example shows the timer taking orders from a parent component.
The key shift: instead of manually hunting for DOM nodes every second, you declare the relationship between state and UI, and the framework keeps them in sync. Vue's docs cover the reactivity system, computed properties, and lifecycle hooks in more detail—all the features used here.



