Skip to content
Buttons

Buttons

BorderBeamButton

Button ringed by a travelling beam, spinning conic or sweeping glints, in six fills.

npx bezel-add add border-beam-button

packages/ui/src/buttons/BorderBeamButton.tsx · 441 lines

"use client";

import { useEffect, useState } from "react";
import type {
  AnchorHTMLAttributes,
  ButtonHTMLAttributes,
  CSSProperties,
  ReactNode,
} from "react";

function cn(...parts: Array<string | false | null | undefined>) {
  return parts.filter(Boolean).join(" ");
}

/**
 * The surface under the label: its colour, radius and label case. This is the
 * variant axis, so a new surface is one entry in the union and one CSS block.
 */
export type BorderBeamFill = "ink" | "ghost" | "jade" | "cream" | "crimson" | "gold";

/**
 * How the border is drawn. All three treatments share one ring layer, so a new
 * treatment only has to fill that layer differently: a beam travelling an
 * offset path, a spinning conic ring, or glints sweeping the long edges.
 */
export type BorderBeamRing = "beam" | "conic" | "star";

/** Each fill keeps the ring it shipped with. Pass `ring` to mix them. */
const FILL_RING: Record<BorderBeamFill, BorderBeamRing> = {
  ink: "beam",
  ghost: "beam",
  jade: "conic",
  cream: "star",
  crimson: "star",
  gold: "star",
};

type Shared = {
  children?: ReactNode;
  /** Label text. `children` wins when both are given. */
  label?: string;
  fill?: BorderBeamFill;
  /** The earlier name for the two flat fills: primary is `ink`. */
  variant?: "primary" | "ghost";
  ring?: BorderBeamRing;
  /** Three ring stops. The beam takes the first two, the glints the first. */
  colors?: [string, string, string];
  /** Seconds for one pass of the ring. */
  spinDuration?: number;
  /** Pixels of bare surface the star glints sweep through, top and bottom. */
  thickness?: number;
  /** Grain over the surface. On under the jade fill, off under the others. */
  textured?: boolean;
  className?: string;
  style?: CSSProperties;
};

type AsButton = Shared &
  Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "style" | "children"> & {
    href?: undefined;
  };

type AsLink = Shared &
  Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "className" | "style" | "children" | "href"> & {
    href: string;
  };

export type BorderBeamButtonProps = AsButton | AsLink;

const GRAIN =
  "data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E";

export function BorderBeamButton(props: BorderBeamButtonProps) {
  const {
    children,
    label,
    fill,
    variant,
    ring,
    colors,
    spinDuration,
    thickness,
    textured,
    className,
    style,
    href,
    ...rest
  } = props;

  const resolvedFill: BorderBeamFill = fill ?? (variant === "ghost" ? "ghost" : "ink");
  const resolvedRing: BorderBeamRing = ring ?? FILL_RING[resolvedFill];
  const grain = textured ?? resolvedFill === "jade";
  const [c0, c1, c2] = colors ?? [];

  // A backgrounded tab paints nothing, but the ring keeps its place in the
  // timeline, so it would jump on return. Park it instead.
  const [running, setRunning] = useState(true);
  useEffect(() => {
    const sync = () => setRunning(document.visibilityState !== "hidden");
    sync();
    document.addEventListener("visibilitychange", sync);
    return () => document.removeEventListener("visibilitychange", sync);
  }, []);

  const mergedStyle = {
    ...style,
    ...(colors
      ? {
          ["--border-btn-0" as string]: c0,
          ["--border-btn-1" as string]: c1,
          ["--border-btn-2" as string]: c2,
          ["--border-btn-beam-a" as string]: c0,
          ["--border-btn-beam-b" as string]: c1,
          ["--border-btn-glint" as string]: c0,
        }
      : null),
    ...(spinDuration ? { ["--border-btn-dur" as string]: `${spinDuration}s` } : null),
    ...(thickness ? { ["--border-btn-star-thickness" as string]: `${thickness}px` } : null),
  } as CSSProperties;

  const classes = cn(
    "border-btn",
    `border-btn--${resolvedFill}`,
    `border-btn--ring-${resolvedRing}`,
    grain && "border-btn--textured",
    className,
  );

  const body = (
    <>
      <span
        className={cn("border-btn__ring", `border-btn__ring--${resolvedRing}`)}
        aria-hidden="true"
      >
        {resolvedRing === "beam" ? <span className="border-btn__beam" /> : null}
        {resolvedRing === "star" ? (
          <>
            <span className="border-btn__glint border-btn__glint--top" />
            <span className="border-btn__glint border-btn__glint--bottom" />
          </>
        ) : null}
      </span>
      {/* The star ring shows through a surface that pulls back from the long
       * edges, so only that ring needs one. */}
      {resolvedRing === "star" ? <span className="border-btn__surface" aria-hidden="true" /> : null}
      <span className="border-btn__label">{children ?? label}</span>
    </>
  );

  return (
    <>
      <style>{`
        @property --border-btn-ang {
          syntax: "<angle>";
          inherits: false;
          initial-value: 0deg;
        }
        .border-btn {
          --border-btn-radius: var(--bz-radius-md, 10px);
          --border-btn-ring-width: 1px;
          --border-btn-0: #b8701c;
          --border-btn-1: #4b3a8f;
          --border-btn-2: #4f7d10;
          --border-btn-glint: hsl(38 45% 52%);
          /* One curve and one state-flip duration for every treatment, taken
           * from the tokens: the library collapsed its five near-identical
           * editorial ease-outs to this one. */
          --border-btn-ease: var(--bz-ease-out, cubic-bezier(0.23, 1, 0.32, 1));
          --border-btn-flip: var(--bz-duration-fast, 150ms);
          position: relative;
          isolation: isolate;
          overflow: hidden;
          box-sizing: border-box;
          display: inline-flex;
          align-items: center;
          justify-content: center;
          gap: 0.5rem;
          min-height: 2.75rem;
          padding: 0 1.25rem;
          border: 0;
          border-radius: var(--border-btn-radius);
          /* The fill is a variable so the star ring can hand it to its inset
           * surface instead, and one hover rule serves both. */
          background: var(--border-btn-fill, transparent);
          font: inherit;
          font-size: 0.875rem;
          font-weight: 500;
          letter-spacing: 0.01em;
          text-decoration: none;
          cursor: pointer;
          transition: transform var(--border-btn-flip) var(--border-btn-ease),
            background-color var(--border-btn-flip) var(--border-btn-ease);
        }
        .border-btn--ink {
          --border-btn-fill: var(--bz-ink, #0a0a0a);
          color: var(--bz-paper-sunken, #fafafa);
        }
        .border-btn--ghost {
          --border-btn-fill: transparent;
          color: var(--bz-ink, #0a0a0a);
        }
        .border-btn--jade {
          --border-btn-radius: var(--bz-radius-full, 999px);
          --border-btn-ring-width: 2px;
          --border-btn-fill: linear-gradient(135deg, #00706a, #004b46);
          color: #f5fff9;
          font-size: 0.78125rem;
          font-weight: 700;
          letter-spacing: 0.13em;
          text-transform: uppercase;
        }
        .border-btn--cream,
        .border-btn--crimson,
        .border-btn--gold { letter-spacing: 0.02em; }
        .border-btn--cream {
          --border-btn-fill: var(--bz-paper-raised, #f7f3ee);
          --border-btn-edge: hsl(40 12% 82%);
          color: hsl(20 8% 18%);
        }
        .border-btn--crimson {
          --border-btn-fill: hsl(5 62% 35%);
          --border-btn-edge: hsl(5 62% 35% / 0.3);
          --border-btn-glint: hsl(5 62% 35%);
          color: var(--bz-paper-raised, #f7f3ee);
        }
        .border-btn--gold {
          --border-btn-fill: hsl(38 45% 52%);
          --border-btn-edge: hsl(38 45% 52% / 0.35);
          color: #1c1917;
        }
        .border-btn__ring {
          position: absolute;
          inset: 0;
          z-index: 1;
          border-radius: inherit;
          pointer-events: none;
        }
        .border-btn__ring--beam,
        .border-btn__ring--conic {
          padding: var(--border-btn-ring-width);
          -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
                  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          -webkit-mask-composite: xor;
                  mask-composite: exclude;
        }
        .border-btn__ring--beam { background: rgba(255, 255, 255, 0.09); }
        /* Ghost has no surface and cream barely separates from paper, so for
         * both the ring carries the control boundary and has to be seen:
         * 3.4:1 on paper. */
        .border-btn--ghost .border-btn__ring--beam,
        .border-btn--cream .border-btn__ring--beam {
          background: var(--bz-line-control, #8a8a8e);
        }
        .border-btn__ring--conic {
          opacity: 0.9;
          background: conic-gradient(
            from var(--border-btn-ang, 0deg),
            var(--border-btn-0),
            var(--border-btn-1),
            var(--border-btn-2),
            var(--border-btn-0)
          );
          animation: border-btn-spin var(--border-btn-dur, 3.2s) linear infinite;
        }
        .border-btn__beam {
          position: absolute;
          width: 80px;
          aspect-ratio: 1;
          background: linear-gradient(
            to left,
            var(--border-btn-beam-a, #ffaa40),
            var(--border-btn-beam-b, #9c40ff),
            transparent
          );
          offset-path: rect(0 100% 100% 0 round var(--border-btn-radius));
          offset-distance: 0%;
          animation: border-btn-travel var(--border-btn-dur, 4s) linear infinite;
        }
        /* The star ring reads as light escaping past the surface, so the ring
         * and the surface both sit behind, glints first. */
        .border-btn--ring-star {
          --border-btn-radius: 0.625rem;
          background: transparent;
          min-height: calc(2.75rem + 2 * var(--border-btn-star-thickness, 2px));
          padding: 0 calc(1.25rem + 1px);
          transition: transform var(--border-btn-flip) var(--border-btn-ease);
        }
        .border-btn__ring--star { z-index: -1; }
        .border-btn__surface {
          position: absolute;
          inset: var(--border-btn-star-thickness, 2px) 0;
          z-index: -1;
          box-sizing: border-box;
          border: 1px solid var(--border-btn-edge, transparent);
          /* Inset from the button box by the ring thickness, so the surface
           * keeps the box's radius off the scale rather than picking its own. */
          border-radius: calc(var(--border-btn-radius) - var(--border-btn-star-thickness, 2px));
          background: var(--border-btn-fill, transparent);
          box-shadow: 0 4px 14px -10px hsl(5 62% 22% / 0.2);
          pointer-events: none;
          transition: background-color var(--border-btn-flip) var(--border-btn-ease),
            border-color var(--border-btn-flip) var(--border-btn-ease),
            box-shadow var(--border-btn-flip) var(--border-btn-ease);
        }
        .border-btn__glint {
          position: absolute;
          width: 300%;
          height: 50%;
          opacity: 0.65;
          border-radius: 50%;
          pointer-events: none;
          background: radial-gradient(circle, var(--border-btn-glint), transparent 10%);
        }
        .border-btn__glint--top {
          top: -12px;
          left: -250%;
          animation: border-btn-glint-top var(--border-btn-dur, 5s) linear infinite alternate;
        }
        .border-btn__glint--bottom {
          bottom: -12px;
          right: -250%;
          animation: border-btn-glint-bottom var(--border-btn-dur, 5s) linear infinite alternate;
        }
        .border-btn--textured::before {
          content: "";
          position: absolute;
          inset: 0;
          z-index: 0;
          border-radius: inherit;
          pointer-events: none;
          opacity: 0.22;
          mix-blend-mode: soft-light;
          background-image: url("${GRAIN}");
        }
        .border-btn--ring-star.border-btn--textured::before {
          inset: var(--border-btn-star-thickness, 2px) 0;
          border-radius: 0.5rem;
        }
        .border-btn__label {
          position: relative;
          z-index: 2;
          display: inline-flex;
          align-items: center;
          gap: 0.5rem;
          white-space: nowrap;
        }
        .border-btn__label svg {
          transition: transform var(--border-btn-flip) var(--border-btn-ease);
        }
        /* The button clips its own ring, so the focus indicator is drawn as two
         * shadows rather than an outline. The gap and the ring still come off
         * the focus tokens, so it is the system's 2px at 2px offset. */
        .border-btn:focus-visible {
          outline: none;
          box-shadow: 0 0 0 var(--bz-focus-offset, 2px) var(--bz-paper, #ffffff),
            0 0 0 calc(var(--bz-focus-offset, 2px) + var(--bz-focus-width, 2px))
              var(--bz-focus-ring, #912c22);
        }
        .border-btn:disabled { cursor: not-allowed; opacity: 0.5; }
        .border-btn:disabled .border-btn__beam,
        .border-btn:disabled .border-btn__ring--conic { animation-play-state: paused; }
        .border-btn:disabled .border-btn__glint {
          animation-play-state: paused;
          opacity: 0.25;
        }
        .border-btn[data-running="false"] .border-btn__beam,
        .border-btn[data-running="false"] .border-btn__ring--conic,
        .border-btn[data-running="false"] .border-btn__glint {
          animation-play-state: paused;
        }
        .border-btn:active:not(:disabled) { transform: scale(0.97); }
        @media (hover: hover) and (pointer: fine) {
          .border-btn:hover:not(:disabled) { transform: translateY(-2px); }
          .border-btn--ring-star:hover:not(:disabled) { transform: translateY(-3px); }
          .border-btn--ink:hover:not(:disabled) {
            --border-btn-fill: var(--bz-void-raised, #1a1a1a);
          }
          .border-btn--ghost:hover:not(:disabled) {
            --border-btn-fill: var(--bz-paper-sunken, #fafafa);
          }
          .border-btn--cream:hover:not(:disabled) {
            --border-btn-fill: #efe9e1;
            --border-btn-edge: hsl(38 45% 52% / 0.4);
          }
          .border-btn--crimson:hover:not(:disabled) .border-btn__surface,
          .border-btn--gold:hover:not(:disabled) .border-btn__surface {
            filter: brightness(1.03);
          }
          .border-btn--ring-star:hover:not(:disabled) .border-btn__surface {
            box-shadow: 0 14px 28px -12px hsl(5 62% 22% / 0.38);
          }
          .border-btn--ring-star:hover:not(:disabled) .border-btn__label svg:last-child {
            transform: translateX(3px);
          }
          .border-btn:active:not(:disabled) { transform: translateY(-1px) scale(0.97); }
        }
        @media (prefers-reduced-motion: reduce) {
          .border-btn,
          .border-btn__surface,
          .border-btn__label svg { transition: none; }
          .border-btn:hover:not(:disabled),
          .border-btn:active:not(:disabled) { transform: none; }
          .border-btn__beam,
          .border-btn__ring--conic { animation: none; }
          .border-btn__glint { animation: none; opacity: 0.35; }
        }
        @keyframes border-btn-travel { to { offset-distance: 100%; } }
        @keyframes border-btn-spin { to { --border-btn-ang: 360deg; } }
        @keyframes border-btn-glint-top {
          0% { transform: translate(0%, 0%); opacity: 1; }
          100% { transform: translate(100%, 0%); opacity: 0; }
        }
        @keyframes border-btn-glint-bottom {
          0% { transform: translate(0%, 0%); opacity: 1; }
          100% { transform: translate(-100%, 0%); opacity: 0; }
        }
      `}</style>
      {typeof href === "string" ? (
        <a
          className={classes}
          style={mergedStyle}
          data-running={running ? "true" : "false"}
          {...(rest as AsLink)}
          href={href}
        >
          {body}
        </a>
      ) : (
        <button
          type={(rest as AsButton).type ?? "button"}
          className={classes}
          style={mergedStyle}
          data-running={running ? "true" : "false"}
          {...(rest as AsButton)}
        >
          {body}
        </button>
      )}
    </>
  );
}