Keyboard Maestro is incredible (in part) because it has something for everyone—from beginner to advanced. Being simple to use makes it accessible to anyone, but I’m constantly discovering more advanced features.

My most recent discovery came a few months ago when working one of my jobby-jobs. I write user education support articles for a software company as a contractor. The company’s software contains hundreds of icon assets we use when writing articles. Every asset was the same except for the ID, and I knew Keyboard Maestro must have a way to automate inserting these icons. Enter the Custom Floating HTML Prompt! You can code little mini webpages using HTML, CSS, and JavaScript and pass and receive data from/into Keyboard Maestro.

I’ve continued using the base structure of the script for personal things as well. Using the native JavaScript Fetch API, for instance, I access an endpoint and fetch my videos (thumbails included). Using JavaScript, I added keyboard events to keep my hands on the keyboard, which makes it easy to navigate the list, choose a video, and paste the full YouTube link!

How to Write a Custom Floating HTML Prompt

Per the Keyboard Maestro docs, “[the prompt] allows you to display an entirely customized window to gather or display information.” In short, you need to design a full webpage with an embedded form. You can pass Keyboard Maestro variables into and from the HTML form on submit and then carry on with a macro like normal.

Note: I give you the code for the HTML file piece-by-piece below. If you don’t want to recreate it along with me, you can access the full code here.

1. Add core HTML.

There are a few things you’ll notice in my example code:

  • I’ve set the size of the window in a data attribute on the body (data-kmwindow=“520, 945”). This tells Keyboard Maestro to set the window size to 520px wide and 945px tall.
  • I turned off autocomplete and spellcheck on the input field to prevent macOS from suggesting or correcting inputs and set an autofocus attribute so the input is selected as soon as the window appears.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale="1.0">
  <title>YouTube Videos</title>
</head>
<body data-kmwindow="520,945">
  <form>
    <input type="search" class="search" autocomplete="off" spellcheck="false" autofocus placeholder="Type to filter…">
    <div id="input-list">
    </div>
    <div class="buttons">
      <button class="submit" name="OK" type="button">Select
      </button>
      <button class="view" name="View" type="button">View All
      </button>
      <button class="cancel" name="cancel" type="button">Cancel
      </button>
    </div>
  </form>
</body>
</html>

Note: You can either type your code directly into Keyboard Maestro or reference an HTML file. I’d recommend an HTML file so you can write the code in a code editor meant for web development. Select Custom prompt with HTML file in the Custom Floating HTML Prompt action. If that doesn’t make sense, if you download the macro at the end of the article, I think it will. Basically, write this code in a code editor, not inside Keyboard Maestro.

2. Add custom CSS.

There’s not a lot to say here because you can use normal CSS to style the window—like any webpage. Because it’s in an HTML doc, you’ll have to add the CSS in style tags.

Note: The docs do mention, “You will probably also need to specify the body width in the body CSS in order to ensure proper HTML display: e.g., body {width: 420px;}.”

<style>
  *,
  *::after,
  *::before {
    border: 0;
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  :root {
    --text: hsl(218, 16.7%, 28.2%);
    --muted: hsl(10, 3.6%, 32.9%, .5);
    --light: hsl(150, 7.7%, 94.9%, 1);
    --accent1: 264 91.2% 51.2%;
    --accent2: 186 91.2% 49%;
    --radius: 10px;
    --boxs: 0 5px 5px -3px hsl(var(--accent1) / 10%), 0 3px 14px 2px hsl(var(--accent1) / 6%), 0 8px 10px 0 hsl(var(--accent2) / 7%);
  }
  body {
    font-family: 'MesloLGS Nerd Font', sans-serif;
    color: var(--text);
    padding: 1.5rem;
    background-color: var(--light);
  }
  input,
  label,
  button {
    font: inherit;
    background: inherit;
    line-height: inherit;
  }
  input {
    opacity: 0;
    width: 0;
    height: 0;
    display: flex;
    align-items: center;
  }
  .radio,
  button {
    display: flex;
    align-items: center;
    padding: 1.3rem .7rem;
    border-radius: var(--radius);
    background-color: var(--light);
    box-shadow: var(--boxs);
    cursor: pointer;
  }
  form,
  #input-list {
    display: grid;
    gap: 1rem;
    margin-bottom: 1rem;
  }
  .radio {
    border-left: 8px solid transparent;
    color: var(--text);
    font-weight: bold;
    transition: all 160ms cubic-bezier(1, 0.15, 0.86, 1.03);
  }
  .radio:is(:hover, :focus, :focus-within) {
    border-left: 8px solid var(--text);
    color: var(--text);
    background-color: var(--light);
    box-shadow: none;
  }
  .radio__img {
    border-radius: 6px;
  }
  .radio__label {
    padding-left: .6rem;
  }
  .radio__img, .radio__label, .radio__input {
    pointer-events: none;
  }

  input.search {
    opacity: initial;
    width: initial;
    height: initial;
    padding: .8rem .6rem;
    margin-bottom: .8rem;
    border: 2px solid transparent;
    border-radius: var(--radius);
    background-color: var(--light);
    box-shadow: var(--boxs);
  }
  input.search:focus {
    outline: none;
    box-shadow: none;
    border: 2px solid var(--text);
  }
  input.search::placeholder {
    color: var(--muted);
  }
  .buttons {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }
  button {
    flex: 1 1 25%;
    border-radius: 10px;
    display: inline-block;
    color: var(--text);
    background-color: var(--light);
    border: 4px solid var(--text);
    padding: .8rem 1.2rem;
  }
  button:is(:hover, :focus) {
    color: var(--light);
    background-color: var(--text);
    border: 4px solid transparent;
    outline: none;
    box-shadow: none;
  }
  button.cancel:is(:hover, :focus) {
    --text: hsl(0, 66%, 57%);
    background-color: var(--text);
    color: var(--light);
  }
</style>

3. Add JavaScript.

The script to work properly, you’ll need to add a few Keyboard Maestro functions to your script.

  1. Cancel macro: window.KeyboardMaestro.Cancel() (called when I click the cancel button or hit the Escape key)
  2. Submit macro: window.KeyboardMaestro.Submit() (called when I click the submit button, double-click any video, or hit the Enter key when a video is focused).

Here is my full JavaScript with my API endpoint removed. Replace ENDPOINT in the fetch call with your own API endpoint.

<script>
  let list = [];
  let maxDisplayLimit = 4;
  const displayList = document.querySelector('#input-list');
  const search = document.querySelector('.search');
  function textMatch(item) {
    const searchTerm = search.value.toLowerCase();
    const matchingItems = item.name.toLowerCase();
    return matchingItems.indexOf(searchTerm) !== -1;
  }
  function getFilteredItems() {
    displayList.innerHTML = list.filter(textMatch).slice(0, maxDisplayLimit).map((item) => `<label class="radio" for=${item.id}>
      <input type="radio" class="radio__input" value="${item.id}" />
      <img class="radio__img" src="${item.thumbnail}" width="220px">
      <span class ="radio__label">${item.name}</span>
    </label>`).join('')
  }
  function submitMacro() {
    if(!document.activeElement.classList.contains('radio__input')){return};
    window.KeyboardMaestro.SetVariable('youTubeID', document.activeElement.value);
    window.KeyboardMaestro.SetVariable('cancel', 'false');
    window.KeyboardMaestro.Submit();
  }
  function viewAllIcons() {
    search.value = '';
    maxDisplayLimit = 500;
    getFilteredItems();
  }
  function cancelMacro() {
    window.KeyboardMaestro.SetVariable('cancel', 'true');
    window.KeyboardMaestro.Cancel();
  }

  // event listeners
  search.addEventListener('keyup', getFilteredItems);
  document.querySelector('.submit').addEventListener('click', submitMacro);
  document.querySelector('.view').addEventListener('click', viewAllIcons);
  document.querySelector('.cancel').addEventListener('click', cancelMacro);
  displayList.addEventListener('click', (e) => {
    e.target.classList.contains('radio') ? e.target.querySelector('input').focus() : null;
  });
  displayList.addEventListener('dblclick', (e) => {
      e.target.classList.contains('radio') ? submitMacro() : null;
  });

  window.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      submitMacro();
    }
    if (e.key === 'Escape') {
      cancelMacro();
    }
    if (e.key === 'ArrowDown' && document.activeElement === search) {
      document.querySelector('.radio__input').focus();
    }
  });
  async function fetchData() {
    await fetch('ENDPOINT')
      .then((response) => {
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        return response.json();
      })
      .then((data) => {
        list = data.map((video) => ({
          name: video.snippet.title,
          id: video.snippet.resourceId.videoId,
          thumbnail: video.snippet.thumbnails.medium.url,
        }));
        getFilteredItems();
      })
      .catch((error) => {
        console.error('There has been a problem with your fetch operation:', error);
      });
  }
  fetchData();
</script>

Note: I have a serverless node function calling the YouTube API with my PLAYLIST_ID and YOUTUBE_API key like this: "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId={YOUTUBE_API}&maxResults=100&key={YOUTUBE_API}". It parses the JSON to and endpoint which is what my Keyboard Maestro script calls as my ENDPOINT.

A few things to note:

  • During the cancelMacro and submitMacro functions, I set a Keyboard Maestro variable ‘cancel’ to either ‘true’ or ‘false’ depending on whether I’m cancelling or submitting the form. I use this varible in the Keyboard Maestro macro to determine whether to paste the YouTube link or not (more on that in a moment).
  • For the submitMacro function, I’m also grabbing the selected video’s value attribute and updating a Keyboard Maestro variable called ‘youTubeID’ with the value.
  • I’ve added some basic keyboard events so you can arrow down to results, etc.
  • I have a function that filters as you search, dynamically updating the list of videos.
  • The View All button reprocesses the entire list of videos and adds them all to the list.
  • Lastly, notice that I do not have a ‘name’ attribute on my input elements so Keyboard Maestro doesn’t pull in the inputs as variables (that’s how I understand the documentation). Alternatively, I could include the ‘name’ attribute and add ‘data-kmignore’ as an attribute of the input. For more help on preventing initializing or saving Keyboard Maestro variables in your script, see the documentation under the Excluding Set and/or Saving of Form Fields heading.

4. Build the Keyboard Maestro macro.

Other than the two small functions dealing with Keyboard Maestro, everything else is pure HTML, CSS, and JavaScript. But you do have to actually create a macro. Mine contains two actions: 1) the Custom floating HTML prompt pointing to my file and 2) an if statement that checks if I’m cancelling or submitting the form and then pastes the YouTube link if I’m submitting the form.

Image of Keyboard Maestro prompts.

Here is the macro if you don’t want to rebuild those two steps.

Download Macro

Note: Again, if you don’t want to recreate the HTML file along with me, you can access the full code here.