Buttons, links, and lists
Picking the semantic HTML element, button, link, or list, that matches what each control does.
The last lesson gave you the page’s rooms: <header>, <nav>, <main>, <footer>, and the heading outline inside them. Now you wire up what the user clicks. Take one screen in the Acme invoicing app: a Save button that writes the open invoice, a Go to dashboard action that takes you to /dashboard, and a short list of plan features in the sidebar. Three controls, and it is tempting to reach for whatever tag is nearest.
Here is that screen written two ways. Read the first sentence under each tab before the code.
<div onClick={save}>Save</div><button onClick={() => router.push('/dashboard')}>Dashboard</button><div>Unlimited invoices</div><div>Priority support</div>Each control is a <div> or <button> chosen by habit, not by what it does.
<button type="button" onClick={save}>Save</button><Link href="/dashboard">Dashboard</Link><ul> <li>Unlimited invoices</li> <li>Priority support</li></ul>Three elements, each chosen by what the control does: act, go, or list.
The two render the same pixels, so in a browser you cannot tell them apart. But they behave completely differently the moment anyone reaches for a keyboard, a screen reader, or a right-click. This lesson teaches the decision that separates them: behavior picks the element, the class picks the look. A control that performs an action is a <button> even when styled to look like a link; a control that goes to a URL is a link even when styled to look like a button. The look is a separate knob, one you’ll reach for with Tailwind in the next chapter, and it never changes which element is correct. Last lesson the element carried the page outline; here it carries the page’s interactivity, across three families: things that do, things that go, and things that form a sequence.
Buttons perform actions
Section titled “Buttons perform actions”A <button> performs an action on the current page: submit a form, open a modal, delete a row, toggle a sort order. It acts and you stay put; it never navigates. If a click does something but doesn’t take the user to a URL, it’s a button.
So why not a <div onClick>, which fires the same handler? Because the browser gives a real <button> five behaviors you would otherwise write by hand:
- Keyboard focus. A
<button>is in theTaborder automatically. - Keyboard activation.
EnterandSpacefire its click, the convention every keyboard user expects. - A focus ring. The browser draws a visible focus indicator when the button is tabbed to.
- The role. A screen reader announces it as “button,” so a non-sighted user knows it’s actionable.
- Disabled handling. Setting
disabledremoves the button from the activation path and dims it, in one attribute.
Reach for <div onClick> and you rebuild all five yourself.
<button type> is not optional
Section titled “<button type> is not optional”A <button> inside a <form> defaults to type="submit", so a click submits the form whether or not you intended it. The attribute takes one of three lowercase values; type="Submit" does nothing and reports no error, the same casing trap you saw with JSX attributes.
type="submit"submits the form. This is the default inside a form.type="button"is inert: it runs only its ownonClickand submits nothing.type="reset"clears the form to its initial values. Rare and surprising to users, so know it exists and move on.
The reflex to build: every <button> declares its type explicitly. The default isn’t wrong, but it’s invisible, and the failure it causes is expensive: a Cancel button with no type submits the half-finished form instead of abandoning it.
function InvoiceActions() { return ( <form action={saveInvoice}> <button type="submit">Save</button> <button type="button" onClick={discard}>Cancel</button> </form> );}action is the Server Action that runs on submit. The next lesson wires it up; for now, watch the buttons.
function InvoiceActions() { return ( <form action={saveInvoice}> <button type="submit">Save</button> <button type="button" onClick={discard}>Cancel</button> </form> );}Save is the primary action, so type="submit" is right. Clicking it, or pressing Enter in a field, submits the form.
function InvoiceActions() { return ( <form action={saveInvoice}> <button type="submit">Save</button> <button type="button" onClick={discard}>Cancel</button> </form> );}With no type, Cancel would default to submit and send the half-finished invoice. Nothing warns you.
function InvoiceActions() { return ( <form action={saveInvoice}> <button type="submit">Save</button> <button type="button" onClick={discard}>Cancel</button> </form> );}type="button" makes Cancel inert to the form: it runs only its onClick.
Disabled buttons must say why
Section titled “Disabled buttons must say why”disabled is a boolean attribute: write disabled to turn it on, omit it to leave it off. A disabled button can’t be activated, the browser dims it, and it drops out of the activation path.
The cost is to the user. A disabled button is often skipped in the Tab order and gives no reason it won’t work, so a customer can stare at a greyed-out Send invoice button without knowing it’s waiting on a required field three rows up. Never hide a critical action behind a bare disabled; surface the reason where the user can find it, with an inline message, a tooltip, or a hint wired to the button with aria-describedby (covered in “data-*, aria-*, and tables”).
Icon-only buttons need a name
Section titled “Icon-only buttons need a name”Many buttons in a real app have no text: a trash can to delete a row, a copy glyph, an X to dismiss. They look obvious to a sighted user, but a <button> whose only child is an icon has no accessible name , so a screen reader user hears “button” and nothing more. aria-label gives it a name assistive tech reads aloud, without putting text on screen.
<button type="button" aria-label="Delete invoice" onClick={remove}> <TrashIcon /></button>aria-label replaces the accessible name, so on a button that already has visible text it overrides those words. Reach for it only when there’s no visible text to begin with.
Links navigate to URLs
Section titled “Links navigate to URLs”A link is the mirror image of a button: a button acts and keeps you in place, while a link takes the user to a URL — another page, an external site, a file, or a spot further down the current page. If the job is to go somewhere, it’s a link.
href is what makes a link a link
Section titled “href is what makes a link a link”The href is what makes an <a> a link. With one, it’s the real thing: focusable, activated by Enter, openable in a new tab via Cmd/Ctrl-click, right-clickable for “Open in new tab” and “Copy link,” visible to crawlers. Without one, the <a> is inert — no focus, no role, no behavior. It looks like a link and does nothing.
So if you catch yourself writing an <a> with an onClick and no href, stop. A control that acts on click is a <button>; the href is the tell that a control goes somewhere.
target="_blank" travels with rel
Section titled “target="_blank" travels with rel”By default a link replaces the current page. Add target="_blank" and it opens in a new tab instead, the right call for an external site. But target="_blank" carries a security obligation, so pair it with rel="noopener noreferrer":
noopenercuts the new page’s handle back to yours. Without it, the opened site can reach throughwindow.openerand silently redirect your original tab to a phishing clone, an attack called tabnabbing .noreferrerstrips theRefererheader, so the destination isn’t told which of your pages sent the visitor.
Modern browsers imply noopener here on their own, but write the rel explicitly anyway: defaults shift and older webviews lag. On user-generated links such as a comment or bio, add nofollow so crawlers don’t lend your ranking to whatever a stranger pasted.
Files and in-page jumps
Section titled “Files and in-page jumps”Two smaller link jobs round this out. A download attribute turns a link into a file download: <a download href="/report.pdf">, and a value (download="invoice.pdf") renames the saved file.
An href starting with # points at an element on the same page by its id. So <a href="#pricing"> scrolls to whatever has id="pricing" and updates the URL hash, with no page load — how a table of contents and the “skip to main content” link work. The target just needs a unique id, and one CSS line softens the jump (scroll-behavior: smooth, exposed by Tailwind as scroll-smooth).
<a href="/pricing">Pricing</a><a href="https://stripe.com" target="_blank" rel="noopener noreferrer">Stripe</a><a href="#faq">Jump to FAQ</a><Link> is <a> plus client-side navigation
Section titled “<Link> is <a> plus client-side navigation”For links inside your own app, Next.js gives you a better anchor. <Link> renders to a plain <a href> in the HTML the browser receives, so everything you just learned still holds: crawlers follow it, Cmd-click opens a new tab, “Copy link” works, and if JavaScript fails to load the link still navigates.
What it adds is soft navigation : clicking it changes the route without a full reload — no white flash, just the parts that changed. The rule is simple:
Internal routes use
<Link>. External links use a plain<a target="_blank" rel="noopener noreferrer">.
Watch for a navigation dressed up as a button. Real codebases are full of <button onClick={() => router.push('/dashboard')}>, a button whose entire job is to go to a URL. It works on click but loses copy-link, middle-click, and crawlability. If the control goes to a URL, it’s a <Link>, however button-like it looks.
<Link href="/dashboard">Dashboard</Link>An internal route. Renders to a real <a href> and adds soft navigation.
<a href="https://stripe.com" target="_blank" rel="noopener noreferrer">Stripe</a>An external site. A plain anchor, new tab, with the security rel pair.
<button onClick={() => router.push('/dashboard')}>Dashboard</button>Avoid this. A navigation pretending to be a button — it loses copy-link, middle-click, and crawlability.
<Link> also prefetches routes and restores scroll position, covered with the App Router later.
Choosing the element: button, link, or div
Section titled “Choosing the element: button, link, or div”Both halves point to one rule: match the element to the behavior, not the look. A <button> styled to look like a link still acts; an <a> styled to look like a button still navigates. Restyle freely, but the element has to encode what the control does.
What <div role="button"> really costs
Section titled “What <div role="button"> really costs”To see why the semantic element wins, try to fake one. Making a <div> behave like a button takes all of this:
role="button"so assistive tech announces it correctly.tabIndex={0}so it’s reachable byTab.- An
onKeyDownhandler that fires onEnterandSpace, and callspreventDefaultonSpace, or the page scrolls instead of activating. - Focus-ring CSS, because you’ve lost the browser’s default one.
aria-pressedif it’s a toggle.cursor-pointer, because a<div>doesn’t get the pointer cursor.
That is everything <button> gives you for free, rebuilt by hand, with more places for bugs to hide. Reach for <button> and restyle it. The same logic rules out <div role="link">: if it navigates, it’s an <a>.
Run the decision one question at a time.
A real button gives you keyboard focus, Enter and Space activation, the focus ring, and the “button” role, all for free. Declare the type explicitly so a surrounding form can’t hijack the click.
Soft client-side navigation, and it still renders to a plain <a href>, so copy-link, Cmd-click, and crawlers all keep working.
A plain anchor to the outside world. New tab, with the rel pair that severs window.opener and strips the referrer.
You’d hand-wire the whole list above — role, Tab reach, keyboard activation, focus ring, cursor — to get what <button> already gives you. Use <button>.
Here is the bare <button> tag, no props or handlers, and everything it hands you.
Five behaviors from the bare <button> tag. A <div> makes every one your code to own.
Now fix three planted bugs: a delete control built as a <div>, a settings link faked with a navigating button, and a Cancel button inside a form with no type. Repair each so it uses the right element and attributes.
Three controls are built with the wrong element. Fix each one: Delete should be a real <button> (not a <div>), Settings should navigate to /settings as a link, and the Cancel button inside the form must not submit it. This sandbox has no router, and <Link> renders to exactly the <a href> you'd write — so use a plain <a href="/settings"> for the navigation.
Reveal the fixed version
export function App() { return ( <div className="flex flex-col gap-3"> <button type="button" onClick={() => alert('deleted')}>Delete</button> <a href="/settings">Settings</a> <form> <button type="submit">Save</button> <button type="button">Cancel</button> </form> </div> );}Delete acts, so it’s a <button type="button">. Settings goes to a URL, so it’s an <a href="/settings">, what <Link> would render. Both form buttons declare a type: submit for Save, button for Cancel so it can’t silently submit.
Lists group related items
Section titled “Lists group related items”The third family is the lightest. A <ul> holds an unordered sequence, an <ol> an ordered one, and an <li> wraps each item. Any sequence of related, parallel items is a list: navigation links, a feature grid, a comment thread, audit-log entries.
You render them with the .map pattern from “JSX as property syntax”, and the key rule carries over: one key per <li>, tied to the data’s identity, never the array index.
<ul> {features.map((feature) => ( <li key={feature.id}>{feature.name}</li> ))}</ul>A <ul> can nest inside an <li> for a file tree or threaded comment, and <ol> takes start, reversed, and type to control numbering. You’ll rarely reach for either.
When a row of items is a list
Section titled “When a row of items is a list”Overusing <ul> is as much a problem as underusing it. The test: would a screen-reader user usefully hear “list, three items” here? Navigation links, feature cards, and comments under a post are related and parallel, so the count helps; they’re a list. A logo beside a sign-in button just sits near it, so it’s not.
Build a nav as a bare row of <a>s and it renders fine, but silently drops the “list of N items” count that tells a screen-reader user how many destinations there are.
The nav-list pattern
Section titled “The nav-list pattern”The last lesson named the primary navigation as a list. Here it is: a <nav> landmark wrapping a <ul> of <li>, each holding a <Link>.
const links = [ { href: '/dashboard', label: 'Dashboard' }, { href: '/invoices', label: 'Invoices' }, { href: '/customers', label: 'Customers' },];
function PrimaryNav() { return ( <nav aria-label="Primary"> <ul className="flex gap-4 list-none"> {links.map((link) => ( <li key={link.href}> <Link href={link.href}>{link.label}</Link> </li> ))} </ul> </nav> );}The <nav> landmark, named with aria-label so a screen reader can tell it from any other nav on the page.
const links = [ { href: '/dashboard', label: 'Dashboard' }, { href: '/invoices', label: 'Invoices' }, { href: '/customers', label: 'Customers' },];
function PrimaryNav() { return ( <nav aria-label="Primary"> <ul className="flex gap-4 list-none"> {links.map((link) => ( <li key={link.href}> <Link href={link.href}>{link.label}</Link> </li> ))} </ul> </nav> );}A real semantic list, so assistive tech announces “list, 3 items.” flex gap-4 lays it out horizontally and list-none drops the bullets; the semantics survive the styling.
const links = [ { href: '/dashboard', label: 'Dashboard' }, { href: '/invoices', label: 'Invoices' }, { href: '/customers', label: 'Customers' },];
function PrimaryNav() { return ( <nav aria-label="Primary"> <ul className="flex gap-4 list-none"> {links.map((link) => ( <li key={link.href}> <Link href={link.href}>{link.label}</Link> </li> ))} </ul> </nav> );}.map over the data, one <li> per link, each keyed by its href rather than the array index.
const links = [ { href: '/dashboard', label: 'Dashboard' }, { href: '/invoices', label: 'Invoices' }, { href: '/customers', label: 'Customers' },];
function PrimaryNav() { return ( <nav aria-label="Primary"> <ul className="flex gap-4 list-none"> {links.map((link) => ( <li key={link.href}> <Link href={link.href}>{link.label}</Link> </li> ))} </ul> </nav> );}An internal <Link> inside each <li>: soft navigation, still a real <a href> underneath.
Now sort the three families apart. Drop each control or piece of content into the element family it should use.
Sort each control or piece of content into the element family it should use. Drag each item into the bucket it belongs to, then press Check.
Button types inside a form
Section titled “Button types inside a form”Inside a form, a <button> defaults to type="submit", and pressing Enter in a text input submits too. So any unmarked button, and any stray Enter, fires the form unless every non-submit button is marked type="button".
A submit button can also carry its own formAction, formMethod, formEncType, formNoValidate, or formTarget to override the form’s matching attribute for that one button; recognize them, you’ll rarely need them. The inputs, labels, and the name-to-data contract come in the next lesson.