Design system/Components
Buttons
One family, one shape: the pill. Three variants for three levels of importance, one red variant only for destructive actions, and three sizes for three contexts. Every button is a verb.
In codeapp/globals.css (.btn)
Variants
btn-dark is the primary button: the action the page was built for. btn-ghost is for secondary actions and sits on the page like a card. btn-accent is rare: for when one action needs to be seen more than anything else on the page, like "Upgrade."
btn-smbtnbtn-lgbtn-darkbtn-ghostbtn-accentbtn-dangerWhen to use it
- btn-dark: once per area, for the main action.
- btn-ghost: cancel, a secondary action, a link to another page.
- btn-accent: upgrade, or an action that must be seen; at most one per page.
- btn-danger: only for confirming a destructive action, like the red “Yes, delete” button in ConfirmDialog.
When not to
- Two btn-dark buttons side by side; make one of them ghost.
- A button for going somewhere else when a text link would do.
Destructive button
btn-danger has a strong red background (--red-strong) with white text and darkens 6 percent on hover. It's only for confirming something that can't be undone, and it almost always appears inside a ConfirmDialog or an inline confirmation next to "Cancel", not as the first button that starts the task.
bg var(--red-strong) · #fff · border var(--red-strong)Hover brightness(0.94)Do
className="btn btn-danger" for “Yes, delete”.
Don't
style={{ background: "var(--red)" }} on a .btn; every hand-made version is slightly different.
Do
The “Delete” row in the menu is red and opens a confirmation dialog.
Don't
A red button that deletes without confirmation.
Sizes
All sizes have a 999px radius. Icons are 14 to 15px and come before the label with a 6px gap.
36px · 13px40px on phones38px · 13.5px / 600Touch 48px · 15px42px · 13.5px.page-head-actions .btn52px · 15.5px / 600Sign-in, payment; touch 56px58px · 14.5pxMatches floating field heightStates
A button rises 1px under the pointer and shrinks to 97 percent when pressed. Disabled means half-faded and motionless. While in progress, the label gives way to a small spinner and the button is disabled so it can't be pressed twice.
Anatomy
- ContainerA pill with a fixed height; the width comes from the label, and the label never wraps.
- IconOptional, before the label; takes the button's text color.
- TagA verb: "Create action", "Save".
Accessibility
- A button with only an icon (icon-btn) gets aria-label and title.
- On touch screens, btn-sm and pills inside main grow to 42 to 44px.
- A <Link> shaped like a button gets the same .btn class but stays a link.
Code
<button className="btn btn-dark">
<Icon d={paths.plus} size={15} />
{tr("ساخت اقدام")}
</button>
<button className="btn btn-ghost btn-sm" onClick={onClose}>{tr("انصراف")}</button>
<button className="btn btn-dark" disabled={busy}>
{busy ? <span className="spinner" /> : tr("ذخیره")}
</button>Size at each breakpoint
Button height comes from three conditions: screen width, pointer type, and the container the button sits in. This table shows what the browser computes after every rule in globals.css.
38px · padding 0 16px · 13.5px / 600 · gap 6px48px · 15px@media (pointer: coarse)font-size 15px≤ 640px36px · padding 0 14px · 13pxIcon 14px40px · 13.5pxTouch inside main: min-height 44px52px · 15.5px / 600Touch 56px · 16px58px · 14.5px / 600Touch 62px42px · padding-inline 14px 18px · gap 7pxIcon 15px52px · padding-inline 26px · 15.5pxPhone full-widthmin-width 128px · padding-inline 20pxPhone: flex 1 · min-height 48pxmin-height 48px · radius 12px · 14.5pxStates, precisely
transition: all 0.15s easetranslateY(-1px)btn-lg without the liftbackground: var(--dark-hover)--muted · shadow 0 2px 6px rgba(24,20,14,.07)filter: brightness(0.94)scale(0.97) · 0.06sopacity 0.5 · cursor: not-allowedNo hover or press.spinner 15px · border 2px · 0.6s linearAlong with disabledoutline 2px solid var(--accent) · offset 2px.btn:focus-visible; both themes- Button hover isn't under @media (hover: hover); on touch, the 1px lift can stick after a tap until the next touch. Press (scale) lasts only while the finger is down.
- The default spinner is white (rgba(255,255,255,.3) with a #fff head), for dark buttons; on btn-ghost use .spinner-dark (20px, gray).
Contrast for each variant
12.94 : 1 · 14.62 : 1Light · dark; AAA16.28 : 1 · 14.17 : 1--text on --card; AAA5.19 : 1 · 5.19 : 1#fff on --accent-strong; AA5.14 : 1 · 5.54 : 1#fff on --red-strong; AA1.32 : 1 · 1.38 : 1Decorative border; the text and shadow define the buttonRight to left
- The icon comes before the label in the DOM, and with inline-flex it sits to the right of the label in right-to-left; no reordering needed.
- padding-inline: 14px 18px on the page header pill means less on the icon side; it's correct in both directions because it's logical.
- A “Next” arrow inside a button gets .rtl-flip or StepArrow.
Do
In progress: disabled plus a spinner.
Don't
Just a spinner; tapping again sends a second request.
Do
The primary action goes last in the dialog footer row.
Don't
btn-dark first and Cancel after it; on desktop the footer is aligned to the end of the row, so the primary action has to come last.
Do
<Link className="btn …"> to go to another page.
Don't
<button onClick={() => router.push()}>; middle-click and “Open in new tab” are lost.
Do
Set the size with a class: btn-sm, btn-lg.
Don't
Inline height or border-radius; it misses the touch and phone rules.
Related pages