If you ever tried to implement a generic button yourself you will quickly get overwhelmed by the options and things to consider in order to have a button that will cover your needs while being customizable and generic enough for custom usages. It gets even harder when you have to integrate it with other components as well.
Few things to consider
Whenever you are developing a button for your project or to share with the community in a third-party package, there are a few things to consider.
…from the user perspective
- Consistency: Buttons should not look one way in a view and different in another. This also applies to button text casing;
- Short non-wrapping labels: Nobody has time to read long labeled buttons. Keep it simple
- Clear labels: Don’t label it “yes” and “no” after asking the user if they want to delete something. Say “delete” and “keep”, for example. Labels should tell the user what the button will do.
- Different button types: Have different types of buttons for a different type of action. Cannot use the same-looking button for different types of action.
- Nothing else should look like your buttons: It's easy to find tags, tabs, or links that look like buttons. Don’t confuse the user.
- Icon buttons should be clear: If the icon can't do the work of telling what the button is for, it's broken. Know when to use them.
- Big enough to click: If the user needs to zoom the page in order to click a button or try multiple times, the button is broken.
- Mind the color: There should be enough contrast between the background and foreground color. Also, take into consideration color-blind users so don’t let the meaning of the button rely solely on the color.
- Accessible: Users mostly don’t care about how fancy the button looks, if they can’t detect it with a screen reader or keyboard, for example, it's not a button.
- Fewer buttons: Don’t confuse the user by showing many buttons in the view. What's the action they need to take? Avoid distracting buttons.
…from the Developer perspective
- Easy to customize/extend: If you have a UI library and you provide a button, that button should be pretty easy to customize, extend or wrap in case it does not cover all the needs of the developer.
- Easy to use: There shouldn't be too many confusing setups and dependencies to have a button displayed. The options should be almost guessable or at least well documented.
- Accessibility built-in: No developer wants to fight a button to make it accessible. Accessibility should be one of the most important selling points when it comes to UI components.
Let's build a button…
Now that we have a list of requirements we can work with, let’s try to build a button kit with different and essential button types. Here is what we want to support:
- Different types: Generic, submit, upload, link, and download buttons;
- Different sizes: small, medium, and large;
- Different variants: text, outline(ghost), and fill buttons;
- Different colors: primary, secondary, CTA, success, error, warning, and custom;
- Other: some easy, on the button extra options user can set to change how the button looks like the
radius,padding, andwidth.
Base setup
For this, I want a setup that will allow this button to be used with any framework (React, Angular, Vue, etc). I also want to use Typescript for type support since most projects nowadays use typescript.
I’ll use Vite to set up a vanilla typescript project and use CWCO that uses typescript and simplifies how to build web components that can be used anywhere.
Introducing CWCO — The Only Web Components Solution You Need *A context-full web component library meant to simplify the way you work with native web component APIs in the browser…*javascript.plainenglish.io
npm init vite@latest // follow prompt
npm install cwco
First, I’ll create the enums I need for the many button configurations we will need:
export enum ButtonVariantEnum {
fill= 'fill',
outline = 'outline',
text = 'text',
}
export enum ButtonSizeEnum {
small= 'small',
medium = 'medium',
large = 'large',
}
export enum ButtonColorEnum {
primary = 'primary',
secondary = 'secondary',
cta = 'cta',
brand = 'brand',
error = 'error',
warning = 'warning',
success = 'success',
}
export enum ButtonTypeEnum {
button = 'button',
submit = 'submit',
}
Now we are ready to create the component.
Base Button
I’ll create a BaseButton class extending WebComponent from cwco to create an abstract component and proceed to define the basic attributes I want the component to react to when they change.
import {WebComponent} from 'cwco';
export class BaseButton extends WebComponent {
static observedAttributes = [
'disabled',
'type',
'variant',
'padding',
'radius',
'color',
'label',
'aria-label',
'width',
'size',
];
}
With that, I can go ahead and define a few default values for some of these attributes as well. CWCO by default will create a camel-cased property for each attribute that you can read the attribute value from as well as change.
I’ll also set the role for the component to be of a button and also set the hover and active properties ill update to indicate in which state the button is which will come in handy later.
// attributes
type: ButtonTypeEnum = ButtonTypeEnum.button;
width = 'initial';
size: ButtonSizeEnum = ButtonSizeEnum.medium;
target = '_self';
padding = '10px 20px';
radius = '3px';
color: ButtonColorEnum = ButtonColorEnum.primary;
variant: ButtonVariantEnum = ButtonVariantEnum.fill;
// data
role = 'button';
hover = false;
active = false;
Now we can set the body of this button which is simply an HTML button using a few of these attributes in the template.
get template() {
return `<button
class="btn btn-{variant} btn-{size}"
type="{type}"
attr.disabled="disabled"
attr.aria-label="ariaLabel"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false"
>
<slot>{label}</slot>
</button>
`;
}
The curly brace is a CWCO syntax to data bind in HTML and [attr](https://github.com/beforesemicolon/cwco/blob/master/docs/directives.md#attr) is a directive to conditionally set attributes on the element. As for the inline event handlers, CWCO will change them into event listeners and remove the attribute before setting the element in the DOM.
This is the code we have so far…
export class BaseButton extends WebComponent {
static observedAttributes = [
'disabled',
'type',
'variant',
'padding',
'radius',
'color',
'label',
'aria-label',
'width',
'size',
];
static delegatesFocus = true;
type: ButtonTypeEnum = ButtonTypeEnum.button;
role = 'button';
width = 'initial';
size: ButtonSizeEnum = ButtonSizeEnum.medium;
target = '_self';
padding = '10px 20px';
radius = '3px';
color: ButtonColorEnum = ButtonColorEnum.primary;
variant: ButtonVariantEnum = ButtonVariantEnum.fill;
hover = false;
active = false;
get template() {
return `<button
class="btn btn-{variant} btn-{size}"
type="{type}"
attr.disabled="disabled"
attr.aria-label="ariaLabel"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false"
>
<slot>{label}</slot>
</button>
`;
}
}
To use this ill simply extend BaseButton to create a CWButton which I'll then register as a web component.
export class CWButton extends BaseButton {}
CWButton.register();
The BaseButton is just the base which I’ll keep on using to create the components I need by changing the parts of it I need to be different. This is a powerful way to approach component building which is 100% customizable.
// index.html
<cw-button>My Button</cw-button>

Basic Style
To style this button I’ll first make sure the actual component tag has dimensions and set a box-sizing border-box for it and everything inside.
get stylesheet() {
return `
<style>
:host {
display: inline-block;
cursor: pointer;
width: [this.width];
box-sizing: border-box;
}
:host * {
box-sizing: border-box;
}
</style>`;
}
Note that I data bind width for the host element. The square bracket notation is a special CWCO syntax to data bind in CSS. This means the CSS will update when this property changes.
Now I can handle when the disabled attribute is set on the host and set a generic style for the button itself by removing appearance, and set default basic other styles. I also data-binding radius and padding directly in CSS and added additional basic and generic styles for the button.
:host([disabled]) {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
}
:host .btn {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
outline: none;
background: var(--primary-color, #111);
color: var(--text-color, #fff);
border: 1px solid var(--primary-color, #111);
padding: [this.padding];
border-radius: [this.radius];
cursor: pointer;
display: block;
width: 100%;
transition: background 0.1s ease, color 0.1s ease;
font-family: var(--font-family, sans-serif), sans-serif;
font-size: 1rem;
white-space: nowrap;
}
Now I want to use a CSS variable so I can easily override the colors from outside of the component and have a few default colors in case those are not provided. For that, I’ll create a constant containing the default colors for different button states.
const defaultColors = {
primary: {default: '#111', hover: '#363636', active: '#000000'},
secondary: {default: '#0c496f', hover: '#1471ac', active: '#0c3e5f'},
cta: {default: '#3f2884', hover: '#573aaf', active: '#2a1a58'},
brand: {default: '#ec591e', hover: '#f37c4c', active: '#8b3613'},
error: {default: '#a70c0c', hover: '#c92727', active: '#780f0f'},
warning: {default: '#ce900b', hover: '#dba633', active: '#b1821a'},
success: {default: '#40ba66', hover: '#3acd69', active: '#34ad5a'},
}
With that, I want to parse the color attribute value and use the color constant to generate CSS variables for color. The color attribute value can be of the following format: [default state color], [hover state color], [active state color], where only the default color is required.
get CSSColorVar() {
const color = {
default: `var(--${this.color}-color, ${defaultColors[this.color]?.default})`,
hover: `var(--${this.color}-hover-color, ${defaultColors[this.color]?.hover})`,
active: `var(--${this.color}-active-color, ${defaultColors[this.color]?.active})`,
};
const [def, hover, active] = this.color
.match(
/#[a-f0-9]{3,8}|(rgb|hsl|hwb)a?\s*\([^)]*\)|[a-z]+/gi
) || [];
const {style} = new Option();
style.color = def;
if (style.color) {
color.default = style.color;
}
style.color = hover;
if (style.color) {
color.hover = style.color;
}
style.color = active;
if (style.color) {
color.active = style.color;
}
return color;
}
With that, we can now use this getter directly in the CSS for background, color, and border properties.
background: [this.CSSColorVar.default];
color: var(--text-color, #fff);
border: 1px solid [this.CSSColorVar.default];
On the page, I can simply provide the color as an attribute, or declare a root level color variable that the button can use inside.
// index.html
<style>
:root {
--primary-color: #222;
--primary-hover-color: #555;
--primary-active-color: #000;
}
</style>
<cw-button color="blue, darkblue, lightblue">My Button</cw-button>
<cw-button >My Button</cw-button>
Now we need to target the different states, variants, and sizes of the button using the CSS color variable getter created earlier.
// small button
:host .btn.btn-small {
font-size: 0.8rem;
}
// large button
:host .btn.btn-large {
font-size: 1.2rem;
}
:host .btn:hover {
background: [this.CSSColorVar.hover];
border: 1px solid [this.CSSColorVar.hover];
}
:host .btn:active {
background: [this.CSSColorVar.active];
border: 1px solid [this.CSSColorVar.active];
}
// outline button
:host .btn.btn-outline {
background: none;
color: [this.CSSColorVar.default];
border: 1px solid [this.CSSColorVar.default];
font-weight: 700;
}
:host .btn.btn-outline:hover {
background: var(--text-hover-color, #f4f4f4);
border: 1px solid [this.CSSColorVar.hover];
color: [this.CSSColorVar.hover];
}
:host .btn.btn-outline:active {
background: none;
border: 1px solid [this.CSSColorVar.active];
color: [this.CSSColorVar.active];
}
// text button
:host .btn.btn-text {
background: none;
border: 1px solid transparent;
color: [this.CSSColorVar.default];
font-weight: 700;
}
:host .btn.btn-text:hover {
background: var(--text-hover-color, #f4f4f4);
color: [this.CSSColorVar.hover];
}
:host .btn.btn-text:active {
border: 1px solid transparent;
color: [this.CSSColorVar.active];
}
This is actually enough to create and modify the button enough to handle basic buttons by playing with the color , padding , size , width, and radius attributes of the button
<style>
:root {
--primary-color: #222;
--primary-hover-color: #555;
--primary-active-color: #000;
}
</style>
<cw-button
color="#a00, darkorange, brown"
size="large"
padding="15px 25px"
radius="10px"
>My Button</cw-button>
<cw-button
color="blue, darkblue, #888"
radius="3px 35px"
padding="10px 25px"
>My Button</cw-button>
<cw-button
width="250px"
>My Button</cw-button>
<cw-button
radius="30px"
padding="8px 20px"
size="small"
color="cta"
>My Button</cw-button>

Icon Button
To create our icon button we can simply extend the BaseButton and change a few things. For this, I’ll simply remove the support for the label with the empty slot first when I override the template.
We can also set a few defaults like making it round with 50% radius, 5px padding, and text variant so it is blank.
export class IconBaseButton extends BaseButton {
radius = '50%';
padding = '5px';
variant = ButtonVariantEnum.text;
get template() {
return `<button
class="btn btn-{variant} btn-{size}"
type="{type}"
attr.disabled="disabled"
attr.aria-label="ariaLabel"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false"
>
<slot></slot>
</button>
`;
}
}
CWCO web components are super easy to customize. Just simply extend a component and override the parts you need to be different.
Now I want to target the slotted icon provided in the button body with some predefined style and already make the assumption that you can provide an SVG as well.
get stylesheet() {
return `
${super.stylesheet}
<style>
:host .btn {
display: flex;
justify-content: center;
align-items: center;
line-height: 100%;
}
:host .btn ::slotted(svg),
:host .btn slot svg {
width: 30px;
height: 30px;
}
:host .btn.btn-small ::slotted(svg),
:host .btn.btn-small slot svg {
width: 20px;
height: 20px;
}
:host .btn.btn-large ::slotted(svg),
:host .btn.btn-large slot svg {
width: 40px;
height: 40px;
}
:host .btn slot svg * {
fill: [this.CSSColorVar.default];
stroke: [this.CSSColorVar.default];
}
:host .btn:hover slot svg * {
fill: [this.CSSColorVar.hover];
stroke: [this.CSSColorVar.hover];
}
:host .btn:active slot svg * {
fill: [this.CSSColorVar.active];
stroke: [this.CSSColorVar.active];
}
:host .btn.btn-fill slot svg *,
:host .btn.btn-fill:hover slot svg *,
:host .btn.btn-fill:active slot svg * {
fill: var(--text-color, #fff);
stroke: var(--text-color, #fff);
}
</style>
`;
}
With the BaseIconButton we can create the icon button and register it.
export class CWIconButton extends IconBaseButton {}
CWIconButton.register()
If we use this icon button with SVG along with the power of CWCO directive and data binding, we can change the SVG color as the active or hover the property gets updated.
<cw-icon-button color="#eee" variant="fill">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26">
<g fill="#050"
attr.fill="{active ? '#090' : hover ? '#0a0' : '#050'}, true">
<path d="..."/>
</g>
</svg>
</cw-icon-button>

Link Button
For the link button, I will extend IconBaseButton so I can support having an icon inside and make so the variant is text by default, with extra observed attributes and an anchor tag as the template body with the same btn class name.
export class CWLinkButton extends IconBaseButton {
static observedAttributes = [
...BaseButton.observedAttributes,
'href', 'target'
];
variant = ButtonVariantEnum.text;
get template() {
return `
<a href="{href}"
class="btn btn-text btn-{size}"
style="text-decoration: none"
target="{target}"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false">
<slot>{label}</slot>
</a>
`;
}
}
CWLinkButton.register()
Now to make it look like a link ill add the default link icon inside a slot which can be overwritten as well on the page.
get template() {
return `
<a href="{href}"
class="btn btn-text btn-{size}"
style="text-decoration: none"
target="{target}"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false">
<slot>{label}</slot>
<slot name="link-icon">
<svg class="link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" version="1.1">
<g stroke="none" stroke-width="0" fill="#000000">
<path fill-rule="nonzero" d="..."/>
</g>
</svg>
</slot>
</a>
`;
}
Now when we try this on the page it looks like a link button.
// index.html
<cw-link-button>Info</cw-link-button>

Download Button
Same thing for the download button. Extend IconButton , extend observed attributes with new ones and replace the template body with an anchor tag with btn class and usage of the extra attributes.
export class CWDownloadButton extends IconBaseButton {
static observedAttributes = [
...BaseButton.observedAttributes,
'href', 'filename'
];
get template() {
return `
<a href="{href}"
class="btn btn-{variant} btn-{size}"
download="{filename}"
style="text-decoration: none"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false">
<slot>{label}</slot>
<slot name="download-icon">
<svg xmlns="..." viewBox="0 0 25 24">
<g transform="translate(-49.5 -161.489)">
<path d="..."/>
<path d="..."/>
</g>
</svg>
</slot>
</a>
`
}
}
CWDownloadButton.register();
Just like that, we have a new button type that we can play with.
// index.html
<cw-download-button>Get Files</cw-download-button>

Upload Button
To finalize with the final button I will also extend IconButton with extended observed attributes for the upload button but for this ill use a file input wrapped in a label tag with the btn class as well to be the button.
export class CWUploadButton extends IconBaseButton {
static observedAttributes = [
...BaseButton.observedAttributes,
'accept', 'multiple', 'name', 'uploading'
];
get template() {
return `
<label class="btn btn-{variant} btn-{size}"
onmouseover="this.hover = true"
onmouseleave="this.hover = false"
onmousedown="this.active = true"
onmouseup="this.active = false">
<slot>{label}{uploading ? '...' : ''}</slot>
<slot name="upload-icon">
<svg xmlns="..." viewBox="0 0 24 22.054">
<g>
<path d="..."/>
<path d="..."/>
</g>
</svg>
</slot>
<input type="file"
accept="{accept || '*'}"
name="{name}"
attr.multiple="multiple"
style="display: none"
onchange="this.onChange($event)">
</label>`
}
onChange(event: Event) {
this.dispatchEvent(
new CustomEvent('files', {
detail: (event.target as HTMLInputElement)?.files
})
)
}
}
It will dispatch the event of a file for when it gets the files by calling the onChange method. With that, it is straight-up to use.
// index.html
<cw-upload-button
variant="outline"
color="cta">Upload Files</cw-upload-button>

Now the entire button kit is complete!
Try it out!
You can follow this guide to try these components in your projects no matter the type of UI library you use.
Check the source code of everything here
To learn how to style buttons in a super nice way check the video below.

YouTube Channel: Before Semicolon Website: beforesemicolon.com

By Elson Correia