Skip to content
ssntl/ui

Interface details

The details that make an interface feel right: shadows instead of borders, image outlines, cross-fading icons and staggered entrances.

Color sphereQ 1,250.00

Installation

$ pnpm dlx shadcn@latest add @ssntl/details

Usage

css
/* app/globals.css */
@import "../styles/ssntl/details.css";

/* In your JSX */
<div class="border-shadow hover:border-shadow-strong">…</div>
<img class="image-outline" … />
<span class="icon-swap" data-swapped={copied}><Copy /><Check /></span>
<p class="animate-enter stagger-1">…</p>
  • border-shadow: three transparent layers instead of a border; in dark mode, a 1px edge at 8%.
  • image-outline: 1px black (white in dark) at 10%, inset. Never a tinted grey.
  • icon-swap: both icons in the DOM, crossed with opacity, scale 0.25 → 1 and blur 4px → 0.
  • animate-enter + stagger-{n}: chunked entrances at 100ms; enter-words drops the step to 80ms for titles.
  • Everything turns off or simplifies with reduced motion. The full guide is at /docs/details.

Code

styles/ssntl/details.css
/* Interface details: the small things that compound into an interface that
   feels right. After Jakub Krehel's "Details that make interfaces feel better".

     border-shadow                    depth without a border (3 layers, 1px in dark)
     border-shadow-strong             the same, a step darker (hover, selected)
     image-outline                    1px pure black/white at 10% inside images
     icon-swap                        cross-fade two icons: opacity, scale, blur
     animate-enter, stagger-{n}       split-and-stagger entrance, 100ms apart
     enter-words                      80ms steps, for a title split into words

   Text wrapping, font smoothing and tabular numbers are Tailwind built-ins:
   text-balance, text-pretty, antialiased, tabular-nums. */

/* Shadows instead of borders: transparent layers adapt to any background a
   solid border was not designed for. In dark the stack reads as a 1px edge. */
@utility border-shadow {
  box-shadow:
    0 0 0 1px rgb(0 0 0 / calc(0.06 * var(--border-shadow-step, 1))),
    0 1px 2px -1px rgb(0 0 0 / calc(0.06 * var(--border-shadow-step, 1))),
    0 2px 4px 0 rgb(0 0 0 / calc(0.04 * var(--border-shadow-step, 1)));
  transition-property: box-shadow;
  transition-duration: var(--duration-fast);
  transition-timing-function: var(--ease-out);
  @variant dark {
    box-shadow: 0 0 0 1px
      rgb(255 255 255 / calc(0.08 * var(--border-shadow-step, 1)));
  }
}

@utility border-shadow-strong {
  --border-shadow-step: 1.4;
}

/* A consistent edge on images, whatever is behind them. Pure black or white,
   never a tinted neutral: a tint picks up the surface and reads as dirt. */
@utility image-outline {
  outline: 1px solid oklch(0 0 0 / 0.1);
  outline-offset: -1px;
  @variant dark {
    outline-color: oklch(1 0 0 / 0.1);
  }
}

/* Contextual icons: both stay in the DOM and cross-fade, so the swap has an
   enter and an exit. Set data-swapped on the wrapper:
     <span class="icon-swap" data-swapped={copied}>
       <Copy /> <Check />
     </span>
   With reduced motion only the opacity fade remains. */
@utility icon-swap {
  display: inline-grid;
  place-items: center;

  & > * {
    grid-area: 1 / 1;
    transition-property: opacity, scale, filter;
    transition-duration: var(--duration-pop);
    transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
  }
  &:not([data-swapped="true"]) > :last-child,
  &[data-swapped="true"] > :first-child {
    opacity: 0;
    scale: 0.25;
    filter: blur(4px);
  }

  @media (prefers-reduced-motion: reduce) {
    & > * {
      scale: 1 !important;
      filter: none !important;
    }
  }
}

/* Split and stagger: animate the title, the description and the actions as
   separate chunks, 100ms apart, instead of one block. A keyframe runs once,
   which is right for a staged entrance; interactions use transitions. */
@utility animate-enter {
  @media (prefers-reduced-motion: no-preference) {
    animation: enter 700ms var(--ease-out) both;
    animation-delay: calc(var(--enter-delay, 100ms) * var(--stagger, 0));
  }
}

@utility stagger-* {
  --stagger: --value(integer);
}

@utility enter-words {
  --enter-delay: 80ms;
}

@keyframes enter {
  from {
    opacity: 0;
    translate: 0 var(--es-lift, 8px);
    filter: blur(8px);
  }
}