Files
sharper.ly/apps/web/app/components/ui/Button/Button.css
T

267 lines
11 KiB
CSS
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
BUTTON - figma frame 14:487 (Variant x Size x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens are referenced here. The `--sh-btn-*` custom
properties are component-local plumbing: each variant block binds them to a
tier-2 token once, and the state rules below read them, so the whole 5x3x3
matrix is expressed as five small blocks instead of forty-five selectors.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`w-full`, `mt-lg`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
HEIGHTS. Figma draws the stroke *inside* the frame, so a Secondary button is
the same height as a Primary one. Borders are therefore subtracted from the
padding, which lands every size on its designed box:
sm 6.5*2 + 20 + 1.5*2 = 36 md 10.5*2 + 24 + 1.5*2 = 48
lg 10.5*2 + 28 + 1.5*2 = 52
The 20/24/28 line-heights come from the type role the component puts on the
element (body-sm-bold / body-md-bold / body-lg-bold).
DEVIATIONS FROM FIGMA, deliberate:
* Focus-visible is not drawn in Figma at all. WCAG 2.4.7 requires it, so the
semantic focus ring is applied here; filled variants switch to the inverse
ring colour because a green ring on a green fill is invisible.
* "Ghost / Large / Default" is the one cell in the matrix with no padding
while its own Hover and Disabled cells have it - an authoring slip that
would make the button jump on hover. Padding is kept on all ghost states.
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
Figma's 40% opacity, so the page never shows through a disabled control.
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Base - shared by every variant and size.
A one-cell grid stacks the label and the loading spinner without either
transforms or absolute offsets, so it stays correct under dir="rtl".
------------------------------------------------------------------ */
.sh-btn {
display: inline-grid;
grid-template-areas: "sh-btn-stack";
place-items: center;
border-style: solid;
border-width: var(--control-border-width);
border-radius: var(--radius-control);
background-color: var(--sh-btn-bg);
color: var(--sh-btn-fg);
border-color: var(--sh-btn-border);
text-align: center;
text-decoration: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-entrance);
}
/* The Claude Design drafts lift a button 3px under the pointer and set it
back down on press. Pointer devices only - a touch "hover" would leave the
control raised after the tap - and never while disabled or busy, where the
control must not look live. app.css collapses the transition under
prefers-reduced-motion, so the lift becomes an instant, harmless offset.
The drafts also put a coloured glow behind the lifted button; that is
deliberately not shipped (client decision). */
@media (hover: hover) {
.sh-btn:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: translateY(-3px);
}
}
.sh-btn:active:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: none;
2026-09-11 02:46:56 +02:00
}
.sh-btn--block {
display: grid;
inline-size: 100%;
}
.sh-btn__content {
grid-area: sh-btn-stack;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--control-gap);
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
/* The 24px box the icon is drawn inside in Figma. Kept on the slot rather
than on the icon so a consumer's own glyph inherits the same geometry. */
.sh-btn__icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
}
/* ---------------------------------------------------------------------
Sizes - Figma: sm 16/8 (space.md/space.xs), md + lg 24/12 (space.lg/sm)
------------------------------------------------------------------ */
.sh-btn[data-size="sm"] {
padding-inline: calc(var(--control-padding-inline-sm) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-sm) - var(--control-border-width));
}
.sh-btn[data-size="md"] {
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
}
.sh-btn[data-size="lg"] {
padding-inline: calc(var(--control-padding-inline-lg) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-lg) - var(--control-border-width));
}
/* ---------------------------------------------------------------------
Variants - the only place a token name appears twice.
------------------------------------------------------------------ */
.sh-btn[data-variant="primary"] {
--sh-btn-bg: var(--interactive-primary-bg);
--sh-btn-bg-hover: var(--interactive-primary-bg-hover);
--sh-btn-bg-active: var(--interactive-primary-bg-active);
--sh-btn-bg-disabled: var(--interactive-primary-bg-disabled);
--sh-btn-fg: var(--interactive-primary-fg);
--sh-btn-fg-hover: var(--interactive-primary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-primary-fg-disabled);
--sh-btn-border: var(--interactive-primary-border);
--sh-btn-border-hover: var(--interactive-primary-border-hover);
--sh-btn-border-disabled: var(--interactive-primary-border-disabled);
}
.sh-btn[data-variant="secondary"] {
--sh-btn-bg: var(--interactive-secondary-bg);
--sh-btn-bg-hover: var(--interactive-secondary-bg-hover);
--sh-btn-bg-active: var(--interactive-secondary-bg-active);
--sh-btn-bg-disabled: var(--interactive-secondary-bg-disabled);
--sh-btn-fg: var(--interactive-secondary-fg);
--sh-btn-fg-hover: var(--interactive-secondary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-secondary-fg-disabled);
--sh-btn-border: var(--interactive-secondary-border);
--sh-btn-border-hover: var(--interactive-secondary-border-hover);
--sh-btn-border-disabled: var(--interactive-secondary-border-disabled);
}
.sh-btn[data-variant="dark"] {
--sh-btn-bg: var(--interactive-dark-bg);
--sh-btn-bg-hover: var(--interactive-dark-bg-hover);
--sh-btn-bg-active: var(--interactive-dark-bg-active);
--sh-btn-bg-disabled: var(--interactive-dark-bg-disabled);
--sh-btn-fg: var(--interactive-dark-fg);
--sh-btn-fg-hover: var(--interactive-dark-fg-hover);
--sh-btn-fg-disabled: var(--interactive-dark-fg-disabled);
--sh-btn-border: var(--interactive-dark-border);
--sh-btn-border-hover: var(--interactive-dark-border-hover);
--sh-btn-border-disabled: var(--interactive-dark-border-disabled);
}
.sh-btn[data-variant="danger"] {
--sh-btn-bg: var(--interactive-danger-bg);
--sh-btn-bg-hover: var(--interactive-danger-bg-hover);
--sh-btn-bg-active: var(--interactive-danger-bg-active);
--sh-btn-bg-disabled: var(--interactive-danger-bg-disabled);
--sh-btn-fg: var(--interactive-danger-fg);
--sh-btn-fg-hover: var(--interactive-danger-fg-hover);
--sh-btn-fg-disabled: var(--interactive-danger-fg-disabled);
--sh-btn-border: var(--interactive-danger-border);
--sh-btn-border-hover: var(--interactive-danger-border-hover);
--sh-btn-border-disabled: var(--interactive-danger-border-disabled);
}
.sh-btn[data-variant="ghost"] {
--sh-btn-bg: var(--interactive-ghost-bg);
--sh-btn-bg-hover: var(--interactive-ghost-bg-hover);
--sh-btn-bg-active: var(--interactive-ghost-bg-active);
--sh-btn-bg-disabled: var(--interactive-ghost-bg-disabled);
--sh-btn-fg: var(--interactive-ghost-fg);
--sh-btn-fg-hover: var(--interactive-ghost-fg-hover);
--sh-btn-fg-disabled: var(--interactive-ghost-fg-disabled);
--sh-btn-border: var(--interactive-ghost-border);
--sh-btn-border-hover: var(--interactive-ghost-border-hover);
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
}
/* Figma gives the ghost md/lg cells radius.md while every other cell uses
radius.sm - reproduced rather than normalised. */
.sh-btn[data-variant="ghost"][data-size="md"],
.sh-btn[data-variant="ghost"][data-size="lg"] {
border-radius: var(--radius-control-lg);
}
/* ---------------------------------------------------------------------
States
------------------------------------------------------------------ */
@media (hover: hover) {
.sh-btn:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-hover);
color: var(--sh-btn-fg-hover);
border-color: var(--sh-btn-border-hover);
}
}
/* No `-fg-active` / `-border-active` exists in the token contract, so the
pressed state deepens the fill only. */
.sh-btn:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-active);
}
.sh-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* A green ring on a green (or red, or near-black) fill reads as nothing. */
.sh-btn[data-variant="primary"]:focus-visible,
.sh-btn[data-variant="danger"]:focus-visible,
.sh-btn[data-variant="dark"]:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
.sh-btn:disabled,
.sh-btn[aria-disabled="true"] {
background-color: var(--sh-btn-bg-disabled);
color: var(--sh-btn-fg-disabled);
border-color: var(--sh-btn-border-disabled);
cursor: not-allowed;
}
/* ---------------------------------------------------------------------
Loading - the label keeps its box (no reflow) and stays in the
accessibility tree, which `visibility: hidden` would not allow.
------------------------------------------------------------------ */
.sh-btn[data-loading="true"] {
cursor: progress;
}
.sh-btn[data-loading="true"] .sh-btn__content {
opacity: 0;
}
.sh-btn__spinner {
grid-area: sh-btn-stack;
inline-size: 1em;
block-size: 1em;
border: var(--control-border-width) solid currentColor;
border-block-start-color: transparent;
border-radius: var(--radius-pill);
/* The contract publishes fast/base/slow only; two slows is the ~640ms a
spinner wants without inventing a duration token. */
animation: sh-btn-spin calc(var(--motion-duration-slow) * 2) linear infinite;
}
}
@keyframes sh-btn-spin {
to {
rotate: 1turn;
}
}