A colleague asked recently whether we could push all of our button styles into a single component. Could we get rid of the CSS classes and have one place that knows what a button looks like — regardless of whether it's a link, a form submit, a control, whatever?
I said no. And I think that's the right answer for most projects — it's certainly the conventional one. Custom CSS classes are the right tool precisely because "button" is a design concept, not a single HTML element. It can be an <a>, a <button>, an <input type="submit">, a <form> wrapping a <button>. Slapping a shared class on any of them and getting consistent visual treatment simple and it's easy and it works. Buttons are, I'd argue, the only time a custom CSS class actually makes sense — a rare case where a visual concept genuinely cuts across element types.
But the question stuck with me. Working on my side project Muddle, I decided to see how far it could actually be pushed.
Starting point
The original CSS looked like this:
@layer components {
.button {
@apply cursor-pointer inline-block font-bold text-lg px-8 py-4 rounded-xl transition-colors text-center;
}
.button-grape {
@apply button bg-grape-600 hover:bg-grape-700 text-white;
}
.button-cherry {
@apply button bg-cherry-600 hover:bg-cherry-700 text-white;
}
}
Usage was straightforward: slap a class on anything and it looks like a button.
<%= link_to "Sign up", new_account_path, class: "button-grape" %>
<%= button_to "Delete", recipe, method: :delete, class: "button-cherry" %>
<%= form.submit "Save", class: "button-grape" %>
Most projects stop here, and that's completely reasonable — @layer components is the documented, supported approach. I just wanted to see what life would be like without any custom CSS at all, so: remove it, build a component, and see how much the component ends up having to handle.
Step one: links
The simplest use case: a good old link (<a> tag) that happened to look like a button. Navigation, CTAs, all the usual suspects.
The component starts simple enough.
class ButtonComponent < ViewComponent::Base
BASE_CLASSES = "cursor-pointer block font-bold text-lg px-6 sm:px-8 py-4 rounded-xl transition-colors text-center"
COLOURS = {
grape: "bg-grape-600 hover:bg-grape-700 text-white",
cherry: "bg-cherry-600 hover:bg-cherry-700 text-white"
}.freeze
def initialize(label:, url:, colour: :grape)
...
end
def classes
class_list(BASE_CLASSES, COLOURS.fetch(colour))
end
end
<%%= link_to label, url, class: classes %>
<%= render ButtonComponent.new(label: "Get started", url: new_account_path) %>
<%= render ButtonComponent.new(label: "Delete account", url: account_path, colour: :cherry) %>
This project has some icon-only links, so they need an aria label for screen readers. Let's make sure that gets included and passed along.
def initialize(url:, icon: nil, label: nil, colour: :grape, aria: nil)
...
end
<%= link_to url, class: classes, aria: aria do %>
<% if icon %>
<%= icon %>
<% end %>
<% if label %>
<%= label %>
<% end %>
<% end %>
<%= render ButtonComponent.new(
icon: :edit,
url: edit_recipe_path(recipe),
aria: { label: "Rename recipe" }
) %>
Layouts need a way to control layout and positioning so we need an argument for additional classes to the button.
def initialize(url:, icon: nil, label: nil, colour: :grape, extra_class: nil, aria: nil)
...
end
def classes
class_list(BASE_CLASSES, COLOURS.fetch(colour), extra_class)
end
<%= render ButtonComponent.new(label: "Copy join link", url: join_link, extra_class: "mt-4") %>
That's a few parameters, but for a widely used and fairly stable component it's manageable.
Step two: plain buttons
A standalone <button> with no URL and no HTML-driven action. Not particularly common normally, but there are a couple on this project — mainly to drive Stimulus actions that don't navigate.
Update the component to make URL optional and, when it isn't given, render a button instead of a link. Plus support passing data for Stimulus.
def initialize(label: nil, icon: nil, url: nil, colour: :grape, extra_class: nil, data: nil, aria: nil)
...
end
def button_method
if url
:link_to
else
:button_tag
end
end
def button_args
if url
[url, {class: classes, aria: aria, data: data}]
else
[{class: classes, aria: aria, data: data}]
end
end
<%= public_send(button_method, *button_args) do %>
...
<% end %>
<%= render ButtonComponent.new(
icon: :copy,
label: "Copy join link",
data: { action: "clipboard#copy" }
) %>
Buttons can be disabled, though.
def initialize(label: nil, icon: nil, url: nil, colour: :grape, extra_class: nil, data: nil, aria: nil, disabled: false)
...
end
def button_args
options = {
class: classes,
aria: aria,
disabled: disabled
}
if url
[url, options]
else
[options]
end
end
<%= render ButtonComponent.new(label: "Selected", disabled: true) %>
A bit more complexity, and some slightly magical argument dispatch to make it work. Still manageable.
Step three: button_to
Now things get a little more interesting. button_to produces something with a little more structure — a <form> with a <button> in it, both of which might need styling, and it might contain hidden fields.
In practice it's just a link using a different HTTP method, so we handle it the same way: pass method: alongside url: and delegate to button_to instead of button_tag or link_to.
def initialize(label: nil, icon: nil, url: nil, method: nil, colour: :grape, extra_class: nil, data: nil, aria: nil, disabled: false)
...
end
def button_method
if url
if method
:button_to
else
:link_to
end
else
:button_tag
end
end
def button_args
options = {
class: classes,
aria: aria,
disabled: disabled,
method: method
}
if url
[url, options]
else
[options]
end
end
<%= render ButtonComponent.new(
icon: :trash,
label: "Delete recipe",
colour: :cherry,
url: recipe,
method: :delete
) %>
Sometimes you need to pass params: to the endpoint too.
def initialize(label: nil, icon: nil, url: nil, method: nil, params: nil, colour: :grape, extra_class: nil, data: nil, aria: nil, disabled: false)
...
end
def button_args
options = {
class: classes,
aria: aria,
disabled: disabled,
method: method,
params: params
}.compact
if url
[url, options]
else
[options]
end
end
<%= render ButtonComponent.new(
label: "Join account",
url: join_accounts_path,
method: :post,
params: { join_code: params[:join_code] }
) %>
You also sometimes need to apply classes to the <form> itself — flex and grid layouts often want to control the form element directly.
def initialize(label: nil, icon: nil, url: nil, method: nil, params: nil, colour: :grape, extra_class: nil, form_class: nil, data: nil, aria: nil, disabled: false)
...
end
def button_args
options = {
class: classes,
aria: aria,
disabled: disabled,
method: method,
params: params,
form: {
class: form_class
}
}.compact
if url
[url, options]
else
[options]
end
end
More parameters. But I've gone further than expected with the complexity still fairly contained.
Step four: form submits
This one was actually an issue.
form.submit is the idiomatic Rails way to add a submit button inside a form_with block. We could replace every form.submit call with render ButtonComponent.new(...) — a <button type="submit"> inside a form submits it natively, so we don't need to wire the form to the component. But asking every template author to remember to use the component instead of the form builder method is the sort of thing that people forget. Someone (me) will reach for form.submit six months from now and get an unstyled button with no obvious reason why.
Fortunately this project uses a custom form builder (a subclass of ActionView::Helpers::FormBuilder that centralises field markup and validation error display) — another way in which I'm not adding custom @utilities or @components to CSS, using a Ruby class to map the correct classes to labels and inputs. This is a clean seam to add our behaviour. Override submit to delegate to the component.
class MuddleFormBuilder < ActionView::Helpers::FormBuilder
def submit(value = nil, options = {})
@template.render(ButtonComponent.new(
label: value || submit_default_value,
extra_class: options[:class],
data: options[:data],
disabled: options[:disabled] || false
))
end
end
From a template author's perspective, nothing changes:
<%= form.submit "Save" %>
<%= form.submit "Save", class: "block mx-auto" %>
class: is reinterpreted as extra_class: — layout concerns only, styling is the component's job. submit_default_value preserves the Rails convention of deriving a label from the model when none is given. But seriously, who's writing <%= form.submit %>? Weird.
So — can we push the custom CSS into a ViewComponent?
Yes. It can be done. Every single button in this project uses this component, the custom @components have been fully removed and everything looks correct.
But at what cost?
It's not as bad as I thought, but it's still not great. After a couple more rounds to allow the icon and the label to be independently styled, the final initializer argument count is fourteen. It delegates to three different Rails helpers. Form submits require a non-obvious layer of indirection in the form builder that a new developer would have to go and find.
There is one genuine win, though. The component enforces how icons and labels sit together — every button, regardless of type, gets the same layout. No more gap-2 here and gap-3 there. That consistency is harder to get from a CSS class alone.
But I've swapped a few dozen lines of CSS for a ~100-line ViewComponent. class: "button-grape" on a link_to is genuinely easy to read and easy to apply — it doesn't matter if it's a <div> or a <span>, anything can be a button.
So would I give my colleague the same answer now?
...Yes. But at least now I know why.
Originally posted on tonyrowan.tech
Top comments (0)