Built with the free API

Periodic Table

All 118 elements, drawn in your browser by one JavaScript file from a single request to the free Chemical Elements dataset. Select an element, highlight a family, or press Play to watch the table fill in, one discovery at a time.

Drawn from the API response our server loads for every visitor, so viewing this page uses none of your free requests.

The code

Everything that
draws the table.

172 lines of JavaScript and a stylesheet, with no framework and no build step. This page runs the same file you see here.

The download puts both into one HTML file. Open it in a browser and it requests the elements from the live API, so it works on any site or straight from a folder.

periodic-table.jsDraws the table

// A periodic table drawn from MySafeInfo's free Chemical Elements dataset.
// One request returns all 118 elements as JSON, with no key, and each record says where its element belongs:
// Period is the row, GroupNumber the column, and Category the family that colors it.
// mysafeinfo.com/periodic-table runs this file as it is. Pair it with periodic-table.css.

export const API_URL = 'https://mysafeinfo.com/api/data/elements?format=json';

// The legend's order: roughly left to right across the table.
export const FAMILIES = ['Alkali metal', 'Alkaline earth metal', 'Transition metal', 'Post-transition metal', 'Metalloid',
    'Nonmetal', 'Halogen', 'Noble gas', 'Lanthanide', 'Actinide'];

export async function loadElements(url = API_URL) {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`The API answered ${response.status}.`);
    return response.json();
}

// Grid cells by atomic number. Elements with a group sit at row Period, column GroupNumber. The 28 without one,
// lanthanum to ytterbium and actinium to nobelium, get rows 9 and 10, starting under group 3.
export function cells(elements) {
    const first = {};
    for (const e of elements) if (!e.GroupNumber) first[e.Period] = Math.min(first[e.Period] ?? Infinity, e.AtomicNumber);
    return new Map(elements.map(e => [e.AtomicNumber, e.GroupNumber
        ? { row: e.Period, column: e.GroupNumber }
        : { row: e.Period + 3, column: 3 + e.AtomicNumber - first[e.Period] }]));
}

// Elements with no standard atomic weight show an isotope's mass number, in brackets as printed tables do.
export function weightText(e) {
    return e.WeightType === 'Isotope mass number' ? `[${Number(e.Weight)}]` : String(Number(e.Weight));
}

export function discoveryText(e) {
    const by = e.DiscoveredBy && e.DiscoveredBy !== 'Unknown' ? ` · ${e.DiscoveredBy}` : '';
    return (e.Discovered ?? 'No single year') + by;
}

export const slug = text => String(text).toLowerCase().replace(/[^a-z]+/g, '-');

export function drawPeriodicTable(container, records, { selected = 13 } = {}) {
    if (!records.length || !records.every(e => e.AtomicNumber && e.Period)) throw new Error('These records have no periodic table positions.');
    const elements = [...records].sort((a, b) => a.AtomicNumber - b.AtomicNumber);
    const byNumber = new Map(elements.map(e => [e.AtomicNumber, e]));
    const years = [...new Set(elements.map(e => e.Discovered).filter(y => y != null))].sort((a, b) => a - b);
    const dated = elements.filter(e => e.Discovered != null).length;
    const highlighted = new Set();
    let current = byNumber.has(selected) ? selected : elements[0].AtomicNumber;

    // Legend: press one or more families to highlight them.
    const legend = node('div', 'pt-legend');
    for (const family of [...FAMILIES, ...new Set(elements.map(e => e.Category).filter(c => !FAMILIES.includes(c)))]) {
        const button = node('button', 'pt-family', family);
        button.type = 'button';
        button.dataset.family = slug(family);
        button.setAttribute('aria-pressed', 'false');
        button.addEventListener('click', () => {
            if (!highlighted.delete(family)) highlighted.add(family);
            button.setAttribute('aria-pressed', highlighted.has(family));
            update();
        });
        legend.append(button);
    }

    // The table: one button per element in an 18-column grid, in atomic number order for the keyboard.
    const grid = node('div', 'pt-grid');
    grid.setAttribute('role', 'group');
    grid.setAttribute('aria-label', 'Periodic table');
    const tiles = new Map();
    for (const [number, { row, column }] of cells(elements)) {
        const e = byNumber.get(number);
        const tile = node('button', 'pt-tile');
        tile.type = 'button';
        tile.dataset.family = slug(e.Category);
        tile.style.gridArea = `${row} / ${column}`;
        tile.setAttribute('aria-label', `${e.Element}, ${e.Symbol}, atomic number ${number}`);
        tile.title = e.Element;
        tile.append(face(e));
        // The details follow clicks only, so they stay put while the pointer crosses other tiles on its way there.
        tile.addEventListener('click', () => { current = number; show(e, true); update(); });
        tiles.set(number, tile);
        grid.append(tile);
    }
    for (const period of new Set(elements.filter(e => !e.GroupNumber).map(e => e.Period))) {
        const row = elements.filter(e => !e.GroupNumber && e.Period === period);
        const label = node('span', 'pt-row-label', `${row[0].AtomicNumber}–${row.at(-1).AtomicNumber}`);
        label.style.gridArea = `${period + 3} / 1 / auto / span 2`;
        grid.append(label);
    }
    // Details of the selected element, in the gap above the transition metals.
    const detail = node('div', 'pt-detail');
    grid.append(detail);

    // Timeline: which elements had been discovered by a chosen year.
    const timeline = node('div', 'pt-timeline');
    const play = node('button', 'pt-play', 'Play');
    play.type = 'button';
    const slider = node('input');
    Object.assign(slider, { type: 'range', min: 0, max: years.length - 1, value: years.length - 1 });
    slider.setAttribute('aria-label', 'Discovered by year');
    const progress = node('p', 'pt-year');
    timeline.append(play, slider, progress,
        node('p', 'pt-timeline-note', `${elements.length - dated} elements have no single discovery year and always show. Select one to see why.`));
    timeline.hidden = years.length === 0;

    let timer;
    const stop = () => { clearInterval(timer); timer = undefined; play.textContent = 'Play'; };
    play.addEventListener('click', () => {
        if (timer) return stop();
        if (+slider.value === years.length - 1) slider.value = 0;
        play.textContent = 'Pause';
        update();
        timer = setInterval(() => {
            if (!slider.isConnected || +slider.value >= years.length - 1) return stop();
            slider.value = +slider.value + 1;
            update();
        }, 180);
    });
    slider.addEventListener('input', () => { stop(); update(); });

    const status = node('p', 'pt-sr');
    status.setAttribute('role', 'status');
    container.classList.add('pt');
    container.replaceChildren(legend, grid, timeline, status);
    show(byNumber.get(current));
    update();

    function update() {
        const year = years[+slider.value], latest = +slider.value === years.length - 1;
        let found = 0;
        for (const [number, tile] of tiles) {
            const e = byNumber.get(number);
            const known = e.Discovered == null || e.Discovered <= year;
            if (known && e.Discovered != null) found++;
            tile.classList.toggle('is-future', !known);
            tile.classList.toggle('is-new', !latest && e.Discovered === year);
            tile.classList.toggle('is-dim', highlighted.size > 0 && !highlighted.has(e.Category));
            tile.classList.toggle('is-selected', number === current);
            if (number === current) tile.setAttribute('aria-current', 'true'); else tile.removeAttribute('aria-current');
        }
        const named = elements.filter(e => e.Discovered === year).map(e => e.Element).join(', ');
        progress.replaceChildren(node('strong', null, year), ` ${found} of ${dated} discovered · New in ${year}: ${named}`);
        slider.setAttribute('aria-valuetext', `${year}, ${found} of ${dated} discovered`);
    }

    function show(e, announce = false) {
        const position = `Period ${e.Period} · ${e.GroupNumber ? `Group ${e.GroupNumber}` : 'No group'} · ${e.Block}-block`;
        const facts = node('dl', 'pt-facts');
        for (const [term, value] of [['Family', e.Category], ['Position', position],
            ['Weight', `${weightText(e)}, ${e.WeightType.toLowerCase()}`], ['Discovered', discoveryText(e)]])
            facts.append(node('dt', null, term), node('dd', null, value));
        const info = node('div', 'pt-info');
        info.append(facts);
        if (e.DiscoveryNotes) info.append(node('p', 'pt-note', e.DiscoveryNotes));
        detail.dataset.family = slug(e.Category);
        detail.replaceChildren(face(e), info);
        if (announce) status.textContent = `${e.Element}, ${e.Category}. ${position}. Discovered: ${discoveryText(e)}.`;
    }
}

function face(e) {
    const box = node('span', 'pt-face');
    box.append(node('span', 'pt-number', e.AtomicNumber), node('strong', 'pt-symbol', e.Symbol),
        node('span', 'pt-name', e.Element), node('span', 'pt-weight', weightText(e)));
    return box;
}

function node(tag, className, text) {
    const element = document.createElement(tag);
    if (className) element.className = className;
    if (text != null) element.textContent = text;
    return element;
}

periodic-table.cssColors and layout

/* Periodic table: the styles for periodic-table.js. Everything is scoped to .pt, and the table sizes itself to
   its container: names and weights on wide screens, fewer details as it narrows, and a sideways scroll on a phone. */
.pt {
    --pt-ink: #18202b; --pt-soft: #586170; --pt-line: #dde2e9; --pt-paper: #fff; --pt-accent: #2454eb;
    --pt-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
    --pt-gap: 4px;
    --pt-color: #e7ebf1;
    container-type: inline-size;
    color: var(--pt-ink);
    line-height: 1.65;
}
/* Self-contained: the table looks the same on any page, whatever that page's own base styles are. */
.pt, .pt * { box-sizing: border-box; }
.pt [hidden] { display: none; }
/* One color per family. Change these freely: the data only says which family an element is in. */
.pt [data-family="alkali-metal"] { --pt-color: #ff9aac; }
.pt [data-family="alkaline-earth-metal"] { --pt-color: #ffc56b; }
.pt [data-family="transition-metal"] { --pt-color: #b4c6ff; }
.pt [data-family="post-transition-metal"] { --pt-color: #ff7957; }
.pt [data-family="metalloid"] { --pt-color: #8bd8c4; }
.pt [data-family="nonmetal"] { --pt-color: #c3e58f; }
.pt [data-family="halogen"] { --pt-color: #f4e373; }
.pt [data-family="noble-gas"] { --pt-color: #cfbcff; }
.pt [data-family="lanthanide"] { --pt-color: #9fdcf1; }
.pt [data-family="actinide"] { --pt-color: #f1b3ec; }

.pt button { font: inherit; color: inherit; cursor: pointer; }
.pt button:focus-visible, .pt input:focus-visible { outline: 3px solid var(--pt-accent); outline-offset: 2px; }

.pt-legend { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 18px; }
.pt-family { display: inline-flex; align-items: center; gap: 7px; padding: 4px 11px; border: 1px solid var(--pt-line); border-radius: 999px; background: var(--pt-paper); font-size: .78rem; line-height: 1.5; }
.pt-family::before { content: ""; width: 10px; height: 10px; border-radius: 2px; background: var(--pt-color); }
.pt-family:hover { border-color: var(--pt-soft); }
.pt-family[aria-pressed="true"] { border-color: var(--pt-ink); box-shadow: inset 0 0 0 1px var(--pt-ink); }

.pt-grid {
    --pt-cell: calc((100cqi - 17 * var(--pt-gap)) / 18);
    display: grid; gap: var(--pt-gap);
    grid-template-columns: repeat(18, minmax(0, 1fr));
    grid-template-rows: repeat(7, calc(var(--pt-cell) * 1.12)) calc(var(--pt-cell) * .3) repeat(2, calc(var(--pt-cell) * 1.12)) auto;
}
.pt-tile { min-width: 0; padding: 0; border: 0; border-radius: 3px; background: var(--pt-color); text-align: left; transition: opacity .2s, transform .15s, box-shadow .15s, background-color .2s; }
.pt-face { display: flex; flex-direction: column; justify-content: space-between; height: 100%; padding: calc(var(--pt-cell) * .07) calc(var(--pt-cell) * .09); overflow: hidden; }
.pt-number, .pt-weight { font-family: var(--pt-mono); font-size: clamp(.5rem, calc(var(--pt-cell) * .16), .68rem); line-height: 1; }
.pt-symbol { font-size: clamp(.78rem, calc(var(--pt-cell) * .36), 1.45rem); font-weight: 600; line-height: 1; letter-spacing: -.03em; }
.pt-name { font-size: clamp(.5rem, calc(var(--pt-cell) * .14), .62rem); line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pt-tile:hover { transform: translateY(-2px); box-shadow: 0 5px 12px rgb(24 32 43 / .16); }
.pt-tile.is-selected { position: relative; z-index: 1; box-shadow: 0 0 0 2px var(--pt-paper), 0 0 0 4px var(--pt-ink); }
.pt-tile.is-future { background: transparent; color: var(--pt-soft); box-shadow: inset 0 0 0 1px var(--pt-line); }
.pt-tile.is-future.is-selected { box-shadow: inset 0 0 0 1px var(--pt-line), 0 0 0 2px var(--pt-paper), 0 0 0 4px var(--pt-ink); }
.pt-tile.is-new { box-shadow: 0 0 0 3px var(--pt-accent); }
.pt-tile.is-dim { opacity: .18; }
.pt-row-label { display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font: clamp(.5rem, calc(var(--pt-cell) * .17), .7rem) var(--pt-mono); color: var(--pt-soft); }

/* Details: in the empty space above the transition metals, or under the table when that space is too small. */
.pt-detail { grid-area: 1 / 3 / 4 / 13; display: flex; gap: 20px; min-width: 0; margin: 0 14px; overflow: hidden; }
.pt-detail .pt-face { flex: 0 0 auto; height: 100%; aspect-ratio: 1; padding: 12px 14px; background: var(--pt-color); border-radius: 3px; }
.pt-detail .pt-number, .pt-detail .pt-weight { font-size: .78rem; }
.pt-detail .pt-symbol { font-size: calc(var(--pt-cell) * 1.05); font-weight: 500; letter-spacing: -.06em; }
.pt-detail .pt-name { font-size: .9rem; font-weight: 600; white-space: normal; }
.pt-info { min-width: 0; padding-top: 2px; }
.pt-facts { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; font-size: .82rem; line-height: 1.4; }
.pt-facts dt { padding-top: 2px; font: .62rem/1.6 var(--pt-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--pt-soft); }
.pt-facts dd { margin: 0; }
.pt-note { margin: 8px 0 0; font-size: .74rem; line-height: 1.45; color: var(--pt-soft); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }

.pt-timeline { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; align-items: center; margin-top: 22px; }
.pt-play { min-width: 76px; padding: 7px 16px; border: 1px solid var(--pt-ink); border-radius: 4px; background: var(--pt-paper); font-weight: 600; }
.pt-play:hover { background: var(--pt-ink); color: var(--pt-paper); }
.pt-timeline input { width: 100%; margin: 0; accent-color: var(--pt-accent); }
.pt-year, .pt-timeline-note { grid-column: 1 / -1; margin: 0; }
.pt-year { font-size: .88rem; }
.pt-year strong { font-family: var(--pt-mono); }
.pt-timeline-note { font-size: .76rem; color: var(--pt-soft); }
.pt-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@container (max-width: 999px) {
    .pt-name { display: none; }
    .pt-detail { grid-area: 11 / 1 / 12 / -1; margin: 14px 0 0; }
    .pt-detail .pt-face { height: 132px; }
    .pt-detail .pt-symbol { font-size: 3rem; }
    .pt-detail .pt-name { display: block; }
}
/* Phones: tiles stay large enough to tap and the table scrolls sideways; the details stay in view. */
@container (max-width: 639px) {
    .pt-legend { gap: 5px; }
    .pt-family { gap: 5px; padding: 2px 8px; font-size: .7rem; }
    .pt-grid { --pt-cell: 34px; grid-template-columns: repeat(18, var(--pt-cell)); overflow-x: auto; padding: 4px; }
    .pt-weight { display: none; }
    .pt-face { justify-content: center; gap: 1px; padding: 2px 3px; }
    .pt-detail { position: sticky; left: 0; width: calc(100cqi - 8px); flex-direction: column; gap: 12px; }
    .pt-detail .pt-face { width: 128px; height: 128px; justify-content: space-between; }
    .pt-detail .pt-symbol { font-size: 2.6rem; }
    .pt-detail .pt-weight { display: block; }
}
@media (prefers-reduced-motion: reduce) {
    .pt-tile { transition: none; }
    .pt-tile:hover { transform: none; }
}

index.htmlThe page that loads them

<!doctype html>
<!-- A periodic table from MySafeInfo's free Chemical Elements API: https://mysafeinfo.com/periodic-table -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Periodic Table</title>
<link rel="stylesheet" href="periodic-table.css">
<style>
body { max-width: 1160px; margin: 0 auto; padding: 40px 24px 56px; color: #18202b; font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
h1 { margin: 0 0 6px; font-size: 2.4rem; font-weight: 600; line-height: 1.15; letter-spacing: -.035em; }
.credit { margin: 0 0 28px; color: #586170; font-size: .92rem; }
.credit a { color: #2454eb; }
@media (max-width: 600px) { body { padding: 24px 16px 40px; } h1 { font-size: 1.9rem; } }
</style>
</head>
<body>
<h1>Periodic Table</h1>
<p class="credit">Data from the free <a href="https://mysafeinfo.com/periodic-table">Chemical Elements API</a> at MySafeInfo.</p>
<div id="periodic-table"></div>
<script type="module">
import { loadElements, drawPeriodicTable } from './periodic-table.js';

const table = document.getElementById('periodic-table');
try {
    drawPeriodicTable(table, await loadElements());
} catch (error) {
    table.textContent = `Could not draw the table: ${error.message}`;
}
</script>
</body>
</html>

Program.csC#: print the table in a terminal

// Prints the periodic table in a terminal from MySafeInfo's free Chemical Elements API.
// .NET 10: save as periodic-table.cs and run: dotnet run periodic-table.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;

using var http = new HttpClient();
var elements = await http.GetFromJsonAsync("https://mysafeinfo.com/api/data/elements?format=json",
    ElementsJson.Default.ListChemicalElement) ?? [];

// Period is the row and GroupNumber the column. Elements with no group print in two rows below, under group 3.
foreach (var period in elements.Where(e => e.GroupNumber is not null).GroupBy(e => e.Period).OrderBy(p => p.Key))
{
    var row = new string[18];
    foreach (var e in period) row[e.GroupNumber!.Value - 1] = e.Symbol;
    Console.WriteLine(string.Join(" ", row.Select(symbol => (symbol ?? "").PadRight(2))));
}
Console.WriteLine();
foreach (var period in elements.Where(e => e.GroupNumber is null).GroupBy(e => e.Period).OrderBy(p => p.Key))
    Console.WriteLine("      " + string.Join(" ", period.OrderBy(e => e.AtomicNumber).Select(e => e.Symbol.PadRight(2))));

var aluminum = elements.First(e => e.Symbol == "Al");
Console.WriteLine($"\n{aluminum.Element}: {aluminum.Category}, period {aluminum.Period}, group {aluminum.GroupNumber}, discovered {aluminum.Discovered}.");

record ChemicalElement(int AtomicNumber, string Symbol, string Element, decimal Weight, int Period, int? GroupNumber,
    string Block, string Category, int? Discovered, string DiscoveredBy);

// Generated JSON reading: works in a single-file app, where .NET 10 compiles ahead of time, and in any project.
[JsonSerializable(typeof(List<ChemicalElement>))]
partial class ElementsJson : JsonSerializerContext;

How it works

One request.
Three fields place it.

Every record says where its element belongs, so the drawing code never needs a hard-coded layout. If the data changes, the table follows.

  1. Request every element.

    One request returns all 118 records as JSON, with no key.

    GET https://mysafeinfo.com/api/data/elements?format=json
  2. Place each one.

    Period is the row and GroupNumber the column. The elements with no group, lanthanum to ytterbium and actinium to nobelium, go in two rows below the table, starting under group 3.

  3. Color it and date it.

    Category picks the family color, and Discovered drives the timeline. The colors live in the stylesheet, so you can restyle the table without touching the data.

What each record contains

AtomicNumber
The number of protons, from 1 to 118.
Symbol, Element
The symbol and the English name.
Weight, WeightType
The standard atomic weight, or, for elements that have none, an isotope's mass number.
Period, GroupNumber, Block
The element's row, column, and block (s, p, d, or f). GroupNumber is empty for the two rows below the table.
Category
The element's family, such as Noble gas or Transition metal.
Discovered, DiscoveredBy, DiscoveryNotes
The year and who is credited, with notes where the history is uncertain.

Questions

Is the Chemical Elements API free?

Yes. Chemical Elements is one of three datasets that are free in full: one request returns every element as JSON, CSV, or XML, with no key. Without a key, the API allows 50 requests a day, shared by everyone on the same internet connection.

Does viewing this page use my free API requests?

No. The table is drawn from the API response our server loads once an hour for every visitor. Fetch it live makes one real request from your browser, and that one counts.

Can I use this code in my own project?

Yes. Copy the files or download the single HTML file. It calls the live API, so it works on any site or straight from a folder on your computer.

Why are lutetium and lawrencium in group 3?

The table follows the layout Wikipedia uses and a 2021 IUPAC project report recommended: scandium, yttrium, lutetium, and lawrencium form group 3, and lanthanum to ytterbium and actinium to nobelium sit in the two rows below. Some printed tables put lanthanum and actinium in group 3 instead. In the data, those two rows have no GroupNumber.

Where do the families and colors come from?

Each element's family is its Category, from PubChem's periodic table at the National Institutes of Health. The colors are this example's choice, set in its stylesheet, so you can change them without touching the data.

Why do some elements have no discovery year?

Elements known since antiquity, and those whose discovery is disputed, have no Discovered value. The timeline always shows them, and each one's notes say why.

Working with a downloaded CSV? Convert CSV to JSON for a script, or copy a single column into the comma separator tool to make a list. Both run in your browser, with no Data Pass required.

When you need the whole list

Get the complete
datasets.

Chemical Elements is free in full, so this project needs no pass. The Data Pass covers every other dataset in the catalog, so you can build the next one the same way.

The Data Pass includes complete files in CSV, JSON, and XML, API access, and category ZIPs across the whole catalog.

Data Pass

$69one payment / 365 days

Get the Data Pass

Your key arrives by email. No account to create and no automatic renewal.

Use it in your own projects, coursework, research, or products you sell; commercial use is allowed, free or paid. Check the dataset’s scope and sources before buying; the Data Pass is non-refundable under our terms.