From URL to Theme: How {css2r} Steals a Website’s Colours, and Where It Gives Up
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
You can read the original post in its original format on Rtask website by ThinkR here: From URL to Theme: How {css2r} Steals a Website’s Colours, and Where It Gives Up
You have thirty minutes before the demo, and your Shiny app still looks like Bootstrap 5 out of the box.
We have all been there. The analysis is solid, the model is good, the tables are clean. And then someone from the communication team walks past your screen and says, very politely, that “it doesn’t really look like us”. They are right. Your app is blue-ish, your company is orange. Somewhere there is a 60-page brand book you have never opened, and you have no intention of reading it before 2pm.
So here is the naive question we asked ourselves at ThinkR: the company website already carries the brand. The colours are in its CSS, right there, publicly served over HTTP. Why not just go and take them?
That is {css2r}, and the small Shiny app that wraps it, Shiny Copy. You give it a URL, it gives you a bslib::bs_theme() call. It is live at connect.thinkr.fr/css2r. Open it in another tab, paste your company’s address, and come back. This article will still be here.
It works. And the interesting part of this article is not that it works. It is everything the web refuses to give you along the way. Buckle up, we’re talking about CSS. 🎨
Thirty seconds: a URL in, a theme out
remotes::install_github("ThinkR-open/css2r")
library(css2r)
thinkr <- css2r$new(url = "https://thinkr.fr")
#> ✔ Internet ok
#> ✔ html page downloaded
#> ✔ CSS links extracted
#> ✔ CSS links filtered
#> ✔ CSS downloaded
#> ✔ Colors extracted successfully
#> ✔ Colors analyzed successfully.
#> ℹ No Google Fonts detected.
#> ✔ Shiny theme code generated.
Nine steps, a few seconds, and the object holds everything it found:
thinkr$top_colors
#> $white_black
#> Color Count
#> 32 #FFFFFF 1
#>
#> $top_colors
#> Color Count
#> 1 #38404C 133
#> 2 #0046C8 72
#> 3 #F05622 40
#> 4 #20B8D6 23
cat(thinkr$shiny_code)
#> fluidPage(
#> theme = bslib::bs_theme(
#> bg = "#FFFFFF",
#> fg = "#38404C",
#> primary = "#38404C",
#> secondary = "#0046C8"
#> ),
#> h1("Hello World primary", class = "text-center text-secondary"),
#> h1("Hello World secondary", class = "text-center text-primary")
#> )
Copy, paste into your app_ui.R, done.
And if you prefer clicking to typing, that is exactly what the hosted version does: swatches, a copy button, and a live Bootstrap mockup showing what your app would look like before you write a single line. Locally, css2r::run_app() launches the same thing.
Now look at that theme again. Look at it properly.
#F05622 is the ThinkR orange. The one on the logo, on the slides, on the mugs. It came third, with 40 occurrences. It is nowhere in the generated theme. What {css2r} picked as primary is #38404C, a dark slate grey: the body-text colour, which by construction appears everywhere.
We ran our own tool on our own website and it did not find our own brand colour. That deserves an explanation, and the explanation is the rest of this article.
Under the hood: nine steps and one regular expression
The css2r R6 class is deliberately small. The pipeline is linear and every step can be called by hand with on_initialize = FALSE:
check_internet(), via{curl}download_html(), via{rvest}extract_css_links(), every<link rel="stylesheet">filter_css_links(), keeping only the ones on the same domaindownload_css_files(), via{httr}extract_colors(), the heart of the machineanalyze_colors(), splitting neutrals from brand coloursdetect_google_fonts(), reading the Google Fonts URL parametersgenerate_shiny_code(), assembling thebs_theme()call
Step 6, the one that does all the real work, is five lines:
pattern <- "#[0-9A-Fa-f]{6}"
matches <- gregexpr(pattern = pattern, text = self$css_content, perl = TRUE)
colors_hex <- regmatches(x = self$css_content, m = matches) |>
unlist() |>
toupper()
table_colors_hex <- colors_hex |> table() |> as.data.frame(stringsAsFactors = FALSE)
Find every six-digit hex code, count how many times each one appears, sort. Then analyze_colors() puts #FFFFFF and #000000 aside as neutrals, keeps the top four, and generate_shiny_code() assigns background and foreground by relative luminance:
hex_luminance = function(hex) {
hex <- sub("^#", "", hex)
r <- strtoi(substr(hex, 1, 2), 16L) / 255
g <- strtoi(substr(hex, 3, 4), 16L) / 255
b <- strtoi(substr(hex, 5, 6), 16L) / 255
0.2126 * r + 0.7152 * g + 0.0722 * b
}
Lightest colour becomes bg, darkest becomes fg, most frequent becomes primary, second becomes secondary. That is the entire theory.
It is a heuristic, and a good one: it produces a usable theme on a large share of ordinary websites. But it rests on three assumptions about the web, and the modern web breaks all three.
Why this is harder than it looks
To make this concrete rather than theoretical, we pointed the extraction at a handful of real sites in August 2026 and counted what was actually reachable. The results are more instructive than any diagram.
| Site | CSS links | Same-domain | Inline <style> |
6-digit hex | 3-digit hex | rgb() |
var(--…) |
Verdict |
|---|---|---|---|---|---|---|---|---|
| thinkr.fr | 4 | 3 | 3 blocks (8.3 kB) | 309 | 234 | 70 | 37 | works, wrong primary |
| lemonde.fr | 2 | 2 | 0 | 805 | 140 | 82 | 707 | runs, all-grey theme |
| posit.co | 5 | 4 | 1 | 137 | 42 | 72 | 1 545 | works, misses the palette |
| tailwindcss.com | 2 | 2 | 0 | 973 | 46 | 7 | 8 371 | 300 unique colours, no signal |
| stripe.com | 5 | 0 | 0 | n/a | n/a | n/a | n/a | total failure |
Three distinct failure modes hide in that table.
1. The CSS you cannot reach
filter_css_links() keeps only stylesheets served from the same domain as the page:
keep(.p = ~ domain(.x) == self$domain)
That rule exists for a good reason. On thinkr.fr it correctly throws away a highlight.js theme hosted on cdnjs, a syntax-highlighting palette that has nothing to do with our brand and would have polluted the count with random purples.
On stripe.com, the same rule throws away everything. All five stylesheets live on b.stripecdn.com. Same company, different hostname, and {css2r} returns empty-handed:
External stylesheets only. This website loads 5 stylesheet(s), but all of them are served from another domain, so ShinyCopy cannot tell brand CSS from third-party CSS.
Any organisation that serves its assets from a dedicated CDN, which is to say most large organisations, is invisible to us.
There is a subtlety here that cost us a bug, and it is worth naming because it is the kind of mistake that hides behind a plausible error message. urltools::domain() does not return the registrable domain despite its name: it returns the full host, subdomain included. So for a site that redirects its apex to www, the page we downloaded and the stylesheets it references ended up on www.example.com while the filter was still comparing against the example.com the user had typed. Every stylesheet was discarded, and the app confidently blamed inline CSS. The filter now compares eTLD+1, and resolves everything against the URL actually reached after redirects, which keeps the useful behaviour (cdnjs still goes) without the self-inflicted wound.
Then there is the CSS that never appears in a <link> at all. thinkr.fr ships three inline <style> blocks, 8.3 kB of critical CSS injected straight into the HTML for faster first paint. It is a completely standard performance practice, and extract_css_links() walks right past it because it only looks at link[rel='stylesheet']. Shiny Copy at least has the decency to tell you:
No stylesheet found. No
<link rel="stylesheet">tag was found on thinkr.fr. This usually means the site ships its CSS inline in the HTML, or renders the page with JavaScript. ShinyCopy supports neither yet.
And finally, the pages that have no meaningful HTML at load time because everything is rendered client-side by JavaScript. {rvest} fetches the document as the server sent it, not as the browser eventually paints it. For a single-page app, there is often nothing to read.
2. The colours you cannot parse
This one is my favourite, because the tool fails silently and partially.
#[0-9A-Fa-f]{6} matches six-digit hex codes. Nothing else. On thinkr.fr that means we see 309 colour declarations and quietly ignore 234 three-digit hex codes (#fff, #333) and 70 rgb() / rgba() calls. Roughly forty percent of the colour information on our own site is dropped on the floor, and nothing in the output tells you so. Add hsl(), which appears 60 times on lemonde.fr, plus CSS named colours like rebeccapurple, and the blind spot widens further.
The fix is not conceptually hard: normalise every colour notation to hex before counting. It is just work that has not been done yet.
The deeper problem is CSS custom properties. Look at that var(--…) column again. On tailwindcss.com there are 8 371 references to CSS variables and on posit.co 1 545. A modern design system declares its brand colour exactly once:
:root { --brand-primary: #F05622; }
…and then uses it a thousand times through var(--brand-primary). Our frequency counter sees that colour once. Meanwhile a grey declared inline in fifteen legacy components scores fifteen. We are not measuring importance, we are measuring how badly the stylesheet has aged.
3. Frequency is not identity
Which brings us back to the ThinkR orange, and to the assumption underneath the whole approach: the most frequent colour is the most important colour.
It simply is not true. The most frequent colour in a stylesheet is almost always the interface plumbing: text grey, border grey, disabled grey. Brand colours are used sparingly, and that is the entire point of a brand colour. A logo orange that appears on three buttons carries far more identity than a #38404C used on every paragraph of the site.

lemonde.fr is the purest illustration, and a worse case than our own site. Every step of the pipeline succeeds, no error is raised, and this comes out:
$top_colors
Color Count
1 #2A303C 77
2 #E8EAEE 62
3 #F5F6F8 40
4 #464F5F 25
bslib::bs_theme(bg = "#FFFFFF", fg = "#2A303C",
primary = "#2A303C", secondary = "#E8EAEE")
All four are greys. Le Monde’s blue, #015AAD, sits at rank 10 with 17 occurrences: ranks one through nine are neutrals, every single one. And secondary lands on #E8EAEE, a contrast ratio of 1.09 against the white background. A .btn-secondary would be, quite literally, invisible.
On tailwindcss.com the top two are white (214) and black (129), and since analyze_colors() deliberately sets those aside, we fall through to #030712, which is… very slightly less black. Below that, 300 unique colours with no meaningful frequency gap between them, because a design system publishes its entire palette in one file.
There is one more consequence worth naming. On thinkr.fr, #38404C is both the most frequent colour and the darkest one, so it becomes fg and primary at the same time. A .text-primary element is then exactly the same colour as body text: technically valid, visually pointless. {css2r} performs no contrast check and no de-duplication between roles.
So we asked a human
Here is the pragmatic conclusion we reached while building Shiny Copy: if the machine cannot reliably tell a brand colour from interface plumbing, do not let it decide alone.
The app extracts the top four candidates, generates its best guess, and then displays clickable swatches for the primary and secondary roles. One click re-themes the whole preview live:
observeEvent(input$preview_primary, {
rv$preview_primary <- input$preview_primary
session$setCurrentTheme(
bslib::bs_theme_update(
rv$site$shiny_theme,
primary = rv$preview_primary,
secondary = rv$preview_secondary
)
)
})
The frequency ranking stops being an answer and becomes a shortlist. You still get your ThinkR orange. You just have to click on it, which takes about a second, and you are the one who knows what your brand looks like.
Except that a shortlist is only ever as good as the ranking that produced it, and lemonde.fr shows exactly where that breaks: the four swatches on offer are four greys, and the brand blue at rank 10 is not among them. There is nothing left for the human to rescue. Deferring to the user fixes a ranking that is merely imperfect; it does nothing for one that is wrong from the first row.
What it would take to go further
The limits above are not mysteries, they are a roadmap. In rough order of payoff:
- Rank by chroma, not just by count. The cheapest fix with the largest visible effect. Discard near-neutrals before choosing
primary, using nothing more than the spread between the RGB channels:chroma <- function(hex) { v <- c(strtoi(substr(hex, 2, 3), 16L), strtoi(substr(hex, 4, 5), 16L), strtoi(substr(hex, 6, 7), 16L)) max(v) - min(v) }#38404Cscores 18,#F05622scores 206. The separation is not subtle. Applied to our two examples,primary/secondarywould go from#38404C/#0046C8to#0046C8/#F05622on thinkr.fr, and from two greys to#015AADon lemonde.fr. It also happens to solve theprimary == fgcollision for free, since a dark neutral can no longer win. -
Normalise colour notations. A
parse_css_color()helper turning#fff,rgb(),rgba(),hsl()and named colours into a canonical hex would immediately recover the ~40% we currently drop. -
Read inline
<style>blocks. One extrahtml_nodes("style") |> html_text()and a lot of critical CSS stops being invisible. -
Handle asset CDNs. Comparing registrable domains fixed the apex/www case, but
b.stripecdn.comis genuinely a different domain fromstripe.comand no amount of string comparison will bridge that. It needs a different rule: an allowlist, or scoring stylesheets rather than excluding them outright. -
Resolve CSS custom properties. Parse
--var: #hexdeclarations, then countvar(--var)usages against them. This is the single biggest fidelity gain available, and the one that would make the tool work on modern design systems at all. -
Weight by context, not by count. A colour on a
.btnbackground is not worth the same as a gradient stop in a hero image. That means a real CSS parser instead of a regular expression. A much bigger project, and the point where “small useful tool” becomes “product”. -
Check contrast before emitting. A quick WCAG ratio on the
bg/fgpair, and a rule preventingprimaryfrom collapsing ontofg. -
Headless rendering for JavaScript-heavy pages, via
{chromote}. Powerful, heavy, and a whole other class of deployment problem.
We shipped without any of these on purpose. A tool that solves the common case in five seconds and tells you honestly when it cannot is more useful than one that never leaves the lab. Every one of those error modals in Shiny Copy exists so that you find out immediately, rather than shipping a grey app and wondering why.
Try it, break it, tell us
The app is live and free, no installation required:
And if you would rather have it in your own scripts:
remotes::install_github("ThinkR-open/css2r")
css2r::run_app()
Point it at your company website. If it works, you have saved yourself an afternoon in the brand book. If it fails, we would genuinely like to know which of the three failure modes above caught you. The source is on GitHub, issues and pull requests are very welcome, and the colour-notation parser is a lovely first contribution. 😉
And if what you actually need is a Shiny application that is properly branded, properly tested and properly deployed, get in touch. That part we do by hand.
Figures measured in August 2026; websites change, so your numbers may differ.
This post is better presented on its original ThinkR website here: From URL to Theme: How {css2r} Steals a Website’s Colours, and Where It Gives Up
R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.