component

Attachment

A layout-only wrapper that pins an arbitrary content element to the outer bounds of a target element. It’s content-agnostic — drop in badges, stamps, status dots, icons, or anything else. Positioning is handled by Floating UI with collision-aware flip and shift, and auto-updates on scroll and resize.

The wrapper hugs its target (display: inline-flex, position: relative) and the content slot is positioned against it. By default the content’s center sits on the target’s corner (badge convention) — switch to anchor="outside" for flush-outside placement, or anchor="inside" to tuck it fully within the target’s bounds.

Default

A status dot pinned to the bottom-end corner of an avatar-like element.

---
// GENERATED from packages/demos/attachment/default.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import { css } from "@pindoba/styled-system/css";

const avatar = css({
  width: "64px",
  height: "64px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "full",
});
const status = css({
  width: "14px",
  height: "14px",
  background: "success",
  border: "2px solid",
  borderColor: "neutral.surface.hill",
  borderRadius: "full",
  display: "block",
});
---

<Attachment placement="bottom-end" shape="circle">
  <span slot="content" class={status}></span>
  <div class={avatar}></div>
</Attachment>

Placements

The placement prop accepts any Floating UI Placement — top-start, top-end, bottom-start, bottom-end, left, right, and so on.

3
top-start
3
top-end
3
bottom-start
3
bottom-end
---
// GENERATED from packages/demos/attachment/placements.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";

const placements = [
  "top-start",
  "top-end",
  "bottom-start",
  "bottom-end",
] as const;
const row = flex({ gap: "xl", wrap: "wrap" });
const cell = flex({ direction: "column", align: "center", gap: "xs" });
const thumb = css({
  width: "56px",
  height: "56px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "md",
});
const dot = css({
  colorPalette: "primary",
  minWidth: "18px",
  height: "18px",
  paddingX: "xs",
  background: "colorPalette",
  color: "colorPalette.text.contrast.bold",
  borderRadius: "full",
  fontSize: "xs",
  fontWeight: "semibold",
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
});
const label = css({ fontSize: "xs" });
---

<div class={row}>
  {placements.map((placement) => (
    <div class={cell}>
      <Attachment placement={placement}>
        <span slot="content" class={dot}>
          3
        </span>
        <div class={thumb}></div>
      </Attachment>
      <code class={label}>{placement}</code>
    </div>
  ))}
</div>

Anchor

Switch anchor to "outside" when you want the attachment to sit flush outside the target — common for tooltips, pointer labels, or callouts. Use "inside" when nothing may overhang: the attachment sits fully within the target’s bounds, flush against the placement corner, and offset becomes an inward inset. That’s the right pick for dense lists and narrow rails, where a corner-anchored badge would land on the neighbouring row or on a scrollbar.

9
corner
9
outside
9
inside
---
// GENERATED from packages/demos/attachment/anchor.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";

const row = flex({ gap: "3xl" });
const cell = flex({ direction: "column", align: "center", gap: "xs" });
const thumb = css({
  width: "64px",
  height: "64px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "md",
});
const dot = css({
  colorPalette: "primary",
  minWidth: "20px",
  height: "20px",
  paddingX: "xs",
  background: "colorPalette",
  color: "colorPalette.text.contrast.bold",
  borderRadius: "full",
  fontSize: "xs",
  fontWeight: "semibold",
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
});
const label = css({ fontSize: "xs" });
---

<div class={row}>
  <div class={cell}>
    <Attachment placement="top-end" anchor="corner">
      <span slot="content" class={dot}>
        9
      </span>
      <div class={thumb}></div>
    </Attachment>
    <code class={label}>corner</code>
  </div>

  <div class={cell}>
    <Attachment placement="top-end" anchor="outside">
      <span slot="content" class={dot}>
        9
      </span>
      <div class={thumb}></div>
    </Attachment>
    <code class={label}>outside</code>
  </div>

  <div class={cell}>
    <Attachment placement="top-end" anchor="inside" offset={4}>
      <span slot="content" class={dot}>
        9
      </span>
      <div class={thumb}></div>
    </Attachment>
    <code class={label}>inside</code>
  </div>
</div>

Clipping (strategy)

The content is laid out inside the wrapper, so anything hanging outside the target’s box is clipped by the first ancestor with overflow other than visible — a scrolling rail, an overflow: hidden card. Floating UI computes coordinates; it does not escape ancestor clipping.

Set strategy="fixed" to position the content against the viewport instead. It escapes every clipping ancestor without being portalled out of the DOM, and auto-hides while the target is scrolled out of view. Two caveats: an ancestor with transform, filter, backdrop-filter, contain, or will-change establishes a fixed containing block and clips again, and the content is no longer part of the wrapper’s layout for stacking purposes — raise its z-index through passThrough.content.style if a later sibling paints over it.

<Navigation> already uses strategy="fixed" for its compact-rail badges. If overhang itself is the problem rather than the clip, prefer anchor="inside".

CSS-only placement (strategy="inline")

Both strategies above measure the target with Floating UI and re-measure after layout changes, so when the target moves inside a layout animation — a sidebar collapsing, a panel morphing — the attachment trails it by a frame. Set strategy="inline" and the placement is pure CSS instead: top/left percentages of the wrapper (which shrink-wraps the target) plus a translate of the content’s own size. No JavaScript runs, the content is in place in the server-rendered markup on first paint, and it moves in the same frame as its target.

It supports every placement, anchor, shape="circle" (exact for square or round targets), offset, offsetX, offsetY, and interactive. It ignores what needs a measurement — hugCorners, flip, shift, and trackTransform — and warns in development if you pass them. Use it for in-flow decorations such as badges, dots, and counts; keep "absolute" or "fixed" when you need collision handling or to escape a clip.

9
corner
9
inside
circle
---
// GENERATED from packages/demos/attachment/inline.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";

const row = flex({ gap: "3xl" });
const cell = flex({ direction: "column", align: "center", gap: "xs" });
const thumb = css({
  width: "64px",
  height: "64px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "md",
});
const avatar = css({
  width: "64px",
  height: "64px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "full",
});
const dot = css({
  colorPalette: "primary",
  minWidth: "20px",
  height: "20px",
  paddingX: "xs",
  background: "colorPalette",
  color: "colorPalette.text.contrast.bold",
  borderRadius: "full",
  fontSize: "xs",
  fontWeight: "semibold",
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
});
const status = css({
  colorPalette: "success",
  width: "14px",
  height: "14px",
  background: "colorPalette",
  border: "2px solid",
  borderColor: "neutral.surface.base",
  borderRadius: "full",
});
const label = css({ fontSize: "xs" });
---

<div class={row}>
  <div class={cell}>
    <Attachment strategy="inline" placement="top-end" interactive={false}>
      <span slot="content" class={dot}>
        9
      </span>
      <div class={thumb}></div>
    </Attachment>
    <code class={label}>corner</code>
  </div>

  <div class={cell}>
    <Attachment
      strategy="inline"
      placement="bottom-start"
      anchor="inside"
      offset={4}
      interactive={false}
    >
      <span slot="content" class={dot}>
        9
      </span>
      <div class={thumb}></div>
    </Attachment>
    <code class={label}>inside</code>
  </div>

  <div class={cell}>
    <Attachment
      strategy="inline"
      placement="bottom-end"
      shape="circle"
      interactive={false}
    >
      <span slot="content" class={status}></span>
      <div class={avatar}></div>
    </Attachment>
    <code class={label}>circle</code>
  </div>
</div>

Pointer events

The content is painted on top of the target, so while it’s interactive it also swallows the clicks and hovers that land on its own box — a badge pinned to a link’s corner carves a dead spot out of that link’s hit area. Pass interactive={false} for a decorative indicator (badge, dot, count) and pointer events fall through to the target; keep the default for content that is itself a control, like a dismiss button.

<Navigation>’s compact-rail badges are non-interactive for exactly this reason.

Offset

offset stacks on top of the anchor — positive pushes the content further from the target, negative pulls it closer in. Under anchor="inside" it reads as an inward inset instead.

Card title

Body text.

NEW
---
// GENERATED from packages/demos/attachment/offset.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import { css } from "@pindoba/styled-system/css";

const card = css({
  width: "240px",
  background: "neutral.surface.hill",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "lg",
  padding: "md",
});
const title = css({
  fontSize: "sm",
  fontWeight: "semibold",
  color: "neutral.text.bold",
});
const caption = css({
  marginTop: "2xs",
  color: "neutral.text.muted",
  fontSize: "xs",
});
const tag = css({
  colorPalette: "primary",
  paddingX: "xs",
  paddingY: "2xs",
  background: "colorPalette",
  color: "colorPalette.text.contrast.bold",
  borderRadius: "sm",
  fontSize: "xs",
  fontWeight: "bold",
  letterSpacing: "0.05em",
});
---

<Attachment placement="top-start" offset={-8}>
  <span slot="content" class={tag}>
    NEW
  </span>
  <div class={card}>
    <strong class={title}>Card title</strong>
    <p class={caption}>Body text.</p>
  </div>
</Attachment>

Badges

Numeric counters, unread dots, and presence indicators — the classic “notification badge” use case. Default anchor="corner" keeps the badge centered on the target’s corner.

3
99+
New
Online
---
// GENERATED from packages/demos/attachment/badges.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import Badge from "@pindoba/astro-badge";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";

const row = flex({ gap: "2xl", align: "center" });
const thumb = css({
  width: "48px",
  height: "48px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "md",
});
const thumbRound = css({
  width: "48px",
  height: "48px",
  background: "neutral.surface.ground",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "full",
});
---

<div class={row}>
  <Attachment placement="top-end">
    <Badge slot="content" size="sm" tone="danger">
      3
    </Badge>
    <div class={thumb}></div>
  </Attachment>

  <Attachment placement="top-end">
    <Badge slot="content" size="sm" tone="danger">
      99+
    </Badge>
    <div class={thumb}></div>
  </Attachment>

  <Attachment placement="top-end" shape="circle">
    <Badge slot="content" size="sm" tone="danger">
      New
    </Badge>
    <div class={thumbRound}></div>
  </Attachment>

  <Attachment placement="bottom-end" shape="circle">
    <Badge slot="content" size="sm" tone="success">
      Online
    </Badge>
    <div class={thumbRound}></div>
  </Attachment>
</div>

Stamps

Marketing stamps like NEW, -30%, or a rotated SOLD overlay. A negative offset pulls the stamp inward so it overlaps the card edge.

Wireless Headphones

Premium audio, noise cancelling.

Leather Wallet

Handcrafted, full-grain leather.

Vintage Camera

Rare 1970s film shooter.

---
// GENERATED from packages/demos/attachment/stamps.demo.tsx — edit the source, not this file.
import Attachment from "@pindoba/astro-attachment";
import Stamp from "@pindoba/astro-stamp";
import { Star, Flame, Check } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";

const row = flex({ gap: "2xl", wrap: "wrap" });
const card = css({
  width: "240px",
  background: "neutral.surface.hill",
  border: "1px solid",
  borderColor: "neutral.border.muted",
  borderRadius: "lg",
  padding: "md",
});
const title = css({
  fontSize: "sm",
  fontWeight: "semibold",
  color: "neutral.text.bold",
});
const caption = css({
  marginTop: "2xs",
  color: "neutral.text.muted",
  fontSize: "xs",
});
---

<div class={row}>
  <Attachment placement="top-start">
    <Stamp slot="content" size="xs" emphasis="primary">
      <Star />
    </Stamp>
    <div class={card}>
      <strong class={title}>Wireless Headphones</strong>
      <p class={caption}>Premium audio, noise cancelling.</p>
    </div>
  </Attachment>

  <Attachment placement="top-end">
    <Stamp slot="content" emphasis="secondary">
      <Flame />
    </Stamp>
    <div class={card}>
      <strong class={title}>Leather Wallet</strong>
      <p class={caption}>Handcrafted, full-grain leather.</p>
    </div>
  </Attachment>

  <Attachment placement="top-end">
    <Stamp slot="content" shape="circle" emphasis="primary" tone="success">
      <Check />
    </Stamp>
    <div class={card}>
      <strong class={title}>Vintage Camera</strong>
      <p class={caption}>Rare 1970s film shooter.</p>
    </div>
  </Attachment>
</div>
  • Components
  • Blocks