javascriptcssanimationcss-animations

Add a progress animation to a circle


I'm doing a Pomodoro Timer using HTML+CSS+JavaScript.

I want to have a progress animation around the circle that starts at the top of the circle and rotates clockwise until completing the full circle.

I've already tried a lot of different ways to do the code but no matter what I do, I can't make it to work right.

Here is my code:

const bells = new Audio('./sounds/bell.wav'); 
const startBtn = document.querySelector('.btn-start');
const pauseBtn = document.querySelector('.btn-pause');
const resetBtn = document.querySelector('.btn-reset'); 
const session = document.querySelector('.minutes');
const sessionInput = document.querySelector('#session-length');
const breakInput = document.querySelector('#break-length');

let myInterval; 
let state = true;
let isPaused = false
let totalSeconds;
let initialSeconds;

const updateTimerDisplay = () => {
  const minuteDiv = document.querySelector('.minutes');
  const secondDiv = document.querySelector('.seconds');

  let minutesLeft = Math.floor(totalSeconds / 60);
  let secondsLeft = totalSeconds % 60;

  secondDiv.textContent = secondsLeft < 10 ? '0' + secondsLeft : secondsLeft;
  minuteDiv.textContent = `${minutesLeft}`;

  // Update the circle animation
  const leftSide = document.querySelector('.left-side');
  const rightSide = document.querySelector('.right-side');
  
  const duration = initialSeconds; // Total duration in seconds
  const elapsed = initialSeconds - totalSeconds;
  const percentage = (elapsed / duration) * 100;


let rotationRight, rotationLeft;

  if (percentage <= 50) {
      // First half: rotate right side down from top to bottom
      rotationRight = (percentage / 50) * 180 - 90;
      rotationLeft = -90; // Keep left side at the top
  } else {
      // Second half: rotate left side up from bottom to top
      rotationRight = 90; // Keep right side at the bottom
      rotationLeft = ((percentage - 50) / 50) * 180 + 90;
  }

  rightSide.style.transform = `rotate(${rotationRight}deg)`;
  leftSide.style.transform = `rotate(${rotationLeft}deg)`;
};

const appTimer = () => {
  const sessionAmount = Number.parseInt(sessionInput.value)
  if (isNaN(sessionAmount) || sessionAmount <= 0) {
    alert('Please enter a valid session duration.');
    return;
  }
  session.textContent = sessionAmount;  // Update the session display

  if(state) {
    state = false;
    totalSeconds = sessionAmount * 60;
    initialSeconds = totalSeconds;

    myInterval = setInterval(() => {
      if (!isPaused) {
        totalSeconds--;
        updateTimerDisplay();

        if (totalSeconds <= 0) {
          bells.play();
          clearInterval(myInterval);
          startBreakTimer();
        }
      }
    }, 1000);
  } else {
    alert('Session has already started.');
  }
};

const pauseTimer = () => {
  if (!state) {
    isPaused = !isPaused;
    pauseBtn.textContent = isPaused ? 'resume' : 'pause';
  }
}

const startBreakTimer = () => {
  const breakAmount = Number.parseInt(breakInput.value);
  if (isNaN(breakAmount) || breakAmount <= 0) {
    alert('Please enter a valid break duration.');
    return;
  }
  totalSeconds = breakAmount * 60;
  initialSeconds = totalSeconds;

    myInterval = setInterval(() => {
    if (!isPaused) {
      totalSeconds--;
      updateTimerDisplay();

      if (totalSeconds <= 0) {
        bells.play();
        clearInterval(myInterval);
        state = true;
      }
    }
  }, 1000);
};

const resetTimer = () => {
  clearInterval(myInterval);
  state = true;
  isPaused = false;
  pauseBtn.textContent = 'pause';
  const minuteDiv = document.querySelector('.minutes');
  const secondDiv = document.querySelector('.seconds');
  minuteDiv.textContent = sessionInput.value;;
  secondDiv.textContent = '00';

  // Reset circle animation
  const leftSide = document.querySelector('.left-side');
  const rightSide = document.querySelector('.right-side');
  leftSide.style.transform = 'rotate(-90deg)';
  rightSide.style.transform = 'rotate(-90deg)';
}

startBtn.addEventListener('click', appTimer);
pauseBtn.addEventListener('click', pauseTimer);
resetBtn.addEventListener('click', resetTimer);
html {
  font-family: 'Fira Sans', sans-serif;
  font-size: 20px;
  letter-spacing: 0.8px;
  min-height: 100vh;
  color: #d8e9ef;
  background-image: linear-gradient(-20deg, #025159 0%, #733b36 100%);
  background-size: cover;
}

h1 {
  margin: 0 auto 10px auto;
  color: #d8e9ef;
}

p {
  margin: 0;
}

.app-message {
  height: 20px;
  margin: 10px auto 20px auto;
}

.app-container {
  width: 250px;
  height: 420px;
  margin: 40px auto;
  text-align: center;
  border-radius: 5px;
  padding: 20px;
}

/*@keyframes rotate-right-from-top {
  0% {
    transform: rotate(-90deg); 
  }
  100% {
    transform: rotate(90deg); 
  }
}

@keyframes rotate-left-from-top {
  0% {
    transform: rotate(-90deg); 
  }
  100% {
    transform: rotate(90deg); 
  }
}*/

.app-circle {
  position: relative;
  margin: 0 auto;
  width: 200px;
  height: 200px;
}

.circle-shape {
  pointer-events: none;
}

.semi-circle {
  position: absolute;
  width: 100px;
  height: 200px;
  box-sizing: border-box;
  border: solid 6px;
  transform: rotate(-90deg);
  transform-origin: 50% 100%; /* Set the transform origin to the bottom center */
}


.left-side {
  top: 0;
  left: 0;
  transform-origin: right center;
  /*transform: rotate(0deg);*/
  border-top-left-radius: 100px;
  border-bottom-left-radius: 100px;
  border-right: none;
  z-index: 1;
  /*animation-name: rotate-left-from-top;*/
}

 .right-side {
  top: 0;
  left: 100px;
  transform-origin: left center;
  /*transform: rotate(0deg);*/
  border-top-right-radius: 100px;
  border-bottom-right-radius: 100px;
  border-left: none;
  /*animation-name: rotate-right-from-top;*/
}

.circle {
  border-color: #bf5239;
}

.circle-mask {
  border-color: #e85a71;
}

.app-counter-box {
  font-family: 'Droid Sans Mono', monospace;
  font-size: 250%;
  position: relative;
  top: 50px;
  color: #d8e9ef;
}

button {
  position: relative;
  top: 50px;
  font-size: 80%;
  text-transform: uppercase;
  letter-spacing: 1px;
  border: none;
  background: none;
  outline: none;
  color: #d8e9ef;
  margin: 5px;
}

button:hover {
  color: #90c0d1;
}

.btn-pause, .btn-reset {
  display: inline-block;
}

.settings {
  position: relative;
  top: 100px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.settings label {
  margin: 5px 0;
  color: #d8e9ef;
}
<!DOCTYPE html>
<html lang="en">
  
  <head>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=EG+Garamond:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
    <link rel="stylesheet" href="./style.css" />
    <title>Pomodoro App</title>
  </head>
  
  <body>
    <div class="app-container">
      <h1>pomodoro</h1>
      <div class="app-message">press start to begin</div>
      <div class="app-circle">
        <div class="circle-shape">
          <div class="semi-circle right-side circle-mask"></div>
          <div class="semi-circle right-side circle"></div>
          <div class="semi-circle left-side circle-mask"></div>
          <div class="semi-circle left-side circle"></div>
        </div>
        <div class="app-counter-box">
          <p><span class="minutes">25</span>:<span class="seconds">00</span></p>
        </div>
        <button class="btn-start">start</button>
        <button class="btn-pause">pause</button>
        <button class="btn-reset">reset</button>
        <div class="settings">
          <label>Session (minutes): <input type="number" id="session-length" value="25"></label>
          <label>Break (minutes): <input type="number" id="break-length" value="5"></label>
        </div>
      </div>
    </div>
  </body>
  
  <script src="./app.js"></script>
</html>

Right now nothing is happening until the timer reaches half of the total time, then, it fills the top half of the circle and the animation starts from the middle of the left side of the circle.

Can someone help me with this please?

Thank you!


Solution

  • Here is a possible solution for this.

    Instead of your 4 divs, I went with a svg approach which seems easier to use. This svg circle uses the dashArray css property which creates dash in the circle stroke.

    Using your calculation, I make it so the circle stroke only has 1 dash in it which measures percentage * circlePerimiter

    Here it is in action (Be carefull as this is just a POC and there may be some dead code/leftover from your code)

    const bells = new Audio('./sounds/bell.wav'); 
    const startBtn = document.querySelector('.btn-start');
    const pauseBtn = document.querySelector('.btn-pause');
    const resetBtn = document.querySelector('.btn-reset'); 
    const session = document.querySelector('.minutes');
    const sessionInput = document.querySelector('#session-length');
    const breakInput = document.querySelector('#break-length');
    
    let myInterval; 
    let state = true;
    let isPaused = false
    let totalSeconds;
    let initialSeconds;
    
    const updateTimerDisplay = () => {
      const minuteDiv = document.querySelector('.minutes');
      const secondDiv = document.querySelector('.seconds');
    
      let minutesLeft = Math.floor(totalSeconds / 60);
      let secondsLeft = totalSeconds % 60;
    
      secondDiv.textContent = secondsLeft < 10 ? '0' + secondsLeft : secondsLeft;
      minuteDiv.textContent = `${minutesLeft}`;
    
      // Update the circle animation
      const circle = document.querySelector('.circle-shape');
      
      const duration = initialSeconds; // Total duration in seconds
      const elapsed = initialSeconds - totalSeconds;
      const length = document.querySelector('.circle-shape > circle').getTotalLength()
      const percentage = (elapsed / duration) * 100;
      circle.style.strokeDasharray = `${length * (1 - percentage/100)} ${length * (percentage/100)}`;
    };
    
    const appTimer = () => {
      const sessionAmount = Number.parseInt(sessionInput.value)
      if (isNaN(sessionAmount) || sessionAmount <= 0) {
        alert('Please enter a valid session duration.');
        return;
      }
      session.textContent = sessionAmount;  // Update the session display
    
      if(state) {
        state = false;
        totalSeconds = sessionAmount * 60;
        initialSeconds = totalSeconds;
    
        myInterval = setInterval(() => {
          if (!isPaused) {
            totalSeconds--;
            updateTimerDisplay();
    
            if (totalSeconds <= 0) {
              bells.play();
              clearInterval(myInterval);
              startBreakTimer();
            }
          }
        }, 1000);
      } else {
        alert('Session has already started.');
      }
    };
    
    const pauseTimer = () => {
      if (!state) {
        isPaused = !isPaused;
        pauseBtn.textContent = isPaused ? 'resume' : 'pause';
      }
    }
    
    const startBreakTimer = () => {
      const breakAmount = Number.parseInt(breakInput.value);
      if (isNaN(breakAmount) || breakAmount <= 0) {
        alert('Please enter a valid break duration.');
        return;
      }
      totalSeconds = breakAmount * 60;
      initialSeconds = totalSeconds;
    
        myInterval = setInterval(() => {
        if (!isPaused) {
          totalSeconds--;
          updateTimerDisplay();
    
          if (totalSeconds <= 0) {
            bells.play();
            clearInterval(myInterval);
            state = true;
          }
        }
      }, 1000);
    };
    
    const resetTimer = () => {
      clearInterval(myInterval);
      state = true;
      isPaused = false;
      pauseBtn.textContent = 'pause';
      const minuteDiv = document.querySelector('.minutes');
      const secondDiv = document.querySelector('.seconds');
      minuteDiv.textContent = sessionInput.value;;
      secondDiv.textContent = '00';
    
    }
    
    startBtn.addEventListener('click', appTimer);
    pauseBtn.addEventListener('click', pauseTimer);
    resetBtn.addEventListener('click', resetTimer);
    html {
      font-family: 'Fira Sans', sans-serif;
      font-size: 20px;
      letter-spacing: 0.8px;
      min-height: 100vh;
      color: #d8e9ef;
      background-image: linear-gradient(-20deg, #025159 0%, #733b36 100%);
      background-size: cover;
    }
    
    h1 {
      margin: 0 auto 10px auto;
      color: #d8e9ef;
    }
    
    p {
      margin: 0;
    }
    
    .app-message {
      height: 20px;
      margin: 10px auto 20px auto;
    }
    
    .app-container {
      width: 250px;
      height: 420px;
      margin: 40px auto;
      text-align: center;
      border-radius: 5px;
      padding: 20px;
    }
    
    /*@keyframes rotate-right-from-top {
      0% {
        transform: rotate(-90deg); 
      }
      100% {
        transform: rotate(90deg); 
      }
    }
    
    @keyframes rotate-left-from-top {
      0% {
        transform: rotate(-90deg); 
      }
      100% {
        transform: rotate(90deg); 
      }
    }*/
    
    .app-circle {
      position: relative;
      margin: 0 auto;
      width: 200px;
      height: 200px;
    }
    
    .circle-shape {
      position: absolute;
      pointer-events: none;
      stroke-width: 6px;
      background: transparent;
      fill: transparent;
      stroke: red;
      aspect-ratio: 1;
      box-sizing: border-box;
      inset: 0;
      transform: rotateY(180deg) rotate(-90deg);
    }
    
    
    .left-side {
      top: 0;
      left: 0;
      transform-origin: right center;
      /*transform: rotate(0deg);*/
      border-top-left-radius: 100px;
      border-bottom-left-radius: 100px;
      border-right: none;
      z-index: 1;
      /*animation-name: rotate-left-from-top;*/
    }
    
     .right-side {
      top: 0;
      left: 100px;
      transform-origin: left center;
      /*transform: rotate(0deg);*/
      border-top-right-radius: 100px;
      border-bottom-right-radius: 100px;
      border-left: none;
      /*animation-name: rotate-right-from-top;*/
    }
    
    .circle {
      border-color: #bf5239;
    }
    
    .circle-mask {
      border-color: #e85a71;
    }
    
    .app-counter-box {
      font-family: 'Droid Sans Mono', monospace;
      font-size: 250%;
      position: relative;
      top: 50px;
      color: #d8e9ef;
    }
    
    button {
      position: relative;
      top: 50px;
      font-size: 80%;
      text-transform: uppercase;
      letter-spacing: 1px;
      border: none;
      background: none;
      outline: none;
      color: #d8e9ef;
      margin: 5px;
    }
    
    button:hover {
      color: #90c0d1;
    }
    
    .btn-pause, .btn-reset {
      display: inline-block;
    }
    
    .settings {
      position: relative;
      top: 100px;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    
    .settings label {
      margin: 5px 0;
      color: #d8e9ef;
    }
    <!DOCTYPE html>
    <html lang="en">
      
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
        <link href="https://fonts.googleapis.com/css2?family=EG+Garamond:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
        <link rel="stylesheet" href="./style.css" />
        <title>Pomodoro App</title>
      </head>
      
      <body>
        <div class="app-container">
          <h1>pomodoro</h1>
          <div class="app-message">press start to begin</div>
          <div class="app-circle">
            <svg class="circle-shape" viewBox="0 0 100 100" stroke-width>
              <circle r="40" cx="50" cy="50" />
            </svg>
            <div class="app-counter-box">
              <p><span class="minutes">25</span>:<span class="seconds">00</span></p>
            </div>
            <button class="btn-start">start</button>
            <button class="btn-pause">pause</button>
            <button class="btn-reset">reset</button>
            <div class="settings">
              <label>Session (minutes): <input type="number" id="session-length" value="25"></label>
              <label>Break (minutes): <input type="number" id="break-length" value="5"></label>
            </div>
          </div>
        </div>
      </body>
      
      <script src="./app.js"></script>
    </html>

    Notice that this refreshes only each seconds (as it is called from the text update). I you wanted to achieve a smooth animation. You could use the same approach using css-animations on the dash-array, and only access the animation-duration property to match your timer duration