Skip to content

Latest commit

 

History

History
688 lines (580 loc) · 13.4 KB

File metadata and controls

688 lines (580 loc) · 13.4 KB

Component Patterns

Production-ready components using modern CSS and semantic HTML.

Table of Contents


Button (Tile Pattern)

Exact tile button pattern with nested structure and staggered timing.

Behavior:

  • Default: outer shell raised, inner flat
  • Hover: outer flattens FIRST (immediate), inner becomes pressed SECOND (150ms delay)
  • Unhover: reverse sequence (inner releases first, outer raises with delay)
  • Text/icon moves down 2.4px on hover

HTML

<!-- Primary button with icon -->
<a href="#contact" class="btn-tile btn-tile--primary">
  <span class="btn-tile__inner">
    <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
    <span>Get in touch</span>
  </span>
</a>

<!-- Ghost button (secondary action) -->
<a href="#work" class="btn-tile btn-tile--ghost">
  <span class="btn-tile__inner">
    <span>View work</span>
  </span>
</a>

<!-- Submit button -->
<button type="submit" class="btn-tile btn-tile--primary">
  <span class="btn-tile__inner">
    <i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
    <span>Send message</span>
  </span>
</button>

CSS

/* Outer shell */
.btn-tile {
  --depth: 1;
  position: relative;
  display: inline-flex;
  padding: 0.1rem;
  border-radius: var(--radius-sm);
  background: var(--silver-100);
  text-decoration: none;
  cursor: pointer;
  border: none;
  font: inherit;

  /* Raised state */
  box-shadow:
    var(--shadow-source)
    calc(0.33rem * var(--depth))
    calc(0.33rem * var(--depth))
    0.6rem;
  /* Return animation: 150ms delay before transition */
  transition: box-shadow 0.15s 0.15s ease-in;
}

.btn-tile::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow:
    var(--light-source)
    calc(-0.33rem * var(--depth))
    calc(-0.33rem * var(--depth))
    0.6rem;
  transition: box-shadow 0.15s 0.15s ease-in;
}

/* Inner tile - starts FLAT */
.btn-tile__inner {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-6);
  min-block-size: var(--min-touch-target);
  border-radius: calc(var(--radius-sm) - 2px);
  background: var(--silver-100);
  color: var(--ink-900);
  font-weight: 500;

  /* No shadow initially */
  box-shadow: inset var(--shadow-source) 0 0 0;
  transition: box-shadow 0.15s ease-in;
}

.btn-tile__inner::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow: inset var(--light-source) 0 0 0;
  transition: box-shadow 0.15s ease-in;
}

/* Text/icon transition */
.btn-tile__inner span,
.btn-tile__inner i {
  transition: transform 0.3s ease-in-out;
}

/* HOVER: Outer flattens immediately */
.btn-tile:hover {
  box-shadow: var(--shadow-source) 0 0 0;
  transition: box-shadow 0.15s ease-out;
}

.btn-tile:hover::before {
  box-shadow: var(--light-source) 0 0 0;
  transition: box-shadow 0.15s ease-out;
}

/* HOVER: Inner becomes pressed (150ms delay) */
.btn-tile:hover .btn-tile__inner {
  box-shadow:
    inset var(--shadow-source)
    calc(0.25rem * var(--depth))
    calc(0.25rem * var(--depth))
    0.6rem;
  transition: box-shadow 0.15s 0.15s ease-out;
}

.btn-tile:hover .btn-tile__inner::before {
  box-shadow:
    inset var(--light-source)
    calc(-0.25rem * var(--depth))
    calc(-0.25rem * var(--depth))
    0.6rem;
  transition: box-shadow 0.15s 0.15s ease-out;
}

/* Text moves down on press */
.btn-tile:hover .btn-tile__inner span,
.btn-tile:hover .btn-tile__inner i {
  transform: translateY(0.1516rem);  /* 2.4px */
}

.btn-tile:focus-visible {
  outline: 2px solid var(--signal-focus);
  outline-offset: 2px;
}

/* Primary: dark inner */
.btn-tile--primary .btn-tile__inner {
  background: var(--ink-900);
  color: var(--white);
}

/* Ghost: transparent, no shadows */
.btn-tile--ghost {
  background: transparent;
  box-shadow: none;
  padding: 0;
}

.btn-tile--ghost::before {
  display: none;
}

.btn-tile--ghost .btn-tile__inner {
  background: transparent;
  box-shadow: none;
}

.btn-tile--ghost .btn-tile__inner::before {
  display: none;
}

.btn-tile--ghost:hover {
  box-shadow: none;
}

.btn-tile--ghost:hover .btn-tile__inner {
  background: var(--silver-200);
  box-shadow: none;
}

Input

Neumorphic inset input field.

HTML

<div class="input-group">
  <label for="email" class="input-label">Email address</label>
  <input
    type="email"
    id="email"
    class="input"
    placeholder="you@example.com"
    required
  >
  <span class="input-error" aria-live="polite"></span>
</div>

CSS

.input-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.input-label {
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--ink-700);
}

.input {
  /* Reset */
  appearance: none;
  border: none;
  font: inherit;

  /* Base */
  --depth: 2;
  position: relative;
  padding: var(--space-3) var(--space-4);
  min-block-size: var(--min-touch-target);
  border: 1px solid var(--silver-400);
  border-radius: var(--radius-sm);
  background: var(--silver-100);
  color: var(--ink-900);

  /* Neumorphic inset */
  box-shadow:
    inset var(--shadow-source)
    calc(0.25rem * var(--depth))
    calc(0.25rem * var(--depth))
    0.6rem;

  transition:
    border-color var(--duration-instant),
    box-shadow var(--duration-instant);
}

.input::placeholder {
  color: var(--ink-500);
}

/* Focus */
.input:focus {
  outline: none;
  border-color: var(--signal-focus);
  box-shadow:
    inset var(--shadow-source) 2px 2px 4px,
    0 0 0 2px oklch(45% 0.2 260 / 0.2);
}

/* Invalid (only when not empty) */
.input:invalid:not(:placeholder-shown) {
  border-color: var(--signal-error);
}

/* Error message */
.input-error {
  font-size: var(--text-sm);
  color: var(--signal-error);
  min-block-size: 1.25em;
}

/* Textarea variant */
textarea.input {
  min-block-size: 8rem;
  resize: vertical;
}

Card

Elevated container with optional interactivity.

HTML

<article class="card">
  <header class="card__header">
    <h3 class="card__title">Card Title</h3>
  </header>
  <div class="card__body">
    <p>Card content goes here.</p>
  </div>
  <footer class="card__footer">
    <button class="btn btn--ghost">Cancel</button>
    <button class="btn btn--primary">Confirm</button>
  </footer>
</article>

<!-- Interactive card (link) -->
<a href="/details" class="card card--interactive">
  <div class="card__body">
    <h3 class="card__title">Clickable Card</h3>
    <p>This entire card is a link.</p>
  </div>
</a>

CSS

.card {
  --depth: 2;
  position: relative;
  padding: var(--space-6);
  border-radius: var(--radius-md);
  background: var(--white);

  /* Neumorphic raised */
  box-shadow:
    var(--shadow-source)
    calc(0.33rem * var(--depth))
    calc(0.33rem * var(--depth))
    0.6rem;
}

.card::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow:
    var(--light-source)
    calc(-0.33rem * var(--depth))
    calc(-0.33rem * var(--depth))
    0.6rem;
}

.card__header {
  margin-block-end: var(--space-4);
}

.card__title {
  font-size: var(--text-lg);
  font-weight: 600;
  color: var(--ink-900);
  margin: 0;
}

.card__body {
  color: var(--ink-700);
}

.card__body > * + * {
  margin-block-start: var(--space-2);
}

.card__footer {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-2);
  margin-block-start: var(--space-6);
  padding-block-start: var(--space-4);
  border-block-start: 1px solid var(--silver-200);
}

/* Interactive variant */
.card--interactive {
  cursor: pointer;
  text-decoration: none;
  color: inherit;
  transition: transform var(--duration-quick) var(--ease-subtle);
}

.card--interactive:hover {
  --depth: 4;
  transform: translateY(-2px);
}

.card--interactive:focus-visible {
  outline: 2px solid var(--signal-focus);
  outline-offset: 2px;
}

Tile System

Neumorphic tiles with up/down/button states (from original design).

HTML

<div class="tile up">Up</div>
<div class="tile down">Down</div>
<div class="tile padded up">
  <div class="tile down">Up & Down</div>
</div>
<div class="tile button">
  <div class="tile"><span>Button</span></div>
</div>

<!-- Circular variants -->
<div class="tile tile--circle up">Up</div>
<div class="tile tile--circle down">Down</div>
<div class="tile tile--circle button"><span>Button</span></div>

CSS

.tile {
  --depth: 1;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 6rem;
  height: 6rem;
  border-radius: var(--radius-md);
  background: var(--silver-100);
  font-family: var(--font-display);
  text-transform: uppercase;
  text-align: center;
  line-height: 0.9;
  color: var(--ink-700);
}

.tile::before,
.tile::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
}

.tile--circle {
  border-radius: 50%;
}

.tile--circle::before {
  border-radius: 50%;
}

.padded {
  padding: 0.1rem;
}

/* Raised (up) */
.up {
  box-shadow:
    var(--shadow-source)
    calc(0.33rem * var(--depth))
    calc(0.33rem * var(--depth))
    0.6rem;
}

.up::before {
  box-shadow:
    var(--light-source)
    calc(-0.33rem * var(--depth))
    calc(-0.33rem * var(--depth))
    0.6rem;
}

/* Inset (down) */
.down {
  box-shadow:
    inset var(--shadow-source)
    calc(0.25rem * var(--depth))
    calc(0.25rem * var(--depth))
    0.6rem;
}

.down::before {
  box-shadow:
    inset var(--light-source)
    calc(-0.25rem * var(--depth))
    calc(-0.25rem * var(--depth))
    0.6rem;
}

/* Button (interactive) */
.button {
  cursor: pointer;
  box-shadow:
    var(--shadow-source)
    calc(0.33rem * var(--depth))
    calc(0.33rem * var(--depth))
    0.6rem;
  transition: box-shadow var(--duration-quick) var(--duration-quick) ease-in;
}

.button::before {
  box-shadow:
    var(--light-source)
    calc(-0.33rem * var(--depth))
    calc(-0.33rem * var(--depth))
    0.6rem;
  transition: box-shadow var(--duration-quick) var(--duration-quick) ease-in;
}

.button span {
  transition: transform var(--duration-standard) ease-in-out;
}

.button > .tile {
  box-shadow: inset var(--shadow-source) 0 0 0;
  transition: box-shadow var(--duration-quick) ease-in;
}

.button > .tile::before {
  box-shadow: inset var(--light-source) 0 0 0;
  transition: box-shadow var(--duration-quick) ease-in;
}

.button:hover {
  box-shadow: var(--shadow-source) 0 0 0;
  transition: box-shadow var(--duration-quick) ease-out;
}

.button:hover::before {
  box-shadow: var(--light-source) 0 0 0;
  transition: box-shadow var(--duration-quick) ease-out;
}

.button:hover > .tile {
  box-shadow:
    inset var(--shadow-source)
    calc(0.25rem * var(--depth))
    calc(0.25rem * var(--depth))
    0.6rem;
  transition: box-shadow var(--duration-quick) var(--duration-quick) ease-out;
}

.button:hover > .tile::before {
  box-shadow:
    inset var(--light-source)
    calc(-0.25rem * var(--depth))
    calc(-0.25rem * var(--depth))
    0.6rem;
  transition: box-shadow var(--duration-quick) var(--duration-quick) ease-out;
}

.button:hover span {
  transform: translateY(2px);
}

Navigation (Neumorphic Pills)

Navigation links styled as subtle neumorphic pills that raise on hover and press on active.

HTML

<nav class="nav" aria-label="Main navigation">
  <a href="/" class="nav__logo" aria-label="Home">
    <img src="/logo.svg" alt="" width="32" height="32">
  </a>
  <ul class="nav__links" role="list">
    <li><a href="/about" class="nav__link">About</a></li>
    <li><a href="/work" class="nav__link">Work</a></li>
    <li><a href="/contact" class="nav__link nav__link--active" aria-current="page">Contact</a></li>
  </ul>
</nav>

CSS

.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-8);
  padding: var(--space-4) var(--container-padding);
}

.nav__logo {
  display: flex;
  align-items: center;
}

.nav__links {
  display: flex;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Neumorphic pill links */
.nav__link {
  --depth: 1;
  position: relative;
  display: inline-flex;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink-700);
  text-decoration: none;
  font-weight: 500;
  transition:
    color var(--duration-instant),
    background var(--duration-quick),
    box-shadow var(--duration-quick);
}

.nav__link::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow: var(--light-source) 0 0 0;
  transition: box-shadow var(--duration-quick);
}

/* Hover: raised pill */
.nav__link:hover {
  color: var(--ink-900);
  background: var(--silver-100);
  box-shadow:
    var(--shadow-source)
    calc(0.2rem * var(--depth))
    calc(0.2rem * var(--depth))
    0.4rem;
}

.nav__link:hover::before {
  box-shadow:
    var(--light-source)
    calc(-0.2rem * var(--depth))
    calc(-0.2rem * var(--depth))
    0.4rem;
}

/* Active: pressed/inset pill */
.nav__link--active {
  background: var(--silver-100);
  box-shadow:
    inset var(--shadow-source)
    calc(0.15rem * var(--depth))
    calc(0.15rem * var(--depth))
    0.3rem;
}

.nav__link--active::before {
  box-shadow:
    inset var(--light-source)
    calc(-0.15rem * var(--depth))
    calc(-0.15rem * var(--depth))
    0.3rem;
}

.nav__link:focus-visible {
  outline: 2px solid var(--signal-focus);
  outline-offset: 2px;
}