Guides / Tutorials
Dog breed autocomplete with plain JavaScript
By the Dog Image API team. Updated .
The shortest version of a breed autocomplete is a <datalist>, which the browser turns into a suggestion dropdown. Fill it from GET /api/v1/breeds and the job is done. When you need styling control or a visible match list, a small custom version adds about forty lines.
Version 1: datalist
Save this as breeds.html and open it. It is a complete page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Breed autocomplete</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 28rem; margin: 2rem auto; padding: 0 1rem; }
input { width: 100%; padding: 0.5rem; font-size: 1rem; }
img { width: 100%; height: auto; background: #f4f1ea; border-radius: 8px; margin-top: 1rem; }
[hidden] { display: none; }
</style>
</head>
<body>
<label for="breed">Dog breed</label>
<input id="breed" list="breed-list" autocomplete="off" placeholder="Type to search">
<datalist id="breed-list"></datalist>
<figure id="result" hidden>
<img id="preview" alt="" width="512" height="512">
<figcaption><a id="credit" href="#"></a></figcaption>
</figure>
<script>
const API = "https://dogimage.dev";
const input = document.getElementById("breed");
const list = document.getElementById("breed-list");
const result = document.getElementById("result");
const preview = document.getElementById("preview");
const credit = document.getElementById("credit");
const bySlugName = new Map();
fetch(`${API}/api/v1/breeds`)
.then((res) => {
if (!res.ok) throw new Error(`Status ${res.status}`);
return res.json();
})
.then(({ breeds }) => {
for (const b of breeds) {
bySlugName.set(b.name.toLowerCase(), b);
list.append(new Option(b.name));
}
})
.catch((err) => console.error("Could not load breeds", err));
input.addEventListener("input", () => {
const breed = bySlugName.get(input.value.trim().toLowerCase());
result.hidden = !breed;
if (!breed) return;
preview.src = `${API}/api/v1/images/${breed.slug}/default/front?size=512`;
preview.alt = `${breed.name}, AI-generated illustration`;
credit.href = `${API}/dogs/${breed.slug}`;
credit.textContent = `${breed.name} image: Dog Image API`;
});
</script>
</body>
</html>
The page maps lowercase breed names to catalog entries. When the text in the box exactly matches a name, either typed or chosen from the dropdown, the preview appears.
Where a datalist falls short
A datalist is easy, but you do not control how it looks, and browsers differ in how they match. Some match anywhere in the text, others only at the start. It also cannot show a thumbnail beside each suggestion. If any of that matters, build a custom list.
Version 2: custom list with keyboard support
Replace the <datalist>, the input's list attribute and the script with the following. The list is a plain <ul> with the ARIA combobox pattern: arrow keys move, Enter picks, Escape closes.
<input id="breed" role="combobox" aria-expanded="false" aria-controls="options"
autocomplete="off" placeholder="Type to search">
<ul id="options" role="listbox" hidden></ul>
<script>
const API = "https://dogimage.dev";
const input = document.getElementById("breed");
const options = document.getElementById("options");
const result = document.getElementById("result");
const preview = document.getElementById("preview");
const credit = document.getElementById("credit");
let breeds = [];
let shown = [];
let active = -1;
fetch(`${API}/api/v1/breeds`)
.then((res) => res.json())
.then((data) => { breeds = data.breeds; })
.catch((err) => console.error("Could not load breeds", err));
function render() {
options.replaceChildren(
...shown.map((b, i) => {
const li = document.createElement("li");
li.role = "option";
li.id = `opt-${i}`;
li.textContent = b.name;
li.ariaSelected = String(i === active);
li.style.cssText = i === active ? "background:#e8eefc;padding:4px 8px" : "padding:4px 8px";
li.addEventListener("mousedown", () => choose(b));
return li;
})
);
const open = shown.length > 0;
options.hidden = !open;
input.setAttribute("aria-expanded", String(open));
if (active >= 0) input.setAttribute("aria-activedescendant", `opt-${active}`);
}
function choose(b) {
input.value = b.name;
shown = [];
active = -1;
render();
preview.src = `${API}/api/v1/images/${b.slug}/default/front?size=512`;
preview.alt = `${b.name}, AI-generated illustration`;
credit.href = `${API}/dogs/${b.slug}`;
credit.textContent = `${b.name} image: Dog Image API`;
result.hidden = false;
}
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
shown = q ? breeds.filter((b) => b.name.toLowerCase().includes(q)).slice(0, 8) : [];
active = shown.length ? 0 : -1;
render();
});
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" && shown.length) { active = (active + 1) % shown.length; render(); e.preventDefault(); }
else if (e.key === "ArrowUp" && shown.length) { active = (active - 1 + shown.length) % shown.length; render(); e.preventDefault(); }
else if (e.key === "Enter" && shown[active]) { choose(shown[active]); e.preventDefault(); }
else if (e.key === "Escape") { shown = []; active = -1; render(); }
});
input.addEventListener("blur", () => { shown = []; render(); });
</script>
The list is capped at eight matches. That keeps the dropdown short and the page fast, because the full catalog sits in memory and filtering it is instant.
Notes on the data
The catalog is one JSON file of breeds, each with a slug, a name and its colors. Fetching it once is cheaper than asking the API as the user types, and the API has no search endpoint, so filtering on your side is the way. Do not hardcode the breed count anywhere.
The default word in the image URL stands for each breed's main set, and front is one of nine view ids. Change it to head for a portrait, or see them all on the views pages.
Credit and origin
Each preview is free to use if a visible link credits Dog Image API on the same page, which is what the figcaption does. The pictures are AI-generated illustrations of dogs, not photos, and the alt text says so. Terms are on the license page. If you later need full-size PNGs, do not call that endpoint from this file: a key placed in browser code is public. Use a server, as described in the Next.js guide, and check the docs for the endpoint list.
Questions
Does this work without a build step?
Yes. Save the HTML file and open it in a browser, or serve it from any static host. It uses fetch, which is built into current browsers.
Why can a page on another domain call the API?
The breed endpoints and the preview files allow cross-origin reads, so browser code on other sites can use them. The full-size endpoint is for servers and needs a key.
How many breeds can the list hold?
It holds whatever the catalog returns, which was 298 breeds on 2026-11-14. The code reads the list from the API, so it keeps up as sets are added.
Next steps
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3