Niveus/niveus/components/button.css
Claude a19e0f205f
Depth, light and translucency without changing the API
Niveus read flat: thin sheens, short shadows, opaque grey chrome and a
painted canvas. This retunes values and component internals only; every
class and token name is unchanged, so existing pages pick it up as is.

- Canvas: a faint accent light falls across the page, so translucent
  layers have something to show through.
- Surfaces: the surface sheen runs the full height (lit top, shaded
  foot); raised and filled sheens, highlights and bevels are stronger;
  the elevation scale is deeper and softer.
- Translucency: cards, dialogs, the sidebar and tab tracks let some of
  the canvas through, with blur so busy grounds never read as texture;
  frosted and glass materials are clearer with more blur.
- Filled buttons give off a soft glow of their own color.
- Light fields are paper instead of grey wells that looked disabled.
- Fixes: navigation bars wrap and lists scroll inside them instead of
  overflowing the page on phones; choice labels sit at control size;
  disabled ghost buttons no longer grow a filled well; the color-swatch
  rule is split so neither engine drops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iMUTHrD8K9CSuHqsanje9
2026-10-12 20:49:22 +00:00

314 lines
10 KiB
CSS

/* ==========================================================================
Niveus — Button
--------------------------------------------------------------------------
.nv-button secondary by default — a raised surface
.nv-button--primary the single affirmative action in a view
.nv-button--ghost low-emphasis, no material until hovered
.nv-button--danger destructive, and only when it is
.nv-button--frosted for use over imagery or frosted chrome
.nv-button--sm | --lg 36px by default
.nv-button--icon square, for a lone icon (needs a label)
.nv-button--block fills its container
A button is a key: a bevelled face with light falling across it, a contact
line where it meets the surface, and a press that sinks it into an inset.
Every variant is defined purely by reassigning the private --_* properties
below. Interaction — the lift on hover, the sink on press, the focus ring —
lives once in the base rule, so a new variant can never drift from the way
the rest of the system responds to a pointer.
========================================================================== */
.nv-button {
/* Variant surface, overridden per modifier. */
--_bg: var(--nv-color-surface-elevated);
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-primary);
--_border: var(--nv-color-border);
--_sheen: var(--nv-sheen-raised);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm), var(--nv-outline);
/* Size, overridden per size modifier. */
--_height: var(--nv-control-height-md);
--_padding: var(--nv-control-padding-md);
--_gap: var(--nv-control-gap-md);
--_font-size: var(--nv-font-size-sm);
--_radius: var(--nv-radius-md);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_gap);
block-size: var(--_height);
padding-inline: var(--_padding);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
background-color: var(--_bg);
background-image: var(--_sheen);
color: var(--_fg);
box-shadow: var(--_shadow);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
font-weight: var(--nv-font-weight-medium);
line-height: var(--nv-line-height-snug);
letter-spacing: var(--nv-letter-spacing-normal);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: var(--nv-transition-control);
}
/* A control smaller than the minimum target still gets a full-size hit area:
the pseudo-element grows outward without changing the visual size. */
.nv-button::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
inline-size: max(100%, var(--nv-target-min));
block-size: max(100%, var(--nv-target-min));
}
@media (hover: hover) {
.nv-button:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-hover);
box-shadow: var(--_shadow-hover);
translate: 0 var(--nv-lift-hover);
}
}
/* Pressed, the face is pushed below the light: the sheen goes and the inset
takes over. */
.nv-button:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-active);
background-image: none;
box-shadow: var(--_shadow-active);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-button:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
.nv-button:disabled,
.nv-button[aria-disabled="true"] {
background-color: var(--nv-color-surface-inset);
background-image: none;
color: var(--nv-color-text-disabled);
border-color: var(--nv-color-border-subtle);
box-shadow: none;
translate: none;
cursor: not-allowed;
}
/* --- Variants --------------------------------------------------------------- */
.nv-button--primary {
--_bg: var(--nv-color-accent);
--_bg-hover: var(--nv-color-accent-hover);
--_bg-active: var(--nv-color-accent-active);
--_fg: var(--nv-color-on-accent);
--_border: transparent;
--_sheen: var(--nv-sheen-filled);
/* A filled key gives off a little of its own color: the glow is what makes
it read as lit rather than printed. */
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2),
0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3),
0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent);
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--danger {
--_bg: var(--nv-color-danger);
--_bg-hover: var(--nv-color-danger-hover);
--_bg-active: var(--nv-color-danger-active);
--_fg: var(--nv-color-on-danger);
--_border: transparent;
--_sheen: var(--nv-sheen-filled);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2),
0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3),
0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent);
--nv-color-focus-ring: var(--nv-color-danger-border);
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--ghost {
--_bg: transparent;
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-secondary);
--_border: transparent;
--_sheen: none;
--_shadow: none;
--_shadow-hover: none;
--_shadow-active: var(--nv-elevation-inset-sm);
}
/* A ghost has no material to lose, so disabling it only dims the label —
it does not suddenly grow a well its enabled siblings never had. */
.nv-button--ghost:disabled,
.nv-button--ghost[aria-disabled="true"] {
background-color: transparent;
border-color: transparent;
}
@media (hover: hover) {
.nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) {
color: var(--nv-color-text-primary);
translate: none; /* nothing to lift — a ghost has no material to raise */
}
}
.nv-button--subtle {
--_bg: var(--nv-color-accent-subtle);
--_bg-hover: var(--nv-color-accent-subtle-hover);
--_bg-active: var(--nv-color-accent-subtle-hover);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_sheen: none;
--_shadow: none;
--_shadow-hover: var(--nv-elevation-1);
}
.nv-button--frosted {
--_bg: var(--nv-material-glass-bg);
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-glass-border);
--_sheen: none;
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Sizes ------------------------------------------------------------------- */
.nv-button--sm {
--_height: var(--nv-control-height-sm);
--_padding: var(--nv-control-padding-sm);
--_gap: var(--nv-control-gap-sm);
--_font-size: var(--nv-font-size-xs);
--_radius: var(--nv-radius-sm);
}
.nv-button--lg {
--_height: var(--nv-control-height-lg);
--_padding: var(--nv-control-padding-lg);
--_gap: var(--nv-control-gap-lg);
--_font-size: var(--nv-font-size-md);
}
.nv-button--icon {
--_padding: 0;
inline-size: var(--_height);
}
.nv-button--pill {
--_radius: var(--nv-radius-full);
}
.nv-button--block {
display: flex;
inline-size: 100%;
}
/* --- Floating ------------------------------------------------------------------
A control that sits *over* content rather than in the flow: a carousel arrow,
a floating action. Two things change.
It is already off the surface, so there is nothing to lift it from — it grows
instead. And it is almost always positioned with `translate`, which the hover
lift would overwrite, throwing the button half its own height down the page
the moment a pointer touched it. --nv-button-offset carries that positioning
through every state. */
.nv-button--float {
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4);
--_shadow-active: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
translate: var(--nv-button-offset, 0 0);
}
@media (hover: hover) {
.nv-button--float:hover:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 1.06;
}
}
.nv-button--float:active:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 0.97;
}
/* --- Parts --------------------------------------------------------------------- */
.nv-button__icon {
inline-size: 1em;
block-size: 1em;
font-size: 1.15em;
opacity: 0.9;
}
/* Busy: the label stays in place to keep the button from resizing mid-action,
and the spinner replaces it. */
.nv-button[aria-busy="true"] {
color: transparent;
pointer-events: none;
}
.nv-button[aria-busy="true"]::before {
content: "";
position: absolute;
inline-size: 1em;
block-size: 1em;
border: 2px solid var(--_fg);
border-block-start-color: transparent;
border-radius: var(--nv-radius-full);
animation: nv-spin var(--nv-duration-slower) var(--nv-ease-linear) infinite;
}
@keyframes nv-spin {
to { rotate: 1turn; }
}
/* --- Group ---------------------------------------------------------------------
Segmented controls: buttons share an edge, so only the outer corners round
and the shared border is not drawn twice. */
.nv-button-group {
display: inline-flex;
align-items: center;
}
.nv-button-group > .nv-button:not(:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
margin-inline-start: calc(var(--nv-border-width) * -1);
}
.nv-button-group > .nv-button:not(:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.nv-button-group > .nv-button:hover,
.nv-button-group > .nv-button:focus-visible {
z-index: 1;
translate: none;
}