Library

Countdown (using custom element)

to run in your site's root directory
View template source
file "app/javascript/components/count-down.js" do
<<~"_"
// Usage

// Provide a `to-time` attribute with an ISO 8601 datetime string:
// ```html
// <count-down to-time="2026-12-31T23:59:59Z"></count-down>
// ```
//
class CountDown extends HTMLElement {
  #intervalId = null;

  static get observedAttributes() {
    return ["to-time", "complete-text"];
  }

  connectedCallback() {
    this.#update();
  }

  disconnectedCallback() {
    this.#stop();
  }

  attributeChangedCallback() {
    this.#update();
  }

  // private

  #update() {
    this.#stop();

    if (this.toTime) {
      this.#tick();

      this.#intervalId = setInterval(() => this.#tick(), 1000);
    }
  }

  #tick() {
    const now = new Date();
    const target = new Date(this.toTime);
    const difference = target - now;

    if (difference <= 0) {
      this.textContent = this.completeText;
      this.#stop();

      return;
    }

    const days = Math.floor(difference / (1000 * 60 * 60 * 24));
    const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
    const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
    const seconds = Math.floor((difference % (1000 * 60)) / 1000);

    this.textContent = `${days}d ${hours}h ${minutes}m ${seconds}s`;
  }

  #stop() {
    if (!this.#intervalId) return

    clearInterval(this.#intervalId);

    this.#intervalId = null;
  }

  get toTime() {
    return this.getAttribute("to-time");
  }

  set toTime(value) {
    if (value === null) {
      this.removeAttribute("to-time");
    } else {
      this.setAttribute("to-time", value);
    }
  }

  get completeText() {
    return this.getAttribute("complete-text") || "0d 0h 0m 0s";
  }

  set completeText(value) {
    if (value === null) {
      this.removeAttribute("complete-text");
    } else {
      this.setAttribute("complete-text", value);
    }
  }
}

customElements.define("count-down", CountDown);

_
end


create_file "app/javascript/components/index.js", <<~JS, skip: true
import "./count_down"
JS

application_js_path = "app/javascript/application.js"

if File.exist?(application_js_path)
  insert_into_file application_js_path, "\nimport \"components\"\n", after: /\A(?:import .+\n)*/
else
  create_file application_js_path, <<~JS
import "components"
JS
end

unless File.exist?("config/importmap.rb")
  say "Warning: importmap.rb not found!", :yellow
  say "Please set up importmap-rails first by running:"
  say "  rails importmap:install"
  say "Or use your preferred JavaScript set up."

  return
end

insert_into_file "config/importmap.rb", after: /\A.*pin .+\n+/m do
  "\npin_all_from \"app/javascript/components\", under: \"components\", to: \"components\"\n"
end

This (headless) component gives you a <count-down /> custom element. It displays a countdown timer to a specific date and time, updating every second.

Usage

Provide a to-time attribute with an ISO 8601 datetime string:

<count-down to-time="2026-12-31T23:59:59Z"></count-down>

This will display the countdown in the format: *d *h *m *s

Timezones

Include timezone information directly in the to-time value:

<!-- UTC time -->
<count-down to-time="2026-12-31T23:59:59Z"></count-down>

<!-- Specific timezone offset -->
<count-down to-time="2026-12-31T23:59:59-05:00"></count-down>

<!-- Another timezone -->
<count-down to-time="2026-12-31T23:59:59+01:00"></count-down>

Custom completion text

Use the complete-text attribute to customize what displays when the countdown reaches zero:

<count-down to-time="2026-12-31T23:59:59Z" complete-text="Sale ended!"></count-down>

Default completion text is 0d 0h 0m 0s.

Styling on completion

When the countdown reaches zero, a complete attribute is added to the element. Use this to style the completed state:

count-down[complete] {
  color: red;
  font-weight: bold;
}