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
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-idwill not parse; you are forced intoobj["user-id"]forever. - Environment variables — POSIX-portable names are limited to letters, digits, and underscore, so
CONSTANT_CASEis the rule. - SQL identifiers — a bare
user-idparses 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 XMLHttp → XML-Http. The second handles userId → user-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
- URL Structure Best Practices for Google Search — the hyphens-over-underscores recommendation and the note that Google's URL handling is case-sensitive.
- CSS Custom Properties for Cascading Variables Module Level 1 — custom property names are case-sensitive and are not exposed in camel-cased form.
- HTML Standard — the
data-*attribute name restrictions and thedatasethyphen-to-capital mapping rules. - GNU Coding Standards — Table of Long Options — used to confirm that GNU long options are uniformly kebab-case.
- RFC 1035 — Domain names: implementation and specification — the LDH label grammar that excludes the underscore from hostnames.
- package.json | npm Docs — the no-uppercase and URL-safe-characters rules for package names.
Related on iKit
- The three-way comparison: when to reach for camelCase, snake_case, or kebab-case — the decision table this article assumes, covering all five conventions rather than just the hyphenated one.
- Converting camelCase to snake_case without breaking your code — the same acronym-boundary regex problem, solved for the underscore side of the fence.
- Going the other way: snake_case to camelCase, acronyms intact — the API-boundary translation you need when the backend speaks snake_case.
- PascalCase and the acronym rules that trip up class names — why
HTTPServerandHttpServerare both defensible, and which style guides pick which.
Related posts
XMLHttpRequest to xml_http_request: The Acronym Rule (2026)
XMLHttpRequest becomes xml_http_request only if your converter knows where the acronym ends. Here is the boundary rule, the regex, and what style guides say.
How a Lorem Ipsum Generator Works: The Word Bank (2026)
Every lorem ipsum generator is a small word bank plus a few rules. Here is the actual algorithm: 182 Latin words, sentence windows, commas, punctuation.
Where Does Lorem Ipsum Come From? The Real Story (2026)
Where does Lorem Ipsum come from? Not an unknown printer in the 1500s. The paper trail runs from Cicero in 45 BC to a 1914 Loeb edition to Letraset in 1966.