Layout

Sticky bar

A bar that sticks to the top or bottom of its scroller and lifts only once content passes under it.

Built on
position: stickyScroll-driven animations
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.4 kB
Dependencies
React
Notifications6 new
  • Invoice 2041 paid
  • Mira mentioned you in Q3 plan
  • Deploy to eu-west-1 finished
  • New sign-in from Lisbon
  • Weekly report is ready
  • Kofi joined Larder
Try its options

Loading its options.

Code
import { StickyBar } from "@/components/equip/sticky-bar";

const messages = [
  "Invoice 2041 paid",
  "Mira mentioned you in Q3 plan",
  "Deploy to eu-west-1 finished",
  "New sign-in from Lisbon",
  "Weekly report is ready",
  "Kofi joined Larder",
];

export default function Demo() {
  return (
    <div
      tabIndex={0}
      aria-label="Notifications"
      role="region"
      style={{
        width: 208,
        height: 124,
        overflow: "auto",
        border: "1px solid var(--eq-line)",
        borderRadius: "var(--eq-radius-m)",
        background: "var(--eq-surface)",
        fontSize: "var(--eq-text-s)",
      }}
    >
      <StickyBar style={{ minHeight: 0, padding: "6px 10px", justifyContent: "space-between" }}>
        <strong>Notifications</strong>
        <span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>6 new</span>
      </StickyBar>
      <ul style={{ margin: 0, padding: "4px 10px 8px", listStyle: "none", display: "grid", gap: 6 }}>
        {messages.map((message) => (
          <li key={message} style={{ color: "var(--eq-ink-2)" }}>
            {message}
          </li>
        ))}
      </ul>
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div tabindex="0" aria-label="Notifications" role="region" style="width:208px;height:124px;overflow:auto;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface);font-size:var(--eq-text-s)">
  <div style="min-height:0;padding:6px 10px;justify-content:space-between" class="eq-sticky-bar" data-edge="top" data-tone="surface">
    <strong>Notifications</strong>
    <span style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">6 new</span>
  </div>
  <ul style="margin:0;padding:4px 10px 8px;list-style:none;display:grid;gap:6px">
    <li style="color:var(--eq-ink-2)">Invoice 2041 paid</li>
    <li style="color:var(--eq-ink-2)">Mira mentioned you in Q3 plan</li>
    <li style="color:var(--eq-ink-2)">Deploy to eu-west-1 finished</li>
    <li style="color:var(--eq-ink-2)">New sign-in from Lisbon</li>
    <li style="color:var(--eq-ink-2)">Weekly report is ready</li>
    <li style="color:var(--eq-ink-2)">Kofi joined Larder</li>
  </ul>
</div>

Examples

A save bar at the bottom

The bottom bar lifts while there are fields below it and settles flat once you reach the end of the form.

Unsaved changes
Code
import { Button } from "@/components/equip/button";
import { StickyBar } from "@/components/equip/sticky-bar";

const fields = ["Company name", "Billing email", "VAT number", "Street", "City", "Postcode", "Country"];

export default function Example() {
  return (
    <div
      tabIndex={0}
      role="region"
      aria-label="Billing details"
      style={{
        width: "100%",
        maxWidth: 420,
        height: 240,
        overflow: "auto",
        border: "1px solid var(--eq-line)",
        borderRadius: "var(--eq-radius-l)",
        background: "var(--eq-surface)",
      }}
    >
      <div style={{ display: "grid", gap: 12, padding: 16 }}>
        {fields.map((field) => (
          <label key={field} style={{ display: "grid", gap: 4, fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>
            {field}
            <input
              style={{
                height: 34,
                padding: "0 12px",
                border: "1px solid var(--eq-line-strong)",
                borderRadius: "var(--eq-radius-m)",
                background: "var(--eq-raised)",
                color: "var(--eq-ink)",
                font: "inherit",
                fontWeight: "var(--eq-weight)",
              }}
            />
          </label>
        ))}
      </div>
      <StickyBar as="footer" edge="bottom" style={{ justifyContent: "flex-end" }}>
        <span style={{ marginInlineEnd: "auto", fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Unsaved changes</span>
        <Button size="s" variant="ghost">
          Discard
        </Button>
        <Button size="s" variant="accent">
          Save
        </Button>
      </StickyBar>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div tabindex="0" role="region" aria-label="Billing details" style="width:100%;max-width:420px;height:240px;overflow:auto;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
  <div style="display:grid;gap:12px;padding:16px">
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Company name<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Billing email<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">VAT number<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Street<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">City<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Postcode<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
    <label style="display:grid;gap:4px;font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Country<input style="height:34px;padding:0 12px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised);color:var(--eq-ink);font:inherit;font-weight:var(--eq-weight)"></label>
  </div>
  <footer style="justify-content:flex-end" class="eq-sticky-bar" data-edge="bottom" data-tone="surface">
    <span style="margin-inline-end:auto;font-size:var(--eq-text-s);color:var(--eq-ink-2)">Unsaved changes</span>
    <button type="button" class="eq-button" data-variant="ghost" data-size="s">
      <span class="eq-button-label">Discard</span>
    </button>
    <button type="button" class="eq-button" data-variant="accent" data-size="s">
      <span class="eq-button-label">Save</span>
    </button>
  </footer>
</div>

A list header

The line and shadow appear only once rows are under the bar, so a list that fits shows no edge at all.

Pipeline runs
  • #1290mainPassed2 min
  • #1289fix/retry-502Passed3 min
  • #1288mainFailed1 min
  • #1287feat/audit-logPassed4 min
  • #1286mainPassed2 min
  • #1285chore/depsCancelled0 min
  • #1284mainPassed2 min
Code
import { Button } from "@/components/equip/button";
import { StickyBar } from "@/components/equip/sticky-bar";

const runs = [
  ["#1290", "main", "Passed", "2 min"],
  ["#1289", "fix/retry-502", "Passed", "3 min"],
  ["#1288", "main", "Failed", "1 min"],
  ["#1287", "feat/audit-log", "Passed", "4 min"],
  ["#1286", "main", "Passed", "2 min"],
  ["#1285", "chore/deps", "Cancelled", "0 min"],
  ["#1284", "main", "Passed", "2 min"],
];

export default function Example() {
  return (
    <div
      tabIndex={0}
      role="region"
      aria-label="Pipeline runs"
      style={{
        width: "100%",
        maxWidth: 480,
        height: 220,
        overflow: "auto",
        border: "1px solid var(--eq-line)",
        borderRadius: "var(--eq-radius-l)",
        background: "var(--eq-surface)",
      }}
    >
      <StickyBar as="header" style={{ justifyContent: "space-between" }}>
        <strong>Pipeline runs</strong>
        <Button size="s" variant="outline">
          Run pipeline
        </Button>
      </StickyBar>
      <ul style={{ margin: 0, padding: "4px 16px 12px", listStyle: "none" }}>
        {runs.map(([id, branch, status, time]) => (
          <li
            key={id}
            style={{
              display: "flex",
              gap: 12,
              padding: "8px 0",
              borderBottom: "1px solid var(--eq-line)",
              fontSize: "var(--eq-text-s)",
            }}
          >
            <span style={{ fontVariantNumeric: "tabular-nums" }}>{id}</span>
            <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--eq-ink-2)" }}>
              {branch}
            </span>
            <span>{status}</span>
            <span style={{ color: "var(--eq-ink-2)" }}>{time}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div tabindex="0" role="region" aria-label="Pipeline runs" style="width:100%;max-width:480px;height:220px;overflow:auto;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);background:var(--eq-surface)">
  <header style="justify-content:space-between" class="eq-sticky-bar" data-edge="top" data-tone="surface">
    <strong>Pipeline runs</strong>
    <button type="button" class="eq-button" data-variant="outline" data-size="s">
      <span class="eq-button-label">Run pipeline</span>
    </button>
  </header>
  <ul style="margin:0;padding:4px 16px 12px;list-style:none">
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1290</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">main</span>
      <span>Passed</span>
      <span style="color:var(--eq-ink-2)">2 min</span>
    </li>
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1289</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">fix/retry-502</span>
      <span>Passed</span>
      <span style="color:var(--eq-ink-2)">3 min</span>
    </li>
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1288</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">main</span>
      <span>Failed</span>
      <span style="color:var(--eq-ink-2)">1 min</span>
    </li>
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1287</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">feat/audit-log</span>
      <span>Passed</span>
      <span style="color:var(--eq-ink-2)">4 min</span>
    </li>
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1286</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">main</span>
      <span>Passed</span>
      <span style="color:var(--eq-ink-2)">2 min</span>
    </li>
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1285</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">chore/deps</span>
      <span>Cancelled</span>
      <span style="color:var(--eq-ink-2)">0 min</span>
    </li>
    <li style="display:flex;gap:12px;padding:8px 0;border-bottom:1px solid var(--eq-line);font-size:var(--eq-text-s)">
      <span style="font-variant-numeric:tabular-nums">#1284</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--eq-ink-2)">main</span>
      <span>Passed</span>
      <span style="color:var(--eq-ink-2)">2 min</span>
    </li>
  </ul>
</div>

Props

PropTypeDefaultWhat it does
as"div" | "header" | "footer" | "nav""div"The root element. header or footer when the bar heads or ends a section.
edge"top" | "bottom""top"The edge of the scroll container it sticks to.
tone"bg" | "surface" | "raised""surface"The fill behind the bar. Match it to what scrolls underneath.
...restComponentProps<"div">noneEvery native attribute of the root element.

Anatomy

root.eq-sticky-barA flex row. The line and shadow are on the side facing the content.

Notes

  • Renders on the server with no client JavaScript; the lift is driven by the scroll position in CSS.
  • A top bar lifts over the first 16px of scrolling; a bottom bar stays lifted until the last 16px, then settles flat at the end.
  • Make it the first or last child of the scroll container: the lift follows that container's scroll position, not the moment the bar starts to stick.
  • When the container does not scroll at all, the bar stays flat.
  • Give a scrolling region tabindex="0" and a name so keyboard users can scroll it, as the examples do.
  • The line and shadow follow the scroll position with scroll-driven animations (animation-timeline: scroll()). Engines without them always show the line on the content side and no shadow.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/sticky-bar.tsx28 lines
import type { ComponentProps } from "react";
import "./sticky-bar.css";

export type StickyBarProps = ComponentProps<"div"> & {
  /** The element at the root. header or footer when the bar heads or ends a section. */
  as?: "div" | "header" | "footer" | "nav";
  /** The edge of the scroll container it sticks to. */
  edge?: "top" | "bottom";
  /** The fill behind the bar. Match it to what scrolls underneath. */
  tone?: "bg" | "surface" | "raised";
};

/**
 * A bar that sticks to the top or bottom of its scroll container and lifts
 * with a line and a shadow only while content is scrolled underneath it.
 */
export function StickyBar({ as = "div", edge = "top", tone = "surface", className, ...rest }: StickyBarProps) {
  // The root changes element but never its attributes, so the div typing holds for all four.
  const Root = as as "div";
  return (
    <Root
      {...rest}
      className={["eq-sticky-bar", className].filter(Boolean).join(" ")}
      data-edge={edge}
      data-tone={tone}
    />
  );
}
components/equip/sticky-bar.css76 lines
@layer equip {
  .eq-sticky-bar {
    --_bg: var(--eq-surface);
    --_line: var(--eq-line);
    /* A short, tight shadow on the side that faces the content. --eq-backdrop is the
       shade the tokens give for "something sits over this", in light and dark. */
    --_lift: 0 8px 12px -10px var(--eq-backdrop);

    position: sticky;
    top: 0;
    z-index: 2;
    box-sizing: border-box;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-2) var(--eq-space-3);
    min-height: var(--eq-h-l);
    margin: 0;
    padding: var(--eq-space-2) max(var(--eq-space-4), env(safe-area-inset-right, 0px)) var(--eq-space-2)
      max(var(--eq-space-4), env(safe-area-inset-left, 0px));
    border: 0 solid transparent;
    border-bottom-width: 1px;
    background: var(--_bg);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
  }

  .eq-sticky-bar[data-edge="bottom"] {
    --_lift: 0 -8px 12px -10px var(--eq-backdrop);

    top: auto;
    bottom: 0;
    border-bottom-width: 0;
    border-top-width: 1px;
  }

  .eq-sticky-bar[data-tone="bg"] { --_bg: var(--eq-bg); }
  .eq-sticky-bar[data-tone="raised"] { --_bg: var(--eq-raised); }

  /* Where scroll-driven animations exist, the line and shadow follow the scroll position:
     a top bar lifts over the first 16px of scrolling, a bottom bar settles over the last 16px.
     When the container does not scroll at all the timeline is inactive and the bar stays flat. */
  @supports (animation-timeline: scroll()) {
    .eq-sticky-bar {
      animation: eq-sticky-bar-lift linear both;
      animation-timeline: scroll(nearest block);
      animation-range: 0 var(--eq-space-4);
    }

    .eq-sticky-bar[data-edge="bottom"] {
      animation-direction: reverse;
      animation-range: calc(100% - var(--eq-space-4)) 100%;
    }
  }

  /* Elsewhere the bar always shows its line, so the edge is never lost; there is no shadow. */
  @supports not (animation-timeline: scroll()) {
    .eq-sticky-bar {
      border-color: var(--_line);
    }
  }

  @keyframes eq-sticky-bar-lift {
    from {
      border-color: transparent;
      box-shadow: 0 0 0 0 transparent;
    }

    to {
      border-color: var(--_line);
      box-shadow: var(--_lift);
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index