This article was originally published on Rails Designer
I recently launched Rails Designers. The underlying platform, Forge, will soon be available. I wanted to highlight some modern CSS techniques I used in Forge as CSS has changed massively and added so much good stuff the last years. I wanted to quickly go over some of the techniques used. Make sure to also check out the article on modern CSS features.
Most of what is covered here works in any app using CSS, but the Propshaft bits are Rails-specific. By default new Rails apps add this in your <head>
:
<%%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
This results in something like this:
<link rel="stylesheet" href="/assets/animations-098cfc69.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/application-4431eacd.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components-734638f8.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components.partialsfx-b6376766.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/buttons-6da1df21.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/cards-7a8a1bb1.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/channel-ecaacfc7.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/chrome-f0d95aad.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/content-125f6836.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/dialog-e6c35362.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/components/footer-7647b14e.css" data-turbo-track="reload" />
<!-- etc. -->
But I'd like to load the one “application.css” instead:
<%%= stylesheet_link_tag :application, "data-turbo-track": "reload" %>
Located at: app/assets/stylesheets/application.css
. Results in:
<link rel="stylesheet" href="/assets/application-4431eacd.css" data-turbo-track="reload" />
This means you can write separate Stylesheets for other parts of your app (e.g. public pages and the admin).
<%%= stylesheet_link_tag :pages, "data-turbo-track": "reload" %>
CSS Layers
CSS Layers are part of modern CSS and now widely available in browsers. They let you control exactly how styles cascade, making your CSS more predictable.
Example from Forge:
@layer reset, variables, defaults, components, utilities;
@import url("./reset.css") layer(reset);
@import url("./variables.css") layer(variables);
@import url("./defaults.css") layer(defaults);
@import url("./components.css") layer(components);
@import url("./components.partialsfx.css") layer(components);
@import url("./utilities.css") layer(utilities);
CSS layers solve the cascade problem by explicitly defining which styles take precedence, regardless of loading order or specificity.
For example, when you define:
@layer reset, components, utilities;
You're telling the browser that utilities always win over components, which always win over reset styles.
Without layers, if you have:
/* components.css */
.btn { padding: .5rem 1rem; }
/* utilities.css */
.p-0 { padding: 0; }
And use <button class="btn p-0">
, the winning style depends on which file loads last or has higher specificity.
With layers, it's predictable:
@layer components {
.btn { padding: .5rem 1rem; }
}
@layer utilities {
.p-0 { padding: 0; }
}
Now .p-0
always wins because the utilities layer has higher priority than components, even if the components styles are loaded later or have higher specificity. This makes CSS much more maintainable.
Now let's look at each CSS file from the Forge app:
Reset
This is essentially just modern normalize.
Variables
:root {
color-scheme: light dark;
--color-value: 40;
--primary-color: light-dark(oklch(.7 0.18 var(--color-value)), oklch(.65 0.18 var(--color-value)));
--base-color: oklch(0 0 var(--color-value));
// etc.
Defaults
Top-level elements/selectors:
body {
tab-size: 4;
line-height: 1.5;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-font-variant-ligatures: common-ligatures;
font-feature-settings: "liga","clig off";
font-variant-ligatures: common-ligatures;
}
a:any-link {
text-decoration-line: none;
color: inherit;
&:not([class]) {
text-decoration-line: underline;
color: var(--primary-color);
&:hover {
text-decoration-line: none;
}
}
}
// etc.
Components
Nothing more than an index for all components. Each is scoped to its own layer:
@import url("./components/buttons.css") layer(components.buttons);
@import url("./components/chrome.css") layer(components.chrome);
@import url("./components/content.css") layer(components.content);
@import url("./components/dialog.css") layer(components.dialog);
@import url("./components/form.css") layer(components.form);
// etc.
Utilities
Also an index for all utilities. These are small one-off utility classes that can be used in multiple places. You might recognize the approach from Tailwind CSS.
@import url("./utilities/colors.css") layer(utilities.colors);
@import url("./utilities/grid.css") layer(utilities.grid);
@import url("./utilities/spacing.css") layer(utilities.spacing);
@import url("./utilities/visibility.css") layer(utilities.visibility);
Use CSS Custom properties (variables)
The previously defined variables can be used to cleanly build reusable components. Example:
.btn {
display: var(--btn-display, inline flex);
gap: var(--btn-gap, .4em);
align-items: var(--btn-align-items, center);
width: var(--btn-width, auto); height: var(--btn-height, auto);
padding: var(--btn-padding-y, .375rem) var(--btn-padding-x, 1rem);
font-size: var(--btn-font-size, .875rem);
line-height: var(--btn-line-height, 1.5);
font-weight: var(--btn-font-weight, 500);
text-align: var(--btn-text-align, left);
color: var(--btn-color, --base-100, #000);
background: var(--btn-background, none);
border: var(--btn-border-width, 1px) solid var(--btn-border-color, transparent);
border-radius: var(--btn-border-radius, .5rem);
box-shadow: 0 0 0 1px var(--btn-box-shadow-color, transparent);
cursor: var(--btn-cursor, pointer);
transition: all 200ms ease-in-out;
&:hover {
color: var(--btn-hover-color, --base-100, #000);
background: var(--btn-hover-background, none);
box-shadow: 0 0 0 1px var(--btn-hover-box-shadow-color, transparent);
}
}
.btn-primary {
--btn-color: var(--paper-color);
--btn-background:
linear-gradient(var(--primary-color), var(--primary-color)) padding-box,
linear-gradient(oklch(from var(--primary-color) l c h / 5%) 0%, oklch(from var(--primary-color) calc(l - .06) c h) 100%) border-box;
--btn-hover-color: var(--paper-color);
--btn-hover-background:
linear-gradient(var(--primary-color), var(--primary-color)) padding-box,
linear-gradient(oklch(from var(--primary-color) l c h / 5%) 0%, oklch(from var(--primary-color) 1 c h) 100%) border-box;
&:active {
transform: scale(.95);
}
}
Heavily relying on CSS custom properties allows you to tweak button classes in a nested component:
.modifications {
// …
.btn {
--btn-padding-x: 0;
--btn-color: var(--base-50);
--btn-hover-color: var(--base-70);
}
}
CSS nesting is now a standard feature in modern CSS, letting you write cleaner, more organized styles without preprocessors like Sass. The &
refers to the parent selector, just like in Sass.
And there you have it. Some modern CSS organization that definitely helped me keep the CSS for Forge in check. I also have another something in the works that definitely helped me write vanilla CSS (there is a hint in this article 🔍😅).
Top comments (0)