An Easy Way To Create <Button>, <Input>, and <Select> Elements That Conform to A Systems Design
In this article, we describe in detail in easy way to create reusable React components (<button>, <input>, and <select> elements) that can…
An Easy Way To Create <Button>, <Input>, and <Select> Elements That Conform to A Systems Design
In this article, we describe in detail in easy way to create reusable React components (<button>, <input>, and <select> elements) that can conform to any System Design.
<Button>
import Spinner from "./Spinner";
const variants = {
primary: "buttonPrimaryColorGlass",
outline:
"desktop:hover:bg-buttonOutlineBgHover active:bg-buttonOutlineBgHover border border-buttonOutlineBorder [transition:background-color_300ms]",
danger: "buttonDangerColorGlass",
dangerOutline:
"text-textDanger desktop:hover:bg-buttonOutlineBgHover active:bg-buttonOutlineBgHover border border-buttonOutlineBorder [transition:background-color_300ms]",
input: "justify-start inputPrimaryColor font-normal",
// custom variants
keypad: "keypadColor",
} as const;
const sizes = {
xs: "h-11 desktop:h-8 px-3 desktop:px-2.5 textSm rounded-lg font-medium",
sm: "h-12 desktop:h-9 px-3 desktop:px-2.5 rounded-lg font-medium",
base: "h-13 desktop:h-10 px-4 desktop:px-3 rounded-lg font-medium",
login: "h-13 desktop:h-12 px-4 desktop:px-3.5 rounded-xl font-medium",
// custom sizes
icon: "flex-none size-9 desktop:size-8 rounded-lg",
pill: "h-11 desktop:h-8 px-4 desktop:px-3.5 textXs rounded-full gap-2 font-normal",
keypad: "w-20 h-20 desktop:w-12 desktop:h-12 textXl font-semibold rounded-full",
} as const;
type ButtonProps = {
label?: React.ReactNode;
variant?: keyof typeof variants;
size?: keyof typeof sizes;
isLoading?: boolean;
icon?: React.ReactNode;
iconRight?: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export default function Button({
label = "",
variant = "primary",
size = "base",
isLoading = false,
icon,
iconRight,
// destructured button props
className = "",
disabled,
type = "button",
...props
}: ButtonProps) {
return (
<button
{...props}
className={`flex items-center justify-center gap-1 disabled:cursor-default select-none ${variants[variant]} ${sizes[size]} ${className}`}
disabled={isLoading || disabled}
type={type}
>
{isLoading ? (
<Spinner buttonVariant={variant} />
) : (
<>
{icon}
{label}
{iconRight}
</>
)}
</button>
);
}
variantandsizeprops should adhere to the System Design. Typical button variants includeprimary,secondary,outline,ghost, anddanger. For sizes,baseis the standard size. For desktop, the Login Page text and button size are usually larger than in the app (for mobile, they are the same). Sizesxsandsmare used when the content is more compact.- We define variant and size as
constand usekeyof typeof <const>to defineButtonProps. This way, we can add new variants or sizes without re-editingButtonProps(to me, this is fairly convenient). - Width is not included in
className, so the default button collapses in width to the pre-defined x padding.className="w-full"should be used for<Button>’s that spans the page’s content (i.e., within modals). iconandiconRightallows for icons on the left (default) or right of the text.- When
isLoadingis true, all text/icons are replaced with a<Spinner>.buttonVariantis used to define the spinner color. disabled,type, andclassNameare destructured fromButtonHTMLAttributesto allow us to 1) define the disabled state withisLoading, 2) set defaulttypetobutton, and 3) add custom classes to the button.
<Input>
const variants = {
primary: "inputPrimaryColor",
danger: "inputDangerColor",
login: "inputLoginColor", // only used for login form
} as const;
const sizes = {
xs: "h-11 desktop:h-8 px-3 desktop:px-2.5 textSm rounded-lg", // used in DetailsModal.tsx
sm: "h-12 desktop:h-9 px-3 desktop:px-2.5 rounded-lg", // not used anywhere yet
base: "h-13 desktop:h-10 px-3.5 desktop:px-3 rounded-lg",
login: "h-13 desktop:h-12 px-3.5 desktop:px-3.5 rounded-xl", // login form uses 16px text for desktop
} as const;
type InputProps = {
inputSize?: keyof typeof sizes;
variant?: keyof typeof variants;
} & React.InputHTMLAttributes<HTMLInputElement>;
export default function Input({ inputSize = "base", variant = "primary", className = "", ...props }: InputProps) {
return <input className={`${variants[variant]} ${sizes[inputSize]} ${className}`} {...props} />;
}
- Like <Button>,
variantandinputSizeare used to conform to the UI Systems Design. They are also defined as aconstandkeyof typeof <const>is used to define theInputProps(so custom variants/sizes can be added without needing to re-edit theInputProps). inputSize is used instead because InputHTMLAttributes contain already has a prop namedsize. - In globals.css, inputPrimaryColor, inputDangerColor, etc. are defined. For example:
@utility inputPrimaryRing {
@apply ring-2 ring-transparent focus:ring-inputPrimaryRing;
}
.inputPrimaryColor {
@apply bg-inputPrimaryBg placeholder:text-inputPrimaryPlaceholder border border-inputPrimaryBorder desktop:hover:border-inputPrimaryBorderHover focus:border-inputPrimaryBorderHover inputPrimaryRing transition-all duration-300;
}
.inputLoginColor {
@apply bg-inputPrimaryBg placeholder:text-inputPrimaryPlaceholder border border-inputLoginBorder desktop:hover:border-inputPrimaryBorderHover focus:border-inputPrimaryBorderHover inputPrimaryRing transition-all duration-300;
}
.inputDangerColor {
@apply bg-inputPrimaryBg placeholder:text-inputPrimaryPlaceholder border border-textDanger desktop:hover:border-textDanger focus:border-textDanger inputDangerRing transition-all duration-300;
}
<Select>
import { FaChevronDown } from "react-icons/fa";
const variants = {
primary: "inputPrimaryColor",
outline: "selectOutlineColor",
} as const;
const sizes = {
xxs: "h-9 desktop:h-8 pl-3 desktop:pl-2.5 pr-6 desktop:pr-5 textXs rounded-lg",
xs: "h-11 desktop:h-8 pl-3 desktop:pl-2.5 pr-8 desktop:pr-6.5 textSm rounded-lg", // used in DetailsModal.tsx
sm: "h-12 desktop:h-9 pl-3 desktop:pl-2.5 pr-8 desktop:pr-6.5 rounded-lg",
base: "h-13 desktop:h-10 pl-3.5 desktop:pl-3 pr-9 desktop:pr-7.5 rounded-lg",
} as const;
const iconSizes = {
xxs: "right-3 desktop:right-2.5 text-xs desktop:text-[0.625rem]",
xs: "right-3 desktop:right-2.5 text-sm desktop:text-[0.625rem]", // used in DetailsModal.tsx
sm: "right-3 desktop:right-2.5 text-sm desktop:text-[0.625rem]",
base: "right-3.5 desktop:right-3 text-sm desktop:text-[0.625rem]",
} as const;
type SelectProps = {
children: React.ReactNode;
variant?: keyof typeof variants;
selectSize?: keyof typeof sizes;
} & React.SelectHTMLAttributes<HTMLSelectElement>;
export default function Select({ children, variant = "primary", selectSize = "base", className = "", ...props }: SelectProps) {
return (
<div className={`relative ${className}`}>
<select className={`appearance-none w-full ${variants[variant]} ${sizes[selectSize]}`} {...props}>
{children}
</select>
<FaChevronDown className={`absolute top-1/2 -translate-y-1/2 opacity-80 pointer-events-none ${iconSizes[selectSize]}`} />
</div>
);
}
- Like <Button> and <Input>,
variantandsizeare defined by the Systems Design.iconSizesdefines the position and size of the dropdown icon (down chevron).
Other Notes
Systems Design Colors
In globals.css, your @theme{…} and .dark{…} should define the System Design colors. For example:
@theme {
/* bg main */
--color-bgPrimary: #f7f3f9;
--color-card: #ffffff;
--color-modal: #f7f3f9;
/* borders */
--color-borderFaint: oklch(92.9% 0.013 255.508); /* slate-200 */
/* text */
--color-textPrimary: #142634;
--color-textSecondary: #647987;
--color-textTertiary: #8f9faa;
--color-link: #007cc5; /* 6% darker than buttonPrimaryBg */
--color-linkHover: #006dab; /* 12% darker than link */
--color-textDanger: #ff4774; /* 5% brighter than buttonDangerBg */
--color-textDangerHover: #df2553; /* 12% darker than textDanger */
/* buttons */
--color-buttonPrimaryText: #ffffff;
--color-buttonPrimaryBg: #0084d1; /* sky-600 */
--color-buttonPrimaryBgHover: #0074b8; /* 12% darker than buttonPrimaryBg */
--color-buttonOutlineBorder: #d6dee9; /* slate-250 */
--color-buttonOutlineBgHoverSubtle: #f6f9fc; /* color is between bgPrimary and buttonOutlineBgHover */
--color-buttonOutlineBgHover: #f7f3f9; /* same as bgPrimary */
--color-buttonDangerBg: #ff3264;
--color-buttonDangerBgHover: #e62a58; /* 8% darker than buttonDangerBg */
--color-buttonRing: rgb(0 132 209 / 70%);
/* input */
--color-inputPrimaryBg: #ffffff;
--color-inputPrimaryBorder: oklch(92.9% 0.013 255.508); /* slate-200, 4% darker than bgPrimary */
--color-inputPrimaryBorderHover: rgb(0 132 209 / 70%);
--color-inputPrimaryRing: rgb(0 132 209 / 40%);
--color-inputPrimaryPlaceholder: var(--color-textTertiary);
--color-inputLoginBorder: #adbcce; /* slate-350 */
--color-inputDangerRing: rgb(240 68 111 / 50%);
/* toggle */
--color-toggleOn: var(--color-buttonPrimaryBg);
--color-toggleOff: oklch(86.9% 0.022 252.894); /* slate-300 */
/* custom colors */
--color-closeButtonBgHover: #dbe1e9; /* slate-250 */
--color-loginButtonBg: #ffffff;
--color-loginButtonBgHover: #ffffff;
--color-loginButtonBorder: transparent; /* slate-200 */
--color-loginButtonBorderHover: transparent; /* slate-300 */
/* buttons glass */
--color-buttonPrimaryGlassBg: #0084d1;
--color-buttonPrimaryGlassBgHover: #0074b8;
--color-buttonPrimaryGlassBorder: transparent;
--color-buttonDangerGlassBg: #ff3264;
--color-buttonDangerGlassBgHover: #e62a58;
--color-buttonDangerGlassBorder: transparent;
/* breakpoints */
--breakpoint-xs: 480px;
--breakpoint-sm: 600px;
--breakpoint-md: 750px;
--breakpoint-lg: 980px;
--breakpoint-xl: 1250px;
--breakpoint-2xl: 1440px;
}
.dark {
/* bg main */
--color-bgPrimary: #0a0826; /* was 0b0f37*/
--color-card: oklch(70.7% 0.165 254.624 / 7%); /* blue-400/7 */
--color-modal: #141344; /* blue-400/14 overlaid on bgPrimary */
/* borders */
--color-borderFaint: oklch(70.7% 0.165 254.624 / 8%); /* blue-400/8 */
/* text */
--color-textPrimary: rgb(240 242 250);
--color-textSecondary: rgb(240 242 250 / 0.7);
--color-textTertiary: rgb(240 242 250 / 0.45);
--color-link: #2072f6; /* 6% brighter than buttonPrimaryBg */
--color-linkHover: #4085f5; /* 9% brighter than link */
--color-textDanger: #ff507a;
--color-textDangerHover: #ff7698; /* 11% brighter than textDanger */
/* buttons */
--color-buttonPrimaryText: #ffffff;
--color-buttonPrimaryBg: #0364ff;
--color-buttonPrimaryBgHover: #1c73ff; /* 8% brighter than buttonPrimaryBg */
--color-buttonOutlineBorder: #293b70;
--color-buttonOutlineBgHoverSubtle: oklch(70.7% 0.165 254.624 / 7%); /* blue-400/7 */
--color-buttonOutlineBgHover: oklch(70.7% 0.165 254.624 / 14%); /* blue-400/14 */
--color-buttonDangerBg: #ff3a6a;
--color-buttonDangerBgHover: #ff507a; /* 6% brighter than buttonDangerBg */
--color-buttonRing: #546bb0;
/* inputs */
--color-inputPrimaryBg: #0a0826; /* bgPrimary */
--color-inputPrimaryBorder: #131f40;
--color-inputPrimaryBorderHover: #314278;
--color-inputPrimaryRing: #1e2e60;
--color-inputLoginBorder: var(--color-inputPrimaryBorder);
--color-inputDangerRing: rgb(255 80 122 / 60%);
/* toggle */
--color-toggleOn: #0364ff;
--color-toggleOff: rgb(240 242 250 / 0.2);
/* custom colors */
--color-closeButtonBgHover: oklch(70.7% 0.165 254.624 / 14%);
--color-loginButtonBg: transparent;
--color-loginButtonBgHover: oklch(70.7% 0.165 254.624 / 7%);
--color-loginButtonBorder: rgb(255 255 255 / 21%);
--color-loginButtonBorderHover: rgb(255 255 255 / 21%);
/* buttons glass */
--color-buttonPrimaryGlassBg: rgba(3, 100, 255, 0.5);
--color-buttonPrimaryGlassBgHover: rgba(3, 100, 255, 0.65);
--color-buttonPrimaryGlassBorder: rgba(73, 138, 244, 0.5);
--color-buttonDangerGlassBg: rgba(255, 58, 106, 0.75);
--color-buttonDangerGlassBgHover: rgba(255, 58, 106, 0.85);
--color-buttonDangerGlassBorder: rgba(255, 112, 143, 0.75);
}
Supplemental Base Styles
I also added these base styles to globals.css:
button {
cursor: pointer;
}
@utility scrollbar-stable {
scrollbar-gutter: stable;
}
:focus-visible {
outline: 2px solid var(--color-buttonRing);
outline-offset: 2px;
}
/* Inputs/selects use ring/border instead of global outline */
:where(input, select, textarea):focus-visible {
outline: none;
}
/* --- add this to buttons within scroll containers where outline overflows and becomes clipped (e.g., item list or details list) ---*/
.innerOutline:focus-visible {
outline-offset: -2px;
}
- all buttons will have cursor-pointer
- all elements with
:focus-visble(except forinput,select, andtextarea; thus, mainly <a> and <button>) will have an outline with +2px offset. This is needed for users who use “tabs” for navigation. The offset is needed for better visibility, as most buttons have a solid color. input,select, andtextareawill use ring-2 (no offset) when focused. Having zero offset is more natural for these elements. These elements will have a highlighted border when focused, so a ring-2 with a softer color/glow (and transparency) will be used.innerOutlineclass is added to<Button>whenever a +2px outline will be cut off. For example, clickable items in a list or segmented buttons will need this class.
Summary
After some experimentation, I found the above to be the easiest way (least cognitive load) to create button, input, and select elements that conform to a Systems Design.
메타데이터
- post_id
- 732db49abfd0
- slug
- creating-button-input-and-select-elements-that-conform-to-a-systems-design-732db49abfd0
- url
- https://medium.com/@brianonchain/creating-button-input-and-select-elements-that-conform-to-a-systems-design-732db49abfd0
- canonical_url
- https://medium.com/@brianonchain/creating-button-input-and-select-elements-that-conform-to-a-systems-design-732db49abfd0
- author_url
- https://medium.com/@brianonchain
- status
- ok
- fetched_at
- 2026-06-17 17:19:58