Index

HTML: semantics, forms and meta

Nobody fails an interview on HTML, and plenty of people lose the room on it. These are the first ten minutes: short questions with obvious-sounding answers, asked to find out whether you know the difference between a tag that means something and a tag that looks a certain way. The whole subject collapses into one idea - the browser, the search crawler and the screen reader all read the same document, and only one of them can see your CSS.

The one mental model

An HTML document is not a picture of a page. It is a tree of elements, each of which claims to BE something - a heading, a list, a button, a navigation region - and the browser builds three separate things out of that tree: a render tree for pixels, an accessibility tree for assistive technology, and a set of behaviours (what is focusable, what submits, what the arrow keys do). CSS only touches the first of the three.

That is why almost every HTML answer has the same shape. A div styled to look like a button gets the pixels and nothing else: it is not in the accessibility tree as a button, it is not focusable, Enter and Space do not fire it, and it is not announced as one. You are not choosing a tag for its default styling. You are choosing which of those three trees you want filled in for free.

Default display is the smallest thing a tag gives you
Every element has a default display value, and it is the part people fixate on because it is the part you can see. It is also the part CSS can overwrite in one line. The parts CSS cannot give you - semantics, focusability, keyboard behaviour, form participation - are the reason the tag exists.
The parser never fails
There is no such thing as an HTML syntax error that stops the page. The parser has a defined recovery for every malformed thing you can write: unclosed tags get closed for you, misnested tags get reordered, elements that cannot legally sit where you put them get moved or dropped. This is a feature (the web does not break) and a trap (your broken markup produces a DOM you did not write, and you debug the wrong tree).

Questions

Answer each one yourself before you open it.

  1. 01What is the difference between a div and a span?

    Both are meaningless containers you use purely to hang styling or scripting on. The only real difference is default display: a div is block-level so it starts on a new line and fills the available width, a span is inline so it sits inside a line of text.

    Neither one means anything to a screen reader, a crawler or the browser's own behaviour - that is the point of them, they are the escape hatch for when no semantic element fits. Because the difference is only default display, and display is one CSS declaration, choosing between them is a much smaller decision than choosing between div and nav.

    Follow-up So when should you actually use one?

    When there genuinely is no meaning to express - a styling wrapper, a grid cell, a scripting hook. If the thing is navigation it is nav, if it is a control it is button, if it is a self-contained piece of content it is article. Reaching for div first and semantics never is the habit the question is testing for.

  2. 02Inline, block, inline-block - what is the difference?

    Block takes a whole line, fills the container's width by default, and respects width, height and margins on all four sides. Inline flows inside a line of text, is only as wide as its content, and ignores width, height and vertical margins. Inline-block is the hybrid: it sits in the line like inline, but it takes width, height and all four margins like block.

    The distinction is about how the box participates in layout. An inline box is a piece of a line of text, so giving it a height is meaningless - the line box height comes from line-height and font metrics, not the element. A block box is a rectangle in the normal flow, so it has a size of its own. Inline-block was the pre-flexbox way to put boxes side by side, and its remaining sharp edge is that whitespace between the tags in your source renders as a real space between the boxes.

    Follow-up Name something that is inline by default and catches people out.

    img. It is a replaced inline element, which is why an image in a div leaves a few pixels of gap underneath - that gap is the space reserved for descenders on the text baseline. The usual fixes are display: block on the image, or vertical-align: middle.

  3. 03I set width: 200px on a span and nothing happened. Why, and how do I fix it?

    width and height do nothing on a non-replaced inline element - the box is sized by its content, full stop. To make them apply you have to stop the element being inline: display: inline-block, display: block, or make it a flex or grid item. Position: absolute or fixed and float also do it, by blockifying the box.

    The inline box model only reserves horizontal space. Horizontal padding and margin do apply to an inline element and will push its neighbours; vertical padding and border are drawn but do not push anything, so they overlap the lines above and below. That asymmetry is what makes the bug confusing: some of what you set appears to work.

    Follow-up Which inline elements do respect width?

    Replaced elements - img, video, iframe, input, canvas, select. They are inline-level but have intrinsic dimensions, so width and height apply to them even though they are display: inline.

  4. 04What does <!DOCTYPE html> do?

    It tells the browser to use standards mode. It is not a version declaration and it is not parsed as anything else - it is a switch, and leaving it out drops the page into quirks mode.

    Modern browsers have three parsing modes: standards, almost-standards, and quirks. Quirks mode exists to keep pages written for 1990s browsers rendering the way they did then, so it turns on a pile of bug-compatible behaviour. Nothing about it is desirable in new code, and the one line at the top of the file is the whole prevention.

    Follow-up What actually changes in quirks mode?

    The big one is the box model: width includes padding and border, the way old Internet Explorer did it, so every layout measurement is wrong. You also get inconsistent handling of vertical-align on table cells, line-height on images, percentage heights, and a document that will not compute the way any reference tells you it should.

  5. 05Why use h1, h2, h3 rather than a div with a big font?

    Because headings are the document's outline, and everything except the pixels reads that outline. Screen reader users navigate a page by jumping heading to heading, crawlers use them to work out what the page is about, and browsers expose them in reader modes and outlines. A styled div gives you the size and none of that.

    The concrete failure is invisible from the outside: a screen reader has a 'list all headings' command, and it is the primary way a blind user gets the shape of an unfamiliar page. A page whose headings are divs presents as one flat wall of text with no way in. You get the same look for free by styling the real heading tag.

    Follow-up Do the levels have to be in order?

    Do not skip levels going down - h2 then h4 with no h3 breaks the outline. Going back up is fine, that is just a new section. Modern practice allows more than one h1 in a page that has multiple top-level regions, but one h1 that names the page is still the answer that never draws a follow-up.

  6. 06What is semantic HTML?

    Choosing the element that describes what the content IS rather than what it should look like. header, nav, main, article, section, aside, footer, figure, figcaption, time, address, plus the older ones that were always semantic - ul, ol, table, form, label, button, and the headings.

    The payoff is three-fold and none of it is visual: assistive technology gets landmarks and roles it can navigate by, crawlers get structure, and you get keyboard and form behaviour built in. It also makes the markup readable - a file of nested divs with class names tells you nothing six months later.

    Follow-up Give me a concrete before and after.

    div class='nav' becomes nav, div class='btn' with a click handler becomes button, div class='card-title' becomes h3, and a div with a click handler wrapping text becomes an a with an href if it navigates. Each swap adds a landmark, a role or keyboard behaviour that you were otherwise going to reimplement badly.

  7. 07section versus article versus div?

    article is a self-contained piece of content that would still make sense somewhere else - a blog post, a comment, a product card. section is a thematic grouping inside a document that normally has a heading. div means nothing at all and is for styling.

    The usable test for section is the heading test: if you cannot naturally give it a heading, it is not a section, it is a div. The usable test for article is the syndication test: if you could lift it out and paste it into a feed and it would still stand up, it is an article. Nesting is allowed and normal - comments are articles inside the article they are attached to.

  8. 08What is main for, and how many can a page have?

    main wraps the dominant content of the page - everything that is not repeated site furniture like the header, nav, sidebar and footer. One per page, and it must not be nested inside article, aside, header, footer or nav.

    It exists so a screen reader user can skip straight to the content with one keystroke instead of tabbing through the navigation on every page. It is the element that makes 'skip to content' links redundant, and it costs one tag.

  9. 09What does <meta charset='utf-8'> do, and why is it first in the head?

    It tells the browser which encoding to decode the bytes with. It has to appear within the first 1024 bytes of the document, which in practice means first thing in the head, because the browser has to guess an encoding to start parsing at all and will restart the parse if the declaration contradicts its guess.

    Get it wrong and you get mojibake - curly quotes, em dashes and any non-English text rendered as garbage. utf-8 is the only answer in new code: it covers every script and emoji, it is ASCII-compatible so English text costs one byte a character, and it is what the HTML spec now requires.

  10. 10What does the viewport meta tag do?

    width=device-width, initial-scale=1 tells a mobile browser to lay the page out at the device's own width instead of pretending to be a 980px desktop screen and zooming out. Without it your media queries never match and the whole page renders shrunk.

    Mobile browsers default to a wide virtual viewport specifically so old desktop-only sites are legible when zoomed out. That default silently defeats responsive CSS: your min-width: 768px query fires on a phone because the layout viewport claims to be 980 wide. The meta tag is the opt-in to real responsive behaviour, and it is one line that nothing else can substitute for.

    Follow-up Should you add user-scalable=no?

    No. It blocks pinch-zoom, which is an accessibility failure for anyone with low vision, and iOS has ignored it for years anyway. Fix the layout instead.

  11. 11Which meta tags actually matter?

    Three: charset, viewport, and title (a tag rather than a meta, but it is what the question means). Then description, because search engines and social cards use it as the snippet, and Open Graph tags if the page is ever shared. Almost everything else is cargo cult.

    The ones that matter change rendering or change what appears in someone else's product. keywords has been ignored by every major search engine for well over a decade. author, generator and revisit-after do nothing. Saying 'the rest are noise' out loud is a stronger answer than reciting a longer list.

  12. 12Does the meta description affect ranking?

    No, not directly. It affects the snippet a search engine shows, which affects whether anyone clicks - so it matters, just not the way people say it does. Search engines routinely rewrite it from page content when they think theirs is better.

  13. 13What does the robots meta tag do, and how is it different from robots.txt?

    meta name='robots' content='noindex, nofollow' tells a crawler not to index this page and not to follow its links. robots.txt tells a crawler not to REQUEST a URL at all. The difference matters: a page blocked in robots.txt can still be indexed from inbound links, because the crawler never fetched the page to see your noindex.

    If your goal is 'keep this out of search results', robots.txt is the wrong tool and the meta tag (or an X-Robots-Tag header, for non-HTML files) is the right one. If your goal is 'stop wasting crawl budget on this URL space', robots.txt is right. Neither is security - both are advisory and both are public.

  14. 14What are Open Graph tags?

    og:title, og:description, og:image, og:url and og:type in meta tags, read by anything that unfurls a link - social networks, chat apps, previews. Without them the unfurl falls back to the title and a guessed image, or to nothing.

    They are the same class of thing as the description: they do not change your page, they change how your page appears inside someone else's product. og:image is the one with real rules - an absolute URL, and a size the platform will accept, around 1200x630. Twitter's twitter:card tags layer on top but fall back to Open Graph, so Open Graph alone is a reasonable answer.

  15. 15What are global attributes?

    Attributes valid on any element: id, class, style, title, hidden, tabindex, lang, dir, contenteditable, draggable, plus all the data-* and aria-* attributes and the on* event handler attributes.

    Follow-up What does the title attribute actually do?

    It produces the native tooltip on hover. It is a poor accessibility tool - it does not appear on touch, it does not appear on keyboard focus in most browsers, and it is inconsistently announced - so never put information there that is not available elsewhere.

  16. 16What are data-* attributes for?

    Attaching custom data to an element without inventing invalid attributes. You read them in JS with element.dataset, where data-user-id becomes dataset.userId, and in CSS with attribute selectors like [data-state='open'].

    The camel-casing is the part people get wrong: the dash-separated attribute name maps to a camelCase key on dataset. Values are always strings - there is no number and no boolean, so data-count='0' comes back as '0', which is truthy. Storing large blobs of JSON there is a smell; a data attribute is for a flag or an id, not for state.

  17. 17When should you NOT use aria-label?

    When there is visible text that already names the element. The first rule of ARIA is not to use ARIA - a native element with real text needs no label, and adding one overrides what the user can see, so the screen reader announces something different from what is on screen.

    That mismatch breaks voice control outright: someone saying 'click Save' cannot activate a button that looks like Save but is labelled 'Submit form'. The legitimate uses are narrow - an icon-only button, an ambiguous link like 'Read more', a nav or region that needs distinguishing from another of the same type. And aria-label does not work reliably on generic elements like div and span; it needs a role that supports a name.

    Follow-up aria-label versus aria-labelledby?

    aria-labelledby points at the id of visible text and wins over aria-label. Prefer it, because it reuses text the user can actually see, which keeps the announced name and the visible name in sync.

  18. 18What is a boolean attribute?

    One whose presence alone means true - disabled, checked, required, readonly, selected, multiple, autofocus, defer, async. The value is irrelevant: disabled='false' is still disabled. To turn it off you remove the attribute.

    This bites in templates, where a framework or string concatenation happily writes checked='false' and you get a permanently checked box. It also bites in the difference between the attribute and the property: getAttribute('checked') reflects the initial markup, element.checked reflects the live state after the user clicks. Read the property, not the attribute.

  19. 19What is an iframe, and what are the risks?

    An embedded browsing context - another whole document inside yours. The risks run both ways: the framed page can try to break out or phone home, and YOUR page can be framed by someone else and used for clickjacking. Lock down what you embed with the sandbox attribute, and stop yourself being embedded with the Content-Security-Policy frame-ancestors directive.

    Clickjacking is the attack that is easy to describe and easy to miss: an attacker loads your real, logged-in page in an invisible iframe, positions it under a decoy button, and the user's real click lands on your real 'delete account' control. Nothing is spoofed, which is why nothing looks wrong. frame-ancestors 'none' (or the older X-Frame-Options: DENY) is the fix, and it is a response header, not markup.

    Follow-up What does sandbox do?

    It strips the framed document of capabilities and you add them back one at a time: allow-scripts, allow-forms, allow-same-origin, allow-popups. The trap is that allow-scripts plus allow-same-origin together lets the framed page remove its own sandbox attribute, so that pair on same-origin content is not a sandbox at all. Also worth naming: loading='lazy' for offscreen frames, and referrerpolicy to stop leaking your URLs.

  20. 20What form controls do you know, and which input types are worth using?

    input, textarea, select with option and optgroup, button, fieldset with legend, label, output, progress, datalist. On input the types worth naming are text, email, tel, url, number, password, search, date, time, datetime-local, month, week, color, range, file, checkbox, radio, hidden.

    The reason to reach for a specific type rather than text is mobile: type='email' and type='tel' change the on-screen keyboard, type='number' gives a numeric pad, and type='search' gives the clear button. You also get free validation and free native UI. The cost is that the native widget is barely styleable, which is why date and color get replaced with custom components so often.

    Follow-up When would you not use type='number'?

    For anything that is a sequence of digits rather than a quantity - phone numbers, card numbers, postcodes, OTP codes. Number inputs strip leading zeros, allow e and +/- for exponent notation, and scroll-wheel over the field changes the value. Use inputmode='numeric' with pattern instead.

  21. 21Tell me about <input type='date'>.

    It gives you the browser's native date picker with no JavaScript. Its value is always the ISO string YYYY-MM-DD regardless of how the picker displays the date to the user, and you constrain it with min, max and step.

    The value format is the part that catches people: the field may show 22/08/2026 to a UK user and 08/22/2026 to a US one, and read back as '2026-08-22' in both. That is a feature - you never parse a locale-formatted string. valueAsDate gives you a Date object, and valueAsNumber a timestamp. What you cannot do is style the picker, which is the usual reason teams swap in a component library.

    Follow-up How do you set today as the default?

    Set the value attribute to the ISO string; there is no 'today' keyword. new Date().toISOString().slice(0, 10) gets you it in UTC, which is off by a day for some users late in the evening - use local parts if that matters.

  22. 22Why does a label need a for attribute?

    for points at the input's id and binds the two, so clicking the label focuses or toggles the control, and a screen reader announces the label when the control gets focus. Without it the text next to a checkbox is just text.

    The hit-target effect alone is worth it - a checkbox is a 13px square, and a bound label makes the whole phrase clickable. The accessibility effect is bigger: an unlabelled input is announced as 'edit text, blank', and the user has no idea what to type. Wrapping the input inside the label works too and needs no ids, which is often the cleaner option; do not do both with mismatched targets.

    Follow-up What about placeholder as a label?

    Never. It disappears the moment someone types, it fails contrast in most designs, and it is announced inconsistently. Placeholder is for an example of the format, next to a real label.

  23. 23What does the name attribute do on a form control?

    It is the key the value is submitted under. No name, no submission - the control's value simply is not in the form data. It is also what groups radio buttons: same name means one choice between them.

  24. 24GET or POST for a form?

    GET when the submission is a read that should be bookmarkable and shareable - a search, a filter. POST when it changes something on the server, or when the data is sensitive or large, because GET puts every field in the URL, which lands in browser history, server logs and the Referer header.

  25. 25What HTML validation can you do without JavaScript?

    required, min, max, minlength, maxlength, step, pattern with a regex, and the type itself for email and url. The browser blocks submission and shows a native message, and you can style the states with :valid, :invalid and :user-invalid.

    It is free, instant and works before your bundle loads - but it is a UX affordance, not a security control. Anything can post to your endpoint without ever loading your form, so the server revalidates everything, always. The other half of the answer is that the native error bubbles are unstyleable and inconsistent across browsers, which is why teams add novalidate and take over the messaging with the Constraint Validation API.

  26. 26How do you embed audio and video, and what is the source fallback for?

    audio and video elements with controls, and one or more source children each naming a file and its type. The browser walks the list top to bottom and plays the first format it supports; anything after the last source is the fallback content for a browser that supports neither.

    Format support is not universal - MP4/H.264 is the safe baseline, WebM is smaller and widely but not universally supported - so you list the modern format first and the safe one second. Naming the type attribute matters because it lets the browser choose without downloading bytes to sniff. On video, also set poster for the frame shown before playback, and preload='metadata' so you are not pulling megabytes for a video nobody plays. Autoplay only works muted.

    Follow-up How do you caption it?

    A track element with kind='captions', a .vtt file and a srclang. It is the accessibility requirement people forget, and it is also what makes the video usable in a silent feed.

  27. 27How do you load Google Fonts, and would you?

    A link to the stylesheet in the head, ideally with a preconnect to fonts.gstatic.com so the font file's connection is warmed while the CSS downloads. I would usually self-host instead: download the woff2 files, serve them from my own origin, and declare them with @font-face.

    The old argument for a font CDN was a shared cache across sites, and that argument is dead - browsers partition their HTTP cache by top-level site precisely to stop the cross-site tracking it enabled, so a visitor downloads your font again no matter who else uses it. What is left is two extra DNS lookups and TLS handshakes on the critical path, a third-party dependency your page cannot render without, and in some jurisdictions a privacy problem, because the request hands the visitor's IP to another company.

    Follow-up What does font-display: swap do?

    It says: show the fallback font immediately, then swap in the web font when it arrives. The default, block, hides the text for up to three seconds - the flash of invisible text. swap trades that for a flash of unstyled text and a reflow, which is a layout shift you can mostly cancel by choosing a fallback with similar metrics and tuning size-adjust. optional is the ruthless version: use the font if it is already cached, otherwise never, and no shift at all.

  28. 28What did HTML5 add?

    Semantic sectioning elements (header, nav, main, article, section, aside, footer, figure), native audio and video, canvas and inline SVG, new input types and validation attributes, data-* attributes, and a set of JS APIs that shipped alongside it - localStorage and sessionStorage, geolocation, web workers, web sockets, history.pushState, drag and drop.

    The bigger structural change was the doctype and the parser: HTML5 is the first version that specified error handling, so every browser now builds the same DOM from the same broken markup. It also formally dropped presentational elements and attributes - font, center, bgcolor, align - and pushed all of that to CSS.

  29. 29What makes good alt text?

    Describe the function the image serves in this context, not the image. If the picture carries information, write the information. If it is purely decorative, use alt='' - an empty alt, present and empty, so the screen reader skips it entirely.

    Missing alt and empty alt are opposite things. Missing means the screen reader falls back to announcing the filename, which is the worst outcome. Empty means 'this is decoration, ignore it', which is exactly right for a background flourish. Also: no 'image of' or 'photo of' prefix, the announcement already says it is an image; and if the image is inside a link, the alt text is the link text, so describe the destination, not the picture.

  30. 30What is focus order, and when do you touch tabindex?

    Focus order is the sequence the Tab key walks, and by default it follows the DOM order. Keep it that way. tabindex='0' puts a normally-unfocusable element into that natural order; tabindex='-1' makes something focusable by script but not by Tab. A positive tabindex jumps ahead of everything and you should never use it.

    A positive value does not reorder locally, it creates a whole separate priority pass ahead of the entire document, so one tabindex='1' anywhere breaks the tab order of the whole page. The other common bug is CSS: reordering with flex's order or grid placement changes the visual order but not the DOM order, so a keyboard user tabs in one sequence and sees another.

  31. 31Why is a button better than a div with an onclick?

    A button is focusable, is announced as a button, fires on both Enter and Space, works with voice control and with the browser's form submission, and gets the platform focus ring. A div gets a mouse click and nothing else.

    Making a div equivalent means adding tabindex='0', role='button', a keydown handler for Enter AND Space with the right preventDefault for Space scrolling, a focus style, and aria-disabled handling - and you will still miss something. Say the list out loud; it is a more convincing answer than 'accessibility'.

    Follow-up What if it navigates rather than acts?

    Then it is an a with a real href, not a button. That gives middle-click to open in a new tab, copy link address, and the browser's own status bar preview - none of which a JS click handler can reproduce.

  32. 32b versus strong, i versus em?

    strong and em carry meaning - importance and stress emphasis - and are announced as such. b and i are purely presentational: bold and italic with no meaning implied, which is right for a product name, a taxonomic term, or a ship's name.

    The honest version is that in HTML5 b and i were redefined rather than deprecated: they now mean 'stylistically offset for a reason that is not emphasis'. In practice, reach for strong and em when you mean it, and use CSS when you just want the weight.

  33. 33What are void elements, and do you need the trailing slash?

    Elements that cannot have children and take no closing tag: img, br, hr, input, meta, link, source, area, col, embed, track, wbr. The trailing slash in <br /> is allowed and ignored in HTML - it is a JSX and XHTML habit, not a requirement.

    The slash is not merely optional, it is meaningless: putting one on a NON-void element does nothing at all, so <div /> opens a div and everything after it becomes its child. That one silently swallows the rest of your page.

  34. 34id or class?

    id is unique per document and is the hook for label's for, aria-labelledby, fragment links and getElementById. class is reusable and is what you style with. Style with classes, identify with ids.

    The practical reason to avoid styling by id is specificity: an id selector outranks any number of classes, so one #header rule ends up needing !important to override and you have started a specificity war. Duplicate ids are also not a validation nitpick - getElementById returns the first, and aria-labelledby resolves to the first, so the second one silently does nothing.

  35. 35How do you serve the right image size to a phone and a laptop?

    srcset with sizes on an img, so the browser picks a file based on viewport and device pixel ratio. Use picture with source elements when you need art direction - a different crop, not just a different size - or a modern format like AVIF with a JPEG fallback.

    The important distinction is who decides. With srcset the browser chooses, and it knows things your CSS does not: the device pixel ratio, the current network, the user's data-saver setting. With picture you choose, via media conditions, which is why it is the right tool only when the images genuinely differ. Also set width and height attributes on the img - that is what lets the browser reserve the box and avoid a layout shift.

  36. 36Where do you put script tags?

    In the head with defer. That downloads the script in parallel with parsing and runs it after the DOM is built, in document order - which is what putting scripts at the end of the body was approximating.

    A plain script tag in the head blocks the parser: HTML parsing stops, the file is fetched and executed, and only then does the document continue. End-of-body was the workaround before defer existed. Use async only for genuinely independent scripts like analytics, because async runs whenever it lands, which means out of order and possibly before the DOM exists. type='module' is deferred by default.

  37. 37Why does the browser render a page with unclosed tags?

    Because the HTML parser has a specified recovery for every malformed construct - that is part of the standard, not a browser kindness. It closes what you left open, moves what cannot be where you put it, and drops what makes no sense.

    The consequence is that your bug is not a rendering bug, it is a DOM bug: the tree you are debugging is not the markup you wrote. A block element inside a p closes the p. A tr outside a table is dropped, but its text is kept. Once you know the parser rewrote your tree, inspecting the Elements panel rather than the source is the fast path.

  38. 38What is the difference between an attribute and a property?

    The attribute is what is in the HTML and is always a string; the property is on the DOM object and has a real type. For most attributes they are kept in sync, but value and checked are not - the attribute holds the INITIAL value and the property holds the current one.

    So after a user types in an input, getAttribute('value') still returns whatever the markup said, while element.value returns what they typed. That is why form.reset() can restore the original: the attribute never moved. Read properties for state, attributes for configuration.

  39. 39How do you make a table accessible?

    Use real table markup: caption for the table's name, thead/tbody, and th with scope='col' or scope='row' for the headers. That is what lets a screen reader announce 'Revenue, Q3' when the user lands on a cell instead of reading a naked number.

    Tables are the one place the old advice reversed: they were abused for layout for a decade, so people learned to avoid them, and now data gets rendered as divs in a grid. A grid of divs is unnavigable - there is no row and column association at all. Tabular data goes in a table.

  40. 40What is the difference between hidden, and CSS display: none?

    The hidden attribute is effectively a built-in display: none, applied via the UA stylesheet - so any CSS display rule on that element beats it. Both remove the element from layout and from the accessibility tree.

    The gotcha is exactly that specificity: an element with hidden that also matches a rule setting display: flex will show, because an author stylesheet outranks the user-agent one. If you use the attribute, pair it with [hidden] { display: none !important; } or stop mixing the two mechanisms. hidden='until-found' is the newer variant that stays hidden but can be revealed by in-page find.

Predict the output

The output is hidden. Predict it before you open it.

Boolean attributes ignore their value

document.body.innerHTML = '<input id="a" type="checkbox" checked="false">';
const a = document.getElementById("a");
console.log(a.checked);
console.log(a.getAttribute("checked"));
Show the output
true
"false"

checked is a boolean attribute: presence is all that counts, so checked='false' checks the box. getAttribute gives you the literal string that was written; the property gives you the real state. This is the bug that appears the moment a template interpolates a variable into a boolean attribute.

The parser rewrites your tree

document.body.innerHTML = "<p>a<div>b</div>c</p>";
console.log(document.body.innerHTML);
Show the output
<p>a</p><div>b</div>c<p></p>

A p cannot contain a div, so the opening div implicitly closes the p. The 'c' then sits directly in the body. By the time the stray </p> is reached there is no open p, and the parser's recovery for that is to insert an empty one. Three elements out of markup that looked like one.

A self-closing slash on a non-void element

document.body.innerHTML = "<div/>a<span/>b";
console.log(document.body.innerHTML);
Show the output
<div>a<span>b</span></div>

In HTML, the trailing slash is only permitted on void elements and is ignored everywhere else. So <div/> opens a div that is never closed, and it swallows everything after it. JSX habits do this to real HTML files.

dataset camel-cases the name

document.body.innerHTML = '<div id="d" data-user-id="0" data-role="admin"></div>';
const d = document.getElementById("d");
console.log(d.dataset.userId, typeof d.dataset.userId);
console.log(d.dataset.role);
console.log(d.dataset["user-id"]);
Show the output
0 string
admin
undefined

data-user-id becomes dataset.userId - dashes are removed and the next letter uppercased, and the dashed key does not exist on dataset at all. Every value is a string, which is why data-count='0' is truthy and why comparing with === to a number always fails.

The value attribute does not track typing

document.body.innerHTML = '<input id="i" value="start">';
const i = document.getElementById("i");
i.value = "typed";
console.log(i.value);
console.log(i.getAttribute("value"));
console.log(i.outerHTML);
Show the output
typed
"start"
<input id="i" value="start">

value is the classic attribute/property split: the attribute is the default, the property is the live state. Nothing you type - or assign to .value - is reflected back into the HTML, which is why copying innerHTML never captures what a user has entered.

A date input's value format

document.body.innerHTML = '<input id="d" type="date" value="2026-08-22">';
const d = document.getElementById("d");
console.log(d.value);
console.log(typeof d.value);
console.log(d.valueAsNumber);
Show the output
2026-08-22
string
1787356800000

The displayed format follows the user's locale; the value is always ISO YYYY-MM-DD, and always a string. valueAsNumber gives the epoch milliseconds for midnight UTC on that date - note UTC, which is where off-by-one-day bugs come from when you build a local Date out of it.

A button inside a form

document.body.innerHTML = "<form><button>Go</button></form>";
console.log(document.querySelector("button").type);
Show the output
submit

A button with no type attribute defaults to type='submit' inside a form. So a 'toggle the panel' button in a form reloads the page, and the click handler appears not to work because the navigation wipes it. Any button that is not submitting needs type='button' explicitly.

What is focusable by default

document.body.innerHTML = '<a id="x">no href</a><a id="y" href="#">href</a><div id="z">div</div>';
console.log(document.getElementById("x").tabIndex);
console.log(document.getElementById("y").tabIndex);
console.log(document.getElementById("z").tabIndex);
Show the output
-1
0
-1

An anchor is only in the tab order if it has an href - which is why 'a with a click handler and no href' is invisible to keyboard users. A div is never in the tab order until you give it tabindex='0'. This is the measurable half of 'use a button'.

Table markup outside a table

document.body.innerHTML = "<td>cell</td>";
console.log(document.body.innerHTML);
console.log(document.querySelectorAll("td").length);
Show the output
cell
0

The td start tag is not allowed in body context so the parser drops the element, but keeps its text. You get content with no structure and no error. The same class of silent drop is why building table rows with innerHTML on the wrong parent produces nothing.

template content is inert

document.body.innerHTML = '<template id="t"><p>hi</p></template>';
console.log(document.querySelectorAll("p").length);
console.log(document.getElementById("t").content.querySelectorAll("p").length);
Show the output
0
1

A template's children are parsed into a separate document fragment, not into the page: they do not render, images inside do not load, and scripts do not run. You reach them through .content, and clone that when you stamp it out. querySelector on the document will never find them.

Duplicate ids

document.body.innerHTML = '<p id="dup">first</p><p id="dup">second</p>';
console.log(document.getElementById("dup").textContent);
console.log(document.querySelectorAll("#dup").length);
Show the output
first
2

The document does not reject the duplicate; getElementById just returns the first match, and so does anything else that resolves an id - label's for, aria-labelledby, a fragment link. querySelectorAll still sees both, which is how the bug hides: your CSS styles both elements and your JS only ever touches one.

Whitespace between inline-block boxes

document.head.insertAdjacentHTML("beforeend", "<style>span{display:inline-block;width:100px}</style>");
document.body.innerHTML = "<span></span>\n<span></span>";
const s = document.querySelectorAll("span");
console.log(s[1].offsetLeft > 100);
Show the output
true

The newline between the two tags is a text node and renders as a space, so the second box does not start at 100px. That whitespace gap is the reason inline-block layouts drifted and the reason people used to write comments between tags. Flexbox has no such behaviour, which is most of why it replaced this technique.

← Re-renders, memo and optimisationCSS: box model, layout and positioning →