Search

Context Menu

Interactions

Add right-click context menus to the timeline.

Context Menu
View live example →

Right click (long press on touch devices) on the timeline to fire a context-menu event.

Attach an event handler to the context-menu event by using the timeline.on() function.

See Also

Right click (long press on touch devices) on the timeline to invoke a context-menu event.

Attach an event handler to the context-menu event by using the onTimelineContextMenu function.

See Also

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

const timeline = createTimeline("my-timeline");
const contextmenuElement = document.getElementById("my-contextmenu");
const timelineElement = document.getElementById("my-timeline");
let entitiesFocused;
let currentData;

function filter(items, predicate) {
  return Object.entries(items).reduce((result, [key, val]) => {
    if (predicate(val, key, items)) {
      result[key] = val;
    }
    return result;
  }, {});
}
function setTimelineData(newData) {
  currentData = newData;
  timeline.set(newData);
}

const contextmenuOptions = {
  background: [
    {
      id: "zoomToFit",
      text: () => "Zoom to Fit",
      handler: () => timeline.fit(),
    },
    {
      id: "resetTimeline",
      text: () => "Reset Timeline",
      handler: () => {
        setTimelineData(data);
        timeline.options({ scales: { dateOrder: "mdy" } });
        timeline.focus([]);
        timeline.markers([]);
        timeline.pin(timeline.getPinnedEntityIds(), false);
        timeline.fit();
      },
    },
  ],
  entity: [
    {
      id: "entityRowPin",
      text: (id) =>
        timeline.getPinnedEntityIds().includes(id) ? "Unpin Entity Row" : "Pin Entity Row",
      handler: (id) => timeline.pin(id, !timeline.getPinnedEntityIds().includes(id)),
    },
    {
      id: "entityRowFocus",
      text: (id) => (entitiesFocused.includes(id) ? "Unfocus Entity Row" : "Focus Entity Row"),
      handler: (id) => {
        timeline.focus(
          entitiesFocused.includes(id)
            ? entitiesFocused.filter((entityId) => entityId !== id)
            : [...entitiesFocused, id]
        );
      },
    },
    {
      id: "removeEntityRow",
      text: () => "Remove Entity Row",
      handler: (id) => removeEntity(id),
    },
  ],
  event: [
    {
      id: "markEvent",
      text: () => "Mark Event",
      handler: (id) => {
        timeline.markers([...timeline.markers(), { label: id, time: timeline.getEvent(id).time }]);
      },
    },
    {
      id: "focusEnds",
      text: (id) => (eventEndsFocused(id) ? "Unfocus Ends" : "Focus Ends"),
      handler: (id) => {
        const eventEntityIds = timeline.getEvent(id).entityIds;
        timeline.focus(
          eventEndsFocused(id)
            ? entitiesFocused.filter((entityId) => !eventEntityIds.includes(entityId))
            : [...entitiesFocused, ...eventEntityIds]
        );
      },
    },
  ],
  marker: [
    {
      id: "unMarkEvent",
      text: () => "Unmark Event",
      handler: (id) => {
        const markerId = id.split("_marker")[1];
        timeline.markers(timeline.markers().filter((_m, index) => index !== +markerId));
      },
    },
  ],
  scale: [
    {
      id: "dmyFormat",
      text: () => "Use 'dmy' Format",
      handler: () =>
        timeline.options({
          scales: {
            dateOrder: "dmy",
          },
        }),
    },
    {
      id: "mdyFormat",
      text: () => "Use 'mdy' Format",
      handler: () =>
        timeline.options({
          scales: {
            dateOrder: "mdy",
          },
        }),
    },
  ],
};

const removeEntity = (id) => {
  const newEntities = filter(currentData.entities, (entity, entityId) => entityId !== id);
  setTimelineData({ entities: newEntities, events: currentData.events });
};

const eventEndsFocused = (id) =>
  timeline.getEvent(id).entityIds.every((entityId) => entitiesFocused.includes(entityId));

const createContextmenuMarkup = (menuType, id) => {
  const listOptions = contextmenuOptions[menuType]
    .map(
      (entry) => `
    <li class='story__contextmenu__option' id=${entry.id} oncontextmenu='return false;'>
      ${entry.text(id)}
    </li>`
    )
    .join("");

  return `
    <ul class='story__contextmenu__options'>
        ${listOptions}
    </ul>
  `;
};

const displayContextmenu = ({ id, targetType, x, y }) => {
  // Don't create a contextmenu when clicking on the pin or focus items.
  if (["pin", "focus", "eventSummary"].includes(targetType)) {
    hideContextmenu();
    return;
  }
  const timelineBounds = timelineElement.getBoundingClientRect();
  const targetTypeSelected = id && id.includes("_marker") ? "marker" : targetType || "background";
  if (targetTypeSelected === "entity" || targetTypeSelected === "event") {
    entitiesFocused = timeline.focus();
  }

  // Style and create the HTML menu
  contextmenuElement.style.display = "block";
  contextmenuElement.style.left = `${timelineBounds.left + x + 10}px`;
  contextmenuElement.style.top = `${timelineBounds.top + y}px`;
  contextmenuElement.innerHTML = createContextmenuMarkup(targetTypeSelected, id);

  // Attach click handler to the menu list items
  contextmenuOptions[targetTypeSelected].forEach((option) => {
    document.getElementById(option.id).addEventListener("click", () => {
      option.handler(id);
      hideContextmenu();
    });
  });
};

const hideContextmenu = () => {
  contextmenuElement.style.display = "none";
};

timeline.on("context-menu", displayContextmenu);
timeline.on("range", hideContextmenu);
timeline.on("click", hideContextmenu);
setTimelineData(data);
timeline.fit();
export default {
  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),
    },
    "Email 2": {
      entityIds: ["smith-johnathan-151", "baxter-eleanor-004"],
      time: new Date(2025, 7, 14, 8, 56),
    },
    "Email 3": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 2),
    },
    "Email 4": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 27),
    },
    "Email 5": {
      entityIds: ["baxter-eleanor-004", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 10, 20),
    },
    "Email 6": {
      entityIds: ["roberts-nathaniel-023", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 30),
    },
    "Email 7": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 40),
    },
    "Email 8": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 10, 51),
    },
    "Email 9": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 12, 5),
    },
    "Email 10": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 14, 37),
    },
    "Email 11": {
      entityIds: ["baxter-eleanor-004", "west-josephine-126"],
      time: new Date(2025, 7, 14, 16, 7),
    },
    "Email 12": {
      entityIds: ["west-josephine-126", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 17, 42),
    },
  },
};
<!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-contextmenu" class="story__contextmenu"></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; /* Allow the DOM to resize the timeline */
}

.story__contextmenu {
  background-color: hsl(209, 15%, 28%);
  box-shadow: 0 4px 5px 3px rgba(0, 0, 0, 0.2);
  border-radius: 0.1975rem;
  color: hsl(216, 33%, 97%);
  display: none;
  max-height: 300px;
  max-width: 300px;
  position: absolute;
}

.story__contextmenu__options {
  list-style: none;
  margin: none;
  padding: 10px 0px;
  margin-block-start: 0em;
  margin-block-end: 0em;
}

.story__contextmenu__option {
  color: hsl(214, 15%, 91%);
  font-weight: 500;
  font-size: 0.8em;
  padding: 0px 10px;
  cursor: pointer;
}

.story__contextmenu__option:hover {
  background: hsl(25, 90%, 63%);
}
import React, { useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import Timeline from "kronograph/react/Timeline";
import data from "./data";

function filter(items, predicate) {
  return Object.entries(items).reduce((result, [key, val]) => {
    if (predicate(val, key, items)) {
      result[key] = val;
    }
    return result;
  }, {});
}

function find(items, predicate) {
  const result = Object.entries(items).find(([itemId, item]) => predicate(item, itemId));
  return result ? result[1] : undefined;
}

export const Demo = () => {
  const storyContainer = useRef(null);
  const timelineRef = useRef(null);
  const [timelineData, setTimelineData] = useState(data);
  const [options, setOptions] = useState({});
  const [focus, setFocus] = useState([]);
  const [markers, setMarkers] = useState([]);
  const [pin, setPin] = useState([]);
  const [contextmenu, setContextmenu] = useState(null);

  const contextmenuOptions = {
    background: [
      {
        id: "zoomToFit",
        text: () => "Zoom to Fit",
        handler: () => timelineRef.current.fit(),
      },
      {
        id: "resetTimeline",
        text: () => "Reset Timeline",
        handler: () => {
          setTimelineData({
            entities: data.entities,
            events: data.events,
          });
          setOptions({ scales: { dateOrder: "mdy" } });
          setFocus([]);
          setMarkers([]);
          setPin([]);
          timelineRef.current.fit();
        },
      },
    ],
    entity: [
      {
        id: "entityRowPin",
        text: (id) => (pin.includes(id) ? "Unpin Entity Row" : "Pin Entity Row"),
        handler: (id) => {
          setPin(pin.includes(id) ? pin.filter((entityId) => entityId !== id) : [...pin, id]);
        },
      },
      {
        id: "entityRowFocus",
        text: (id) => (focus.includes(id) ? "Unfocus Entity Row" : "Focus Entity Row"),
        handler: (id) =>
          setFocus(
            focus.includes(id) ? focus.filter((entityId) => entityId !== id) : [...focus, id]
          ),
      },
      {
        id: "removeEntityRow",
        text: () => "Remove Entity Row",
        handler: (id) => removeEntity(id),
      },
    ],
    event: [
      {
        id: "markEvent",
        text: () => "Mark Event",
        handler: (id) => {
          const newMarkers = [
            ...markers,
            {
              label: id,
              time: find(timelineData.events, (event, eventId) => eventId === id).time,
            },
          ];
          const sortedMarkers = newMarkers.sort((markerA, markerB) => markerA.time - markerB.time);
          setMarkers(sortedMarkers);
        },
      },
      {
        id: "focusEnds",
        text: (id) => (eventEndsFocused(id) ? "Unfocus Ends" : "Focus Ends"),
        handler: (id) => {
          const eventEntityIds = find(
            timelineData.events,
            (event, eventId) => eventId === id
          ).entityIds;
          setFocus(
            eventEndsFocused(id)
              ? focus.filter((entityId) => !eventEntityIds.includes(entityId))
              : [...focus, ...eventEntityIds]
          );
        },
      },
    ],
    marker: [
      {
        id: "unMarkEvent",
        text: () => "Unmark Event",
        handler: (id) => {
          const markerId = id.split("_marker")[1];
          const newMarkers = markers
            .filter((_m, index) => index !== +markerId)
            .sort((a, b) => a.time - b.time);
          setMarkers(newMarkers);
        },
      },
    ],
    scale: [
      {
        id: "dmyFormat",
        text: () => "Use 'dmy' Format",
        handler: () => {
          setOptions({
            scales: {
              dateOrder: "dmy",
            },
          });
        },
      },
      {
        id: "mdyFormat",
        text: () => "Use 'mdy' Format",
        handler: () =>
          setOptions({
            scales: {
              dateOrder: "mdy",
            },
          }),
      },
    ],
  };

  const eventEndsFocused = (id) => {
    const ev = find(data.events, (event, eventId) => eventId === id);
    return ev.entityIds.every((entityId) => focus.includes(entityId));
  };

  const removeEntity = (id) => {
    const newEntities = filter(timelineData.entities, (entity, entityId) => entityId !== id);

    setTimelineData({ entities: newEntities, events: timelineData.events });
  };

  const createMenuMarkup = (menuType, id) => (
    <ul className="story__contextmenu__options">
      {contextmenuOptions[menuType].map((entry) => (
        <li
          key={entry.id}
          className="story__contextmenu__option"
          onContextMenu={() => false}
          onClick={() => {
            entry.handler(id);
            setContextmenu(null);
          }}
        >
          {entry.text(id)}
        </li>
      ))}
    </ul>
  );

  const contextmenuHandler = (event) => {
    // Don't create a contextmenu when clicking on the pin or focus items.
    if (["pin", "focus", "eventSummary"].includes(event.targetType)) {
      setContextmenu(null);
      return;
    }
    const bounds = storyContainer.current.getBoundingClientRect();
    const targetTypeSelected =
      event.id && event.id.includes("_marker") ? "marker" : event.targetType || "background";

    setContextmenu({
      left: `${bounds.left + event.x + 10}px`,
      top: `${bounds.top + event.y}px`,
      menuOptions: createMenuMarkup(targetTypeSelected, event.id),
    });
  };

  const onChangeHandler = (change) => {
    if (change.range) {
      setContextmenu(null);
    }
    if (change.focus) {
      setFocus(change.focus);
    }
    if (change.pin) {
      setPin(change.pin);
    }
  };

  const onClickHandler = () => {
    setContextmenu(null);
  };

  return (
    <div className="story" ref={storyContainer}>
      <Timeline
        entities={timelineData.entities}
        events={timelineData.events}
        options={options}
        focus={focus}
        markers={markers}
        pin={pin}
        onTimelineContextMenu={contextmenuHandler}
        onTimelineChange={onChangeHandler}
        onTimelineClick={onClickHandler}
        ref={timelineRef}
      />
      {contextmenu   (
        <div
          id="my-contextmenu"
          className="story__contextmenu"
          style={{
            display: "block",
            left: contextmenu.left,
            top: contextmenu.top,
          }}
        >
          <div>{contextmenu.menuOptions}</div>
        </div>
      ) : null}
    </div>
  );
};

const root = createRoot(document.getElementById("my-timeline"));
root.render(<Demo />);
export default {
  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),
    },
    "Email 2": {
      entityIds: ["smith-johnathan-151", "baxter-eleanor-004"],
      time: new Date(2025, 7, 14, 8, 56),
    },
    "Email 3": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 2),
    },
    "Email 4": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 9, 27),
    },
    "Email 5": {
      entityIds: ["baxter-eleanor-004", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 10, 20),
    },
    "Email 6": {
      entityIds: ["roberts-nathaniel-023", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 30),
    },
    "Email 7": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 10, 40),
    },
    "Email 8": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 10, 51),
    },
    "Email 9": {
      entityIds: ["west-josephine-126", "smith-johnathan-151"],
      time: new Date(2025, 7, 14, 12, 5),
    },
    "Email 10": {
      entityIds: ["smith-johnathan-151", "west-josephine-126"],
      time: new Date(2025, 7, 14, 14, 37),
    },
    "Email 11": {
      entityIds: ["baxter-eleanor-004", "west-josephine-126"],
      time: new Date(2025, 7, 14, 16, 7),
    },
    "Email 12": {
      entityIds: ["west-josephine-126", "roberts-nathaniel-023"],
      time: new Date(2025, 7, 14, 17, 42),
    },
  },
};
<!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; /* Allow the DOM to resize the timeline */
}

.story__contextmenu {
  background-color: hsl(209, 15%, 28%);
  box-shadow: 0 4px 5px 3px rgba(0, 0, 0, 0.2);
  border-radius: 0.1975rem;
  color: hsl(216, 33%, 97%);
  display: none;
  max-height: 300px;
  max-width: 300px;
  position: absolute;
}

.story__contextmenu__options {
  list-style: none;
  margin: none;
  padding: 10px 0px;
  margin-block-start: 0em;
  margin-block-end: 0em;
}

.story__contextmenu__option {
  color: hsl(214, 15%, 91%);
  font-weight: 500;
  font-size: 0.8em;
  padding: 0px 10px;
  cursor: pointer;
}

.story__contextmenu__option:hover {
  background: hsl(25, 90%, 63%);
}

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.