iKit
Technical · 10 min read ·

kebab-case in CSS, URLs, and CLI Flags: Why Hyphens Win (2026)

kebab-case shows up in CSS properties, URL slugs, and command-line flags for reasons that are not stylistic. Here is what the specs actually require.

kebab-case in CSS, URLs, and CLI Flags: Why Hyphens Win (2026)

kebab-case in CSS, URLs, and CLI Flags: Why Hyphens Win

Open a stylesheet, a router file, and a Makefile side by side and you will see the same shape three times: background-color, /summer-clothing, --dry-run. That is kebab-case, and its dominance in those three places is not a style preference someone won an argument about. Each context has a technical reason the hyphen works and the underscore or capital letter does not.

TL;DR

  • kebab-case wins where the token is read by a parser and a human.
  • CSS property names are case-insensitive; CSS custom properties are not.
  • Google explicitly recommends hyphens over underscores in URL paths.
  • Hostnames have never allowed underscores — the LDH rule predates the web.
  • kebab-case is illegal in most languages: hyphen means minus.

What kebab-case actually is

kebab-case writes every word in lowercase and joins them with a single hyphen: user-profile-card. It also travels under the names dash-case, lisp-case, spinal-case, and — in the CSS world — simply "hyphenated". The variant with capitalised words, User-Profile-Card, is Train-Case or HTTP-Header-Case, and it survives mainly in header field names.

Why the hyphen reads as a word boundary

The underscore has a long history as a joiner: it exists to glue a multi-word identifier into one token in languages where a space would break the parse. format_date is one function, not two words. The hyphen inherited the opposite job from typography, where it separates. When a parser or a crawler has to guess whether summer_clothing is one concept or two, history says one; summer-clothing says two without ambiguity.

The one place the hyphen is not free

In nearly every programming language, - is the subtraction operator. user-profile parses as user minus profile. That single fact explains the entire distribution of kebab-case: it thrives in declarative formats where identifiers are never part of an expression (CSS, HTML attributes, URLs, YAML keys, CLI arguments) and is banned everywhere an identifier can appear in arithmetic. There is no deeper principle at work.

Why do URLs use hyphens instead of underscores

This is the one context where the recommendation is written down by the party that matters.

Google's own recommendation

Google Search Central's URL structure documentation recommends separating words with hyphens rather than underscores, and gives the reason directly: underscores are already commonly used to keep concepts together, as programming languages do when naming functions like format_date. The recommended example is https://example.com/summer-clothing/; both summer_clothing and the run-together greendress are listed as not recommended.

✅ /blog/kebab-case-css-urls-cli-flags
⚠️ /blog/kebab_case_css_urls_cli_flags
❌ /blog/kebabcasecssurlscliflags
❌ /blog/post?id=4127

Hostnames could never use underscores anyway

The path segment is a modern argument. The hostname was settled in 1987. The preferred name syntax in RFC 1035, section 2.3.1 allows a label to contain only letters, digits, and the hyphen — the so-called LDH rule — and requires that a label not end in a hyphen. RFC 1123 later relaxed the rule so a label may start with a digit as well as a letter, but the underscore never entered the grammar. So my-app.example.com is a legal host and my_app.example.com is not, which is why every hosting panel you have used quietly rewrites underscores.

URLs are case-sensitive, which rules out camelCase

Google's same page notes that its URL handling is case-sensitive: /APPLE and /apple are treated as distinct URLs with their own content. Any casing convention that relies on capital letters therefore doubles your canonicalisation surface — someone will link to /userProfile as /userprofile and you will be serving a 404 or a duplicate. All-lowercase kebab-case removes the failure mode entirely. If you are generating slugs from user-supplied titles, remember that anything outside the unreserved set still needs percent-encoding; iKit's URL Encoder / Decoder is the fastest way to check what a slug becomes on the wire.

kebab-case in CSS: the case-sensitivity trap

CSS is where most developers first meet kebab-case, and it is also where the convention has a genuinely surprising edge.

Standard property names are ASCII case-insensitive

background-color, BACKGROUND-COLOR, and Background-Color are the same declaration. CSS folds ASCII case for property names, which is why browsers serialise everything back to lowercase when you read a rule out of the CSSOM. This is forgiving, and it trains an expectation that turns out to be wrong one line later.

Can you use uppercase in a CSS custom property

No — or rather, you can, and it will not do what you expect. The CSS Custom Properties for Cascading Variables Module Level 1 specification states that custom property names are not ASCII case-insensitive: two custom properties are the same only if their names are identical. --my-color and --My-color are two separate properties that will never resolve to each other.

:root {
  --brand-primary: #4f46e5;
  --Brand-Primary: #ef4444;
}

.button {
  /* resolves to #4f46e5 — the second
     declaration is a different property */
  background: var(--brand-primary);
}

The spec goes further: because custom property names may deliberately carry mixed case, they are not exposed on a style declaration in camel-cased form. There is no element.style.myColor. You reach them only by their literal name:

const el = document.querySelector('.button');
el.style.setProperty('--brand-primary', '#6366f1');
getComputedStyle(el).getPropertyValue('--brand-primary');

Practical rule: pick lowercase kebab-case for every design token and never deviate. A single stray capital in a token name produces a bug with no error message. If you are generating token names from a palette, iKit's Color Palette Generator and Color Picker both emit lowercase names by default.

data-* attributes and the dataset bridge

HTML applies the same logic. Per the WHATWG HTML Standard, a custom data attribute must have at least one character after data- and must contain no ASCII uppercase letters — and the parser lowercases attribute names in HTML documents anyway. The dataset API then translates in both directions: each hyphen followed by a lowercase letter becomes a capital, so data-user-id surfaces as dataset.userId, and writing dataset.userId produces data-user-id. Assigning a property name that itself contains a hyphen throws a SyntaxError.

The takeaway is that kebab-case is the storage format and camelCase is the access format. That split appears again and again, and it is the reason a case converter is a daily tool rather than a novelty.

Why are CLI flags written with dashes

Long options are the third pillar, and here the convention is older than both CSS and the web.

The GNU long-option convention

The GNU Coding Standards' table of long options is effectively the canonical list, and it is kebab-case from top to bottom: --dry-run, --ignore-case, --no-builtin-rules, --ignore-matching-lines, --print-directory. There is not a single camelCase entry in it.

The reason is the shell. An argument is a bare word typed by a human under no autocomplete pressure, and shells vary in how they treat case in completion. A capital letter costs a Shift keystroke and buys nothing. The hyphen, meanwhile, is already the option sigil — -x short, --extract long — so extending it into the word separator is free.

Are hyphens allowed in npm package names

Yes, and effectively they are the only word separator you get. npm's package.json documentation states that new packages must not have uppercase letters in the name and must not contain characters that are unsafe in a URL. That leaves lowercase letters, digits, dots, underscores, and hyphens — and since the package name becomes a path segment on the registry, the URL argument from earlier applies again. eslint-config-airbnb is idiomatic; eslintConfigAirbnb would be rejected.

The pattern behind all three

Context Word separator Why
URL path - Crawler word boundary; case-sensitive paths
Hostname - LDH rule (RFC 1035) forbids _
CSS property - Declarative; - is not an operator
CLI long flag - Shell-typed; extends the -- sigil
npm package - Becomes a URL path segment

Where kebab-case is illegal

Knowing where you cannot use it saves more time than knowing where you can.

The banned list

  • JavaScript / Python / Go / Rust identifiers- is subtraction. Not negotiable.
  • JSON keys — technically legal, since any string is a valid key, but obj.user-id will not parse; you are forced into obj["user-id"] forever.
  • Environment variables — POSIX-portable names are limited to letters, digits, and underscore, so CONSTANT_CASE is the rule.
  • SQL identifiers — a bare user-id parses as a subtraction; you would have to quote it on every reference.
  • File names — legal everywhere, and a good default, but leading hyphens confuse CLI tools that read them as flags.

Three names for one field, and why that is fine

The interesting boundary is the API payload. A backend using snake_case columns, a frontend using camelCase variables, and a CSS layer using kebab-case tokens means the same concept carries three names in one request cycle. That is normal and fine, as long as the translation happens at exactly one layer. If you are moving tabular data across that boundary, iKit's CSV ↔ JSON Converter keeps header names intact so you can see exactly which convention arrived.

How to convert camelCase to kebab-case

The naive one-liner is the source of most bugs here.

The acronym boundary problem

// ❌ XMLHttpRequest → -x-m-l-http-request
s.replace(/([A-Z])/g, '-$1').toLowerCase();

An acronym run is a single word to a human and a sequence of capitals to a regex. You need to split between the acronym and the word that follows it, not inside it.

A two-pass regex that holds up

const toKebab = (s) =>
  s
    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
    .replace(/([a-z\d])([A-Z])/g, '$1-$2')
    .replace(/[\s_]+/g, '-')
    .toLowerCase();

toKebab('XMLHttpRequest');  // xml-http-request
toKebab('getUserID');       // get-user-id
toKebab('parseJSON2CSV');   // parse-json2-csv

The first pass handles XMLHttpXML-Http. The second handles userIduser-Id. Note the third case: JSON2CSV still needs a human decision, because no rule can tell you whether the 2 is a version number or the word "to". If you are auditing a pattern like this across a codebase, iKit's Regex Tester will show you every match before you run the replace.

When to just paste it

For a one-off rename, or for a list of forty token names pulled out of a design file, maintaining that regex is not worth it. Paste the identifiers into iKit's Case Converter and read the kebab-case column — it runs in the tab, so nothing leaves the machine, which matters more than it sounds when the identifiers are internal API field names.

References

Related on iKit

Related posts