In these two articles, I will be comparing TailwindCSS with pure CSS + BEM. The goal is to understand which is the better solution for good application architecture. This is not a matter of preference, as this choice will significantly impact the later stages of development and must be very well justified. I'll start with comparing performance. Tailwind allows for a substantial reduction in the final CSS size, thereby speeding up page display time. However, this only works if Tailwind classes are written directly in HTML code, not as @apply in CSS. Tailwind reduces CSS size but increases HTML size. Let's calculate the difference, taking into account HTML. We'll compare pure Tailwind with pure CSS + BEM.
Calculations
For each Tailwind class - one property. For each HTML element - one and a half BEM classes (if considering modifiers).
lt - average length of a Tailwind class
lc - average length of a BEM class
lp - average length of a CSS property
Pu - number of unique properties
P - average number of properties per element
C - average number of BEM classes per element
E - number of elements
Eu - number of unique elements
CSS structure for Tailwind:
.lt * Pu {
lp * Pu;
}
CSS structure for BEM:
.lc * Eu * C {
lp * Eu * P;
}
HTML structure for Tailwind:
<div class="lt * E * P">
HTML structure for BEM:
<div class="lc * E * C">
Calculating total size:
Tailwind: lt * Pu + lp * Pu + lt * E * P
BEM: lc * Eu * C + lp * Eu * P + lc * E * C
Function for browser experiments:
function calc({lt, lc, lp, Pu, P, C, E, Eu}) {
const Tailwind = lt * Pu + lp * Pu + lt * E * P
const BEM = lc * Eu * C + lp * Eu * P + lc * E * C
return {
Tailwind,
BEM,
diff: Tailwind / BEM
}
}
For the analysis, I took the main page of GitHub after login. Here are some tools for analysis:
// Counting elements on the site:
console.log(Array.from(document.querySelectorAll('[class]')).length)
// Counting unique (by class) elements on the site:
console.log(Array.from(document.querySelectorAll('[class]'))
.reduce((a, o) => {
a.add(Array.from(o.classList.values()).sort().join(' '))
return a
}, new Set()).size)
Page: https://github.com/
3164 elements
502 unique elements
12101 all CSS properties
3956 unique CSS properties
404088 total length of all CSS properties
163733 total length of unique CSS properties
33 average length of all CSS properties
41 average length of unique CSS properties
24 properties per element
calc({
lt: 10, // average length of a Tailwind class
lc: 35, // average length of a BEM class
lp: 35, // average length of a CSS property
Pu: 3956, // number of unique properties
P: 24, // average number of properties per element
C: 1.5, // average number of BEM classes per element
E: 3164, // number of elements
Eu: 502, // number of unique elements
})
// Tailwind: 937380,
// BEM: 614145,
// Tailwind / BEM: 1.5263170749578682
Tailwind size is 1.5 times larger
Optimization
For BEM, we could optimize CSS and for each property list all classes with that property in the selector. Then the total length of all properties should decrease:
.lc * Eu * C * P {
lp * Eu;
}
The formula for BEM will be as follows:
lc * Eu * C * P + lp * Eu + lc * E * C
Let's consider the same GitHub, but with class name obfuscation and optimization:
function calc({lt, lc, lp, Pu, P, C, E, Eu}) {
const Tailwind = lt * Pu + lp * Pu + lt * E * P
const BEM = lc * Eu * C * P + lp * Eu + lc * E * C
return {
Tailwind,
BEM,
diff: Tailwind / BEM
}
}
calc({
lt: 5, // average length of a Tailwind class (with obfuscation)
lc: 5, // average length of a BEM class (with obfuscation)
lp: 35, // average length of a CSS property
Pu: 3956, // number of unique properties
P: 24, // average number of properties per element
C: 1.5, // average number of BEM classes per element
E: 3164, // number of elements
Eu: 502, // number of unique elements
});
// Tailwind: 537920
// BEM: 131660
// Tailwind / BEM: 4.085675224061978
With optimization and obfuscation, Tailwind's size is 4 times larger than BEM's.
But, unfortunately, such optimization is not possible. This example illustrates why:
<style>
.a {
color: red;
}
.b {
color: blue;
}
.c {
color: red;
}
</style>
<div class="a b"></div> <!-- Blue -->
<div class="b c"></div> <!-- Red -->
=========================
<style>
.a, .c {
color: red;
}
.b {
color: blue;
}
</style>
<div class="a b"></div> <!-- Blue -->
<div class="b c"></div> <!-- Blue -->
=========================
<style>
.b {
color: blue;
}
.a, .c {
color: red;
}
</style>
<div class="a b"></div> <!-- Red -->
<div class="b c"></div> <!-- Red -->
But class name obfuscation is possible. Let's consider the case without impossible optimization, but with obfuscation.
calc({
lt: 5, // average length of a Tailwind class (with obfuscation)
lc: 5, // average length of a BEM class (with obfuscation)
lp: 35, // average length of a CSS property
Pu: 3956, // number of unique properties
P: 24, // average number of properties per element
C: 1.5, // average number of BEM classes per element
E: 3164, // number of elements
Eu: 502, // number of unique elements
});
// Tailwind: 537920,
// BEM: 449175,
// Tailwind / BEM: 1.1975733288807258
Moreover, the size of a Tailwind class significantly impacts the total HTML/CSS size more than the size of a BEM class. This is understandable since the total number of elements on a page is 7 times more than the unique ones.
Is the size of CSS/HTML important, and to what extent?
How big can CSS actually be? With BEM, it's not hard to get 3 megabytes. But gzip compresses 3MB to just 60kB. Of course, after downloading, the browser still needs to unpack the CSS and then analyze it, which takes some time. According to my tests, it's 0.5-1 second on desktop. On mobile devices, this time should be significantly longer. Fast loading and quick analysis of CSS/HTML are more important than images or even scripts. Scripts are given a small head start because the user does not interact with the page immediately. Without HTML/CSS, the page won't even display correctly, and image loading won't start.
Script for analyzing a specific site
Run this script in the console for any site, and you will know the difference in sizes between Tailwind and BEM.
Preferably run it with browser protection disabled so that the script takes into account all styles loaded from CDNs:
chrome.exe --disable-web-security --user-data-dir="C:/Temp/ChromeDevSession"
The algorithm does not consider cascading styles, i.e., all that contain the symbols " >+~". It's very difficult to account for them, so it's better to just choose well-coded sites with little cascading. The algorithm also assumes that for BEM, each unique element has its unique style sheet. Therefore, in reality, the CSS size for BEM will be less than the algorithm estimates.
The calculations do not consider the current method of class naming. Only the quality of design and its coding affects the result. The Utility First approach forces more quality design and coding, i.e., adhering to certain predefined style sets. Therefore, probably sites coded with Tailwind show better results in terms of HTML/CSS size.
The script takes about a minute to run due to the element.matches(rule.selectorText) function. I don't know how to optimize this.
function walkRules(rules, callback, mediaRule) {
Array.from(rules).forEach(rule => {
if (rule instanceof CSSStyleRule) {
callback(rule, mediaRule)
} else if (rule instanceof CSSMediaRule) {
walkRules(rule.cssRules, callback, rule)
}
})
}
function getRules() {
const rules = []
Array.from(document.styleSheets).forEach(sheet => {
// ignore cross-origin stylesheets
try {
sheet.cssRules
} catch (err) {
console.warn(`Stylesheet ${sheet.href} is not accessible`)
return
}
walkRules(sheet.cssRules, (rule, mediaRule) => {
if (
!/\.[\w-]/.test(rule.selectorText) || // only class selectors
/[ >+~]/.test(rule.selectorText) // exclude cascading selectors
) {
return
}
const style = {}
for (let i = 0; i < rule.style.length; i++) {
const key = rule.style[i]
style[key] = rule.style.getPropertyValue(key).trim().toLowerCase()
}
rules.push({ rule, mediaRule, style })
})
})
return rules
}
function getElements(rules) {
const allElements = Array.from(document.querySelectorAll('[class]'))
const filteredElements = []
const time0 = Date.now()
console.log('getElements START')
let logTime = Date.now()
const nonVisualTags = ['SCRIPT', 'STYLE', 'LINK', 'META', 'TITLE', 'NOSCRIPT']
allElements.forEach((element, i) => {
if (nonVisualTags.includes(element.tagName)) {
return
}
const elementRules = rules.filter(({ rule }) => {
return element.matches(rule.selectorText)
})
if (elementRules.length === 0) {
return
}
filteredElements.push({ element, rules: elementRules })
if (Date.now() - logTime > 1000) {
console.log(
`getElements: ${((i / allElements.length) * 100).toFixed(2)}% ${
(Date.now() - time0) / 1000
}s`,
)
logTime = Date.now()
}
})
console.log('getElements END', performance.now() - time0)
return filteredElements
}
function calcStat() {
const rules = getRules()
const elements = getElements(rules)
const uniqueElements = Array.from(
elements
.reduce((a, e) => {
const key = e.rules.map(({ rule }) => rule.selectorText).join(' ')
if (!a.has(key)) {
a.set(key, e)
}
return a
}, new Map())
.values(),
)
const stat = {
total: {
html: {
elementsCount: 0,
},
css: {
selectorCount: 0,
mediaCount: 0,
mediaSize: 0,
styleSize: 0,
propCount: 0,
},
},
tailwind: {
html: {
classCount: 0,
},
css: {
selectorCount: 0,
styleSize: 0,
propCount: 0,
},
},
bem: {
html: {
elementsCount: 0,
},
css: {
selectorCount: 0,
mediaCount: 0,
mediaSize: 0,
styleSize: 0,
propCount: 0,
},
},
}
const uniqueProps = uniqueElements.reduce((a, e) => {
e.rules.forEach(({ style }) => {
Object.keys(style).forEach(key => {
a.add(key + ':' + style[key] + ';')
})
})
return a
}, new Set())
// Total
stat.total.html.elementsCount = elements.length
rules.forEach(({ rule, mediaRule, style }) => {
Object.keys(style).forEach(key => {
stat.total.css.propCount++
const prop = key + ':' + style[key] + ';'
stat.total.css.styleSize += prop.length
})
stat.total.css.selectorCount++
if (mediaRule) {
stat.total.css.mediaCount++
stat.total.css.mediaSize += mediaRule.media.mediaText.trim().length
}
})
// Tailwind
stat.tailwind.css.selectorCount = uniqueProps.size
stat.tailwind.css.propCount = uniqueProps.size
uniqueProps.forEach(prop => {
stat.tailwind.css.styleSize += prop.length
})
elements.forEach(({ element, rules }) => {
const elemUniqueProps = new Set()
rules.forEach(({ rule, mediaRule, style }) => {
Object.keys(style).forEach(key => {
elemUniqueProps.add(
key +
':' +
style[key] +
';' +
(mediaRule?.media?.mediaText?.trim() || ''),
)
})
})
if (!elemUniqueProps.size) {
debugger
}
stat.tailwind.html.classCount += elemUniqueProps.size
})
// BEM
stat.bem.html.elementsCount = elements.length
stat.bem.css.selectorCount = uniqueElements.length
uniqueElements.forEach(({ element, rules }) => {
const uniqueMedia = new Set()
rules.forEach(({ rule, mediaRule, style }) => {
const mediaText = mediaRule?.media?.mediaText?.trim() || ''
if (mediaText) {
uniqueMedia.add(mediaText)
}
Object.keys(style).forEach(key => {
const prop = key + ':' + style[key] + ';'
stat.bem.css.styleSize += prop.length
stat.bem.css.propCount++
})
})
stat.bem.css.mediaCount += uniqueMedia.size
uniqueMedia.forEach(media => {
stat.bem.css.mediaSize += media.length
})
})
return stat
}
var stat = calcStat()
console.log(JSON.stringify(stat, null, 2))
function tailwindVsBem({
stat,
bemClassSize,
bemClassesPerElement,
tailwindClassSize,
}) {
const bemCssSize =
stat.bem.css.styleSize +
stat.bem.css.mediaSize +
stat.bem.css.selectorCount * bemClassSize +
stat.bem.css.mediaCount * bemClassSize
const bemHtmlSize =
stat.bem.html.elementsCount * bemClassSize * bemClassesPerElement
const tailwindCssSize =
stat.tailwind.css.styleSize +
stat.tailwind.css.selectorCount * tailwindClassSize
const tailwindHtmlSize = stat.tailwind.html.classCount * tailwindClassSize
const bemSize = bemCssSize + bemHtmlSize
const tailwindSize = tailwindCssSize + tailwindHtmlSize
const diff = tailwindSize / bemSize
console.log(`
${document.location.href}
Tailwind = ${tailwindCssSize} (CSS) + ${tailwindHtmlSize} (HTML) = ${tailwindSize}
BEM = ${bemCssSize} (CSS) + ${bemHtmlSize} (HTML) = ${bemSize}
Tailwind / BEM = ${diff}
`)
}
tailwindVsBem({
stat,
bemClassSize: 35,
bemClassesPerElement: 1.5,
tailwindClassSize: 10,
})
Results
Pages with pure CSS:
https://www.youtube.com
Tailwind = 38936 (CSS) + 347670 (HTML) = 386606
BEM = 129424 (CSS) + 202230 (HTML) = 331654
Tailwind / BEM = 1.1656907499984923
https://dzen.ru/
Tailwind = 19449 (CSS) + 165240 (HTML) = 184689
BEM = 80186 (CSS) + 75232.5 (HTML) = 155418.5
Tailwind / BEM = 1.1883334352088073
https://habr.com/ru/articles/774524/
Tailwind = 1173 (CSS) + 370 (HTML) = 1543
BEM = 946 (CSS) + 157.5 (HTML) = 1103.5
Tailwind / BEM = 1.3982782057091074
Pages with Tailwind or another Utility First approach:
https://github.com
(Микс чистого CSS и Tailwind)
Tailwind = 25103 (CSS) + 482850 (HTML) = 507953
BEM = 155295 (CSS) + 155190 (HTML) = 310485
Tailwind / BEM = 1.6359985184469459
https://stackoverflow.com/questions/588004/is-floating-point-math-broken/588014
Tailwind = 25317 (CSS) + 449170 (HTML) = 474487
BEM = 132373 (CSS) + 140437.5 (HTML) = 272810.5
Tailwind / BEM = 1.7392549040451155
https://www.facebook.com/random_user
Tailwind = 42286 (CSS) + 400300 (HTML) = 442586
BEM = 378248 (CSS) + 170415 (HTML) = 548663
Tailwind / BEM = 0.8066627419745819
https://tailwindcss.com
Tailwind = 22325 (CSS) + 159330 (HTML) = 181655
BEM = 120739 (CSS) + 121800 (HTML) = 242539
Tailwind / BEM = 0.7489723302231805
https://www.shopify.com
Tailwind = 25053 (CSS) + 102080 (HTML) = 127133
BEM = 179634 (CSS) + 78750 (HTML) = 258384
Tailwind / BEM = 0.4920312403244783
https://www.netflix.com/tudum/top10/
Tailwind = 13288 (CSS) + 80360 (HTML) = 93648
BEM = 48737 (CSS) + 60375 (HTML) = 109112
Tailwind / BEM = 0.8582740670137107
https://io.google/2022/
Tailwind = 11124 (CSS) + 27390 (HTML) = 38514
BEM = 43079 (CSS) + 21682.5 (HTML) = 64761.5
Tailwind / BEM = 0.5947051874956572
https://dotnet.microsoft.com/en-us/
Tailwind = 15199 (CSS) + 27520 (HTML) = 42719
BEM = 49361 (CSS) + 13860 (HTML) = 63221
Tailwind / BEM = 0.6757090207367805
Conclusions
Tailwind reduces HTML/CSS size only if the entire web page (both design and coding) is designed using the Utility First approach. Of course, this must be agreed upon by everyone: designer, coder, and client. With very good design and coding, you can achieve a size twice smaller than with BEM coding.
If there is an arbitrary layout and you need to code in Pixel Perfect, then Tailwind is more likely to increase the size by 1.5 times. Also, Tailwind won't help if the page has a lot of repetitive elements (like the StackOverflow site, for example).
P.S.
Tailwind lies about phenomenal performance improvements. (In this article, it only considers the size of the files for download). It writes that Netflix has only 10KB of CSS, but doesn't mention the increased size of HTML. The total length of all "class" attributes equals 87231. This is a bit more than what I calculated here. In fact, Tailwind only saved 15% of the total size of uncompressed HTML+CSS.
Here's a script for calculating the total size of classes in HTML:
Array.from(document.querySelectorAll('[class]'))
.reduce((a, o) => {
a += o.getAttribute('class').length
return a
}, 0)
I would be happy to hear any criticism, remarks, your personal experience, ...
Top comments (0)