Search

Timeline Tooltips

Interactions

Show HTML tooltips by handling hover events.

Timeline Tooltips
View live example →

Hover over events in the timeline to trigger the tooltip.

The hover event is triggered by hovering over items.

You can attach handlers to all pointer events using the timeline.on() function.

This story creates a simple HTML tooltip to show additional data.

See Also

Hover over events in the timeline to trigger the tooltip.

The onTimelineHover event is triggered by hovering over items.

This story creates a simple JSX tooltip to show additional data.

See Also

import { createTimeline } from "kronograph";
import data from "./data";

const tooltipElement = document.getElementById("my-tooltip");
const timelineElement = document.getElementById("my-timeline");

const timeline = createTimeline("my-timeline");

const showTooltip = ({ x, y, from, to }) => {
  // display some email metadata in the tooltip
  const bounds = timelineElement.getBoundingClientRect();

  const left = bounds.left + x;
  const top = bounds.top + y;

  tooltipElement.style.display = "block";
  // offset tooltip 10px from cursor
  tooltipElement.style.left = `${left + 10}px`;
  tooltipElement.style.top = `${top}px`;

  const fromElement = document.getElementById("tooltip-from");
  const toElement = document.getElementById("tooltip-to");

  fromElement.innerText = from;
  toElement.innerText = to;
};

function hideTooltip() {
  tooltipElement.style.display = "none";
}

const handleHover = ({ eventIds, x, y }) => {
  if (eventIds && eventIds.length > 0) {
    const hoveredEvents = eventIds.map((eventId) => data.events[eventId]);
    const { from: firstFrom, to: firstTo } = hoveredEvents[0].data;
    const from = hoveredEvents.every((event) => event.data.from === firstFrom)
      ? firstFrom
      : "Multiple entities";
    const to = hoveredEvents.every((event) => event.data.to === firstTo)
      ? firstTo
      : "Multiple entities";
    showTooltip({ x, y, from, to });
  } else {
    hideTooltip();
  }
};

timeline.options({ hover: 0 });
timeline.on("hover", handleHover);
timeline.on("range", hideTooltip);
timeline.set(data);
timeline.fit();
const data = {
  entities: {
    "smith-johnathan-151": {
      label: "John Smith",
    },
    "west-josephine-126": {
      label: "Josephine West",
    },
    "roberts-nathaniel-023": {
      label: "Nathan Roberts",
    },
    "baxter-eleanor-004": {
      label: "Ella Baxter",
    },
  },
  events: {
    "email 1": {
      entityIds: ["smith-johnathan-151", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 8, 49),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 2": {
      entityIds: ["smith-johnathan-151", "baxter-eleanor-004"],
      time: new Date(2025, 7, 14, 8, 56),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 3": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 2),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 4": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 27),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 5": {
      entityIds: ["baxter-eleanor-004", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 10, 20),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 6": {
      entityIds: ["roberts-nathaniel-023", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 30),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 7": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 10, 34),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 8": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 40),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 9": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 10, 51),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 10": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 12, 5),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 11": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 14, 37),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 12": {
      entityIds: ["baxter-eleanor-004", "west-josephine-126"],
      time: new Date(2025, 7, 14, 16, 7),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 13": {
      entityIds: ["west-josephine-126", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 17, 42),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
  },
};

export default data;
<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="@ci/theme/kg/css/examples.css" />
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="story">
      <div class="story__timeline" id="my-timeline"></div>
      <div id="my-tooltip" class="story__tooltip stack stack--small">
        <div>
          <div class="story__tooltip__label">From</div>
          <div class="story__tooltip__email" id="tooltip-from"></div>
        </div>
        <div>
          <div class="story__tooltip__label">To</div>
          <div class="story__tooltip__email" id="tooltip-to"></div>
        </div>
      </div>
    </div>
    <script type="module" src="./code.js"></script>
  </body>
</html>
@import url("https://fonts.googleapis.com/css?family=Muli:200,400,500,700&display=swap");

body {
  font-family: "Muli", "Helvetica Neue", Helvetica, sans-serif;
  font-weight: 400;
  margin: 0;
}

.story {
  background-color: hsl(210, 18%, 22%); /* Charcoal */
  color: hsl(214, 15%, 91%);
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.story__timeline {
  flex: 2;
  min-height: 0px;
}

.story__tooltip {
  background-color: hsl(209, 15%, 28%);
  border-radius: 0.1975rem;
  color: hsl(216, 33%, 97%);
  display: none;
  max-height: 300px;
  max-width: 300px;
  overflow-y: auto;
  padding: 0.666rem;
  position: absolute;
  pointer-events: none;
}

.story__tooltip__label {
  color: hsl(211, 13%, 65%);
  font-size: 0.8em;
}

.story__tooltip__email {
  color: hsl(214, 15%, 91%);
  font-size: 0.9em;
}

.stack {
  flex-direction: column;
  justify-content: flex-start;
}

.stack > * {
  margin-bottom: 0;
  margin-top: 0;
}

.stack > * + * {
  margin-top: 1rem;
}

.stack--small > * + * {
  margin-top: 0.666rem;
}
import React, { useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import Timeline from "kronograph/react/Timeline";
import data from "./data";

const { events, entities } = data;

export const Demo = () => {
  const storyContainer = useRef(null);
  const [tooltip, setTooltip] = useState(null);
  return (
    <div className="story" ref={storyContainer}>
      <Timeline
        className="story__timeline"
        entities={entities}
        events={events}
        options={{ hover: 0 }}
        onTimelineHover={({ eventIds, x, y }) => {
          if (eventIds && eventIds.length > 0) {
            const hoveredEvents = eventIds.map((eventId) => data.events[eventId]);
            const { from: firstFrom, to: firstTo } = hoveredEvents[0].data;
            const from = hoveredEvents.every((event) => event.data.from === firstFrom)
              ? firstFrom
              : "Multiple entities";
            const to = hoveredEvents.every((event) => event.data.to === firstTo)
              ? firstTo
              : "Multiple entities";
            const bounds = storyContainer.current.getBoundingClientRect();
            setTooltip({
              from,
              to,
              // offset tooltip slightly from the cursor
              left: bounds.left + 10 + x,
              top: bounds.top + y,
            });
          } else {
            setTooltip(null);
          }
        }}
        onTimelineChange={(event) => {
          if (event.range) {
            setTooltip(null);
          }
        }}
      />
      {tooltip ? (
        <div
          id="my-tooltip"
          className="story__tooltip stack stack--small"
          style={{
            display: "block",
            left: tooltip.left,
            top: tooltip.top,
          }}
        >
          <div>
            <div className="story__tooltip__label">From</div>
            <div className="story__tooltip__email" id="tooltip-from">
              {tooltip.from}
            </div>
          </div>
          <div>
            <div className="story__tooltip__label">To</div>
            <div className="story__tooltip__email" id="tooltip-to">
              {tooltip.to}
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
};

const root = createRoot(document.getElementById("my-timeline"));
root.render(<Demo />);
const data = {
  entities: {
    "smith-johnathan-151": {
      label: "John Smith",
    },
    "west-josephine-126": {
      label: "Josephine West",
    },
    "roberts-nathaniel-023": {
      label: "Nathan Roberts",
    },
    "baxter-eleanor-004": {
      label: "Ella Baxter",
    },
  },
  events: {
    "email 1": {
      entityIds: ["smith-johnathan-151", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 8, 49),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 2": {
      entityIds: ["smith-johnathan-151", "baxter-eleanor-004"],
      time: new Date(2025, 7, 14, 8, 56),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 3": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 2),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 4": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 27),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 5": {
      entityIds: ["baxter-eleanor-004", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 10, 20),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 6": {
      entityIds: ["roberts-nathaniel-023", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 30),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 7": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 10, 34),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 8": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 40),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 9": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 10, 51),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 10": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 12, 5),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 11": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 14, 37),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 12": {
      entityIds: ["baxter-eleanor-004", "west-josephine-126"],
      time: new Date(2025, 7, 14, 16, 7),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
    "email 13": {
      entityIds: ["west-josephine-126", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 17, 42),
      data: {
        from: "[email protected]",
        to: "[email protected]",
      },
    },
  },
};

export default data;
<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="@ci/theme/kg/css/examples.css" />
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div id="my-timeline" style="height: 100vh"></div>
    <script type="module" src="./code.jsx"></script>
  </body>
</html>
@import url("https://fonts.googleapis.com/css?family=Muli:200,400,500,700&display=swap");

body {
  font-family: "Muli", "Helvetica Neue", Helvetica, sans-serif;
  font-weight: 400;
  margin: 0;
}

.story {
  background-color: hsl(210, 18%, 22%); /* Charcoal */
  color: hsl(214, 15%, 91%);
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.story__timeline {
  flex: 2;
  min-height: 0px;
}

.story__tooltip {
  background-color: hsl(209, 15%, 28%);
  border-radius: 0.1975rem;
  color: hsl(216, 33%, 97%);
  display: none;
  max-height: 300px;
  max-width: 300px;
  overflow-y: auto;
  padding: 0.666rem;
  position: absolute;
  pointer-events: none;
}

.story__tooltip__label {
  color: hsl(211, 13%, 65%);
  font-size: 0.8em;
}

.story__tooltip__email {
  color: hsl(214, 15%, 91%);
  font-size: 0.9em;
}

.stack {
  flex-direction: column;
  justify-content: flex-start;
}

.stack > * {
  margin-bottom: 0;
  margin-top: 0;
}

.stack > * + * {
  margin-top: 1rem;
}

.stack--small > * + * {
  margin-top: 0.666rem;
}

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.