splitforms.com
← Back to the journal

HTML Select Placeholder: The Right Way (With CSS & React)

A select ignores placeholder. Use an empty disabled selected hidden option with required, turn it grey with :invalid, and set defaultValue in React or Vue.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5 per month.
Short answer
A <select> ignores the placeholder attribute. Make the first option the placeholder instead: <option value="" disabled selected hidden>Choose a plan</option>, and add required to the <select> so the form can't be sent until a real option is picked. Turn it grey with select:invalid { color: #6b7280 }. In React, use defaultValue="" on the select, never selected on the option.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

The answer: a disabled, empty first option

The placeholder attribute only works on text-like inputs and <textarea>. Put it on a select and nothing happens. The browser simply shows the first option. So you make the first option look and behave like a placeholder:

<select name="plan" required>
  <option value="" disabled selected hidden>Choose a plan</option>
  <option value="free">Free</option>
  <option value="pro">Pro</option>
  <option value="team">Team</option>
</select>

Here is a full working form you can paste into any page. It posts to splitforms, so you get the submissions by email and in your dashboard without writing a backend:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />

  <label for="plan">Plan</label>
  <select id="plan" name="plan" required>
    <option value="" disabled selected hidden>Choose a plan</option>
    <option value="free">Free</option>
    <option value="pro">Pro</option>
    <option value="team">Team</option>
  </select>

  <button type="submit">Send</button>
</form>

<style>
  select:invalid { color: #6b7280; }
  option { color: initial; }
</style>

If the user clicks Send without choosing, the browser stops the form and shows its "Please select an item in the list" message. Once they pick a plan, plan=pro (or whichever value they chose) is submitted. Get a free access key to try it.

What each attribute does

Each of the four attributes on the placeholder option has one job. Leave one out and the placeholder breaks in a specific way.

  • value="" makes the placeholder an empty answer. With required on the <select>, an empty value fails validation, so the form can't be submitted while the placeholder is showing.
  • disabled stops the user picking the placeholder again after they have chosen a real option.
  • selected makes the placeholder the option shown when the page loads. Without it, the first enabled option is shown instead.
  • hidden removes the placeholder from the open dropdown list, so the list only shows real choices.

hidden on an <option> is not handled the same way in every browser. Some, including Safari on iOS, can still show the hidden option in the native picker. That is why you keep disabled as well: if the placeholder does show, it is greyed out and can't be picked. If you group options with optgroup, keep the placeholder outside every group, as the first child of the <select>.

Make the placeholder grey with :invalid

A real input placeholder is grey, but the closed select shows its placeholder in the normal text color. You can fix that with two lines of CSS:

select:invalid {
  color: #6b7280; /* grey while the placeholder is showing */
}

option {
  color: initial; /* keep the real options readable in the open list */
}

This works because a required select with an empty value matches :invalid. As soon as the user picks a real option, the select becomes valid and the text returns to its normal color. It only works when both parts are there: required on the select and value="" on the placeholder. Without required the select is always valid, and without the empty value the placeholder counts as a real answer.

The option { color: initial } rule matters because in some browsers the open list inherits the select's grey color, which would make every option look disabled.

One side effect: :invalid matches from the moment the page loads, before the user has touched anything. That is fine for a grey color. Don't use the same selector for a red error border, or the form looks broken on first load. Use :user-invalid for error styles, which only matches after the user has interacted. For custom error text, see the custom HTML5 validation message guide.

React and Vue: use the select's value, not selected

React: defaultValue="" on the select

React controls which option is chosen through the <select> itself. Putting selected on an <option> triggers a warning and is ignored. For an uncontrolled form, set defaultValue="":

<select name="plan" defaultValue="" required>
  <option value="" disabled hidden>Choose a plan</option>
  <option value="free">Free</option>
  <option value="pro">Pro</option>
</select>

For a controlled select, start the state at an empty string and pass it as value:

function PlanPicker() {
  const [plan, setPlan] = useState("");

  return (
    <select name="plan" value={plan} required
            onChange={(e) => setPlan(e.target.value)}>
      <option value="" disabled hidden>Choose a plan</option>
      <option value="free">Free</option>
      <option value="pro">Pro</option>
    </select>
  );
}

Don't start the state at undefined or null. React treats that as an uncontrolled select and warns when it switches to controlled after the first pick.

Vue: v-model starting at ''

In Vue, v-model picks the option whose value matches, so start the bound value at an empty string. The empty-value placeholder is then selected on load:

<script setup>
import { ref } from "vue";
const plan = ref("");
</script>

<template>
  <select v-model="plan" name="plan" required>
    <option value="" disabled hidden>Choose a plan</option>
    <option value="free">Free</option>
    <option value="pro">Pro</option>
  </select>
</template>

Resetting the form brings the placeholder back

form.reset() (or a reset button) puts every control back to its default. For a select, the default is the option with the selected attribute in the HTML, which is your placeholder. So after a successful submit you can clear the form and the placeholder returns, grey and all:

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  await fetch(form.action, { method: "POST", body: new FormData(form) });
  form.reset(); // the select shows "Choose a plan" again
});

In a controlled React form, reset() won't change your state. Set it back yourself with setPlan("").

Common mistakes

  • Forgetting value="". An option without a value submits its text, so required passes and you receive plan=Choose a plan.
  • Using disabled without selected. The browser skips the disabled option and shows the first real option instead, so the placeholder never appears and users can send the form without making a choice.
  • Using placeholder= on the select. It isn't a valid attribute for <select> and does nothing.
  • Leaving out required. The empty value is then allowed, the form sends plan=, and the :invalid grey style never applies.
  • Using selected on an option in React. React ignores it. Use defaultValue or value on the select.

FAQ

Does the placeholder attribute work on a select?

No. placeholder is only defined for text-like inputs and textarea. A <select> ignores it. Use a first <option value="" disabled selected hidden> with your hint text instead, and add required to the <select>.

How do I make a select placeholder grey?

Add required to the <select> and give the placeholder option value="". While it is selected, the select matches :invalid, so select:invalid { color: #6b7280 } turns it grey. Add option { color: initial } so the real options in the open list keep their normal color.

How do I add a placeholder to a select in React?

Put defaultValue="" on the <select> (or value="" with state if it is controlled) and add <option value="" disabled hidden>Choose a plan</option>. Don't put selected on the option: React warns about it and ignores it.

Why does my select placeholder text get submitted?

The placeholder option has no value attribute, so the browser submits its text instead. Add value="" to the placeholder option. With required on the <select>, the empty value then blocks the form until the user picks a real option.

Does hidden on an option work on iPhone?

Not reliably. Some browsers, including Safari on iOS, can still show a hidden option in the picker. That is why the placeholder should also be disabled: even if it shows, it can't be picked again.

References: MDN: the select element and MDN: :invalid.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

How to Remove the Arrows From a Number Input (CSS, Tailwind, React)

Hide the number input spin buttons with ::-webkit-inner-spin-button and appearance: textfiel…

7 min readRead →
Tutorials

How to Change the Autofill Background Color of an Input (CSS, Tailwind)

Remove Chrome's yellow autofill background with input:-webkit-autofill, inset box-shadow, -w…

7 min readRead →
Tutorials

input type=number maxlength Not Working? 4 Fixes That Work

Why maxlength is ignored on input type=number, plus four fixes: min/max, oninput slice, text…

8 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe