# Marker

> Quiet notes between content, like date dividers and system events, with sticky dates and times that read as Today or Yesterday.

Docs: https://hextaui.com/docs/marker
Markdown: https://hextaui.com/docs/marker.md

```tsx title="components/examples/marker/demo.tsx"
"use client"

import * as React from "react"
import {
  IconGitMerge,
  IconPencil,
  IconPin,
  IconUserPlus,
} from "@tabler/icons-react"

import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
import {
  Marker,
  MarkerContent,
  MarkerIcon,
  MarkerTime,
} from "@/components/ui/marker"

const day = 24 * 60 * 60 * 1000

export function MarkerDemo() {
  const threadRef = React.useRef<HTMLDivElement>(null)

  React.useLayoutEffect(() => {
    const thread = threadRef.current
    if (thread) {
      thread.scrollTop = thread.scrollHeight
    }
  }, [])

  return (
    <div
      ref={threadRef}
      tabIndex={0}
      role="region"
      aria-label="Conversation"
      className="h-96 w-full max-w-md overflow-y-auto overscroll-none rounded-xl border px-4 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
    >
      <section className="flex flex-col gap-3 pt-4 pb-3">
        <Marker variant="separator" sticky>
          <MarkerContent>
            <MarkerTime date={new Date(Date.now() - 2 * day)} />
          </MarkerContent>
        </Marker>
        <Marker className="justify-center">
          <MarkerIcon>
            <IconUserPlus />
          </MarkerIcon>
          <MarkerContent>Mira added Jun and Sol</MarkerContent>
        </Marker>
        <Bubble variant="secondary">
          <BubbleContent>
            Kicking off the marker component. Dates, events, the works.
          </BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>Can the dates stick while you scroll?</BubbleContent>
        </Bubble>
      </section>
      <section className="flex flex-col gap-3 py-3">
        <Marker variant="separator" sticky>
          <MarkerContent>
            <MarkerTime date={new Date(Date.now() - day)} />
          </MarkerContent>
        </Marker>
        <Marker className="justify-center">
          <MarkerIcon>
            <IconPencil />
          </MarkerIcon>
          <MarkerContent>Jun renamed the thread to “Marker”</MarkerContent>
        </Marker>
        <Bubble variant="secondary">
          <BubbleContent>
            They do now. Scroll up and the date turns into a pill.
          </BubbleContent>
        </Bubble>
        <Marker className="justify-center">
          <MarkerIcon>
            <IconPin />
          </MarkerIcon>
          <MarkerContent>Sol pinned a message</MarkerContent>
        </Marker>
      </section>
      <section className="flex flex-col gap-3 pt-3 pb-4">
        <Marker variant="separator" sticky>
          <MarkerContent>
            <MarkerTime date={new Date()} />
          </MarkerContent>
        </Marker>
        <BubbleGroup>
          <Bubble align="end">
            <BubbleContent>Docs are written.</BubbleContent>
          </Bubble>
          <Bubble align="end">
            <BubbleContent>
              Screenshots look right in dark mode too.
            </BubbleContent>
          </Bubble>
        </BubbleGroup>
        <Marker className="justify-center">
          <MarkerIcon>
            <IconGitMerge className="text-success" />
          </MarkerIcon>
          <MarkerContent>
            Sol merged <a href="#">#482</a> into main
          </MarkerContent>
        </Marker>
        <Marker variant="separator">
          <MarkerContent>New messages</MarkerContent>
        </Marker>
        <Bubble variant="secondary">
          <BubbleContent>Shipping it 🎉</BubbleContent>
        </Bubble>
      </section>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/marker.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/marker.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

const markerVariants = cva(
  "group/marker relative flex min-h-4 w-full min-w-0 items-center gap-2 text-start text-sm text-muted-foreground [&_a:not([data-slot])]:underline [&_a:not([data-slot])]:decoration-foreground/30 [&_a:not([data-slot])]:underline-offset-3 [&_a:not([data-slot])]:hover:text-foreground [&_a:not([data-slot])]:hover:decoration-foreground [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "",
        separator:
          "before:h-(--hairline) before:min-w-4 before:flex-1 before:bg-border before:transition-opacity before:duration-200 after:h-(--hairline) after:min-w-4 after:flex-1 after:bg-border after:transition-opacity after:duration-200 data-stuck:before:opacity-0 data-stuck:after:opacity-0 motion-reduce:before:transition-none motion-reduce:after:transition-none",
        border: "border-b border-border pb-2",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

type MarkerVariant = NonNullable<VariantProps<typeof markerVariants>["variant"]>

function scrollParent(element: HTMLElement) {
  let node = element.parentElement
  while (node && node !== document.body) {
    const { overflowY } = getComputedStyle(node)
    if (/(auto|scroll|overlay)/.test(overflowY)) {
      return node
    }
    node = node.parentElement
  }
  return null
}

function useStuck(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
  const [stuck, setStuck] = React.useState(false)

  React.useEffect(() => {
    const element = ref.current
    if (!enabled || !element || typeof IntersectionObserver === "undefined") {
      setStuck(false)
      return
    }

    const offset = parseFloat(getComputedStyle(element).top) || 0
    const root = scrollParent(element)
    const observer = new IntersectionObserver(
      ([entry]) => {
        const top = entry.rootBounds?.top ?? 0
        setStuck(
          entry.intersectionRatio > 0 &&
            entry.intersectionRatio < 1 &&
            entry.boundingClientRect.top < top + offset + 1
        )
      },
      {
        root,
        rootMargin: `${-(offset + 1)}px 0px 0px 0px`,
        threshold: [0, 1],
      }
    )
    observer.observe(element)
    return () => observer.disconnect()
  }, [ref, enabled])

  return stuck
}

type MarkerProps = useRender.ComponentProps<"div"> & {
  variant?: MarkerVariant
  sticky?: boolean
}

function Marker({
  className,
  variant = "default",
  sticky = false,
  render,
  ref,
  ...props
}: MarkerProps) {
  const markerRef = React.useRef<HTMLDivElement | null>(null)
  const stuck = useStuck(markerRef, sticky)
  const setRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      markerRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  return useRender({
    defaultTagName: "div",
    render,
    ref: setRef,
    props: mergeProps<"div">(
      {
        className: cn(
          markerVariants({ variant }),
          sticky &&
            "sticky top-[calc(var(--marker-sticky-top,--spacing(2))-1px)] z-10",
          className
        ),
      },
      props,
      {
        "data-slot": "marker",
        "data-variant": variant,
        ...(sticky ? { "data-sticky": "" } : {}),
        ...(stuck ? { "data-stuck": "" } : {}),
      } as React.ComponentProps<"div">
    ),
  })
}

function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="marker-icon"
      aria-hidden="true"
      className={cn(
        "inline-flex size-4 shrink-0 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="marker-content"
      className={cn(
        "min-w-0 rounded-4xl text-pretty wrap-break-word transition-[background-color,box-shadow,padding,color] duration-200 ease-out-quint group-data-stuck/marker:bg-popover group-data-stuck/marker:px-2.5 group-data-stuck/marker:py-0.5 group-data-stuck/marker:text-foreground group-data-stuck/marker:shadow-sm group-data-stuck/marker:ring-(length:--hairline) group-data-stuck/marker:ring-foreground/10 group-data-[variant=separator]/marker:flex-initial group-data-[variant=separator]/marker:text-center motion-reduce:transition-none",
        className
      )}
      {...props}
    />
  )
}

const dayMs = 24 * 60 * 60 * 1000

function startOfDay(date: Date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}

function toDate(value: Date | string | number) {
  return value instanceof Date ? value : new Date(value)
}

function capitalize(text: string, locale: Intl.LocalesArgument) {
  return (
    text.charAt(0).toLocaleUpperCase(locale as string | undefined) +
    text.slice(1)
  )
}

function formatDay(
  value: Date,
  now: Date | null,
  locale: Intl.LocalesArgument
) {
  if (Number.isNaN(value.getTime())) {
    return ""
  }
  const sameYear = now ? value.getFullYear() === now.getFullYear() : false
  const absolute = new Intl.DateTimeFormat(locale, {
    month: "short",
    day: "numeric",
    ...(sameYear ? {} : { year: "numeric" }),
  }).format(value)
  if (!now) {
    return absolute
  }
  const days = Math.round(
    (startOfDay(now).getTime() - startOfDay(value).getTime()) / dayMs
  )
  if (days === 0 || days === 1) {
    return capitalize(
      new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
        -days,
        "day"
      ),
      locale
    )
  }
  if (days > 1 && days < 7) {
    return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(value)
  }
  return absolute
}

let midnightListeners = new Set<() => void>()
let midnightTimer: ReturnType<typeof setTimeout> | undefined

function scheduleMidnight() {
  clearTimeout(midnightTimer)
  const now = new Date()
  const next = startOfDay(new Date(now.getTime() + dayMs)).getTime()
  midnightTimer = setTimeout(
    () => {
      for (const notify of midnightListeners) {
        notify()
      }
      scheduleMidnight()
    },
    Math.max(1000, next - now.getTime() + 50)
  )
}

function subscribeToday(notify: () => void) {
  midnightListeners.add(notify)
  if (midnightListeners.size === 1) {
    scheduleMidnight()
  }
  return () => {
    midnightListeners.delete(notify)
    if (midnightListeners.size === 0) {
      clearTimeout(midnightTimer)
      midnightListeners = new Set()
    }
  }
}

function getToday() {
  return startOfDay(new Date()).getTime()
}

function getServerToday() {
  return null
}

type MarkerTimeProps = Omit<React.ComponentProps<"time">, "children"> & {
  date: Date | string | number
  locale?: Intl.LocalesArgument
  format?: (date: Date) => React.ReactNode
}

function MarkerTime({
  className,
  date,
  locale = "en-US",
  format,
  ...props
}: MarkerTimeProps) {
  const today = React.useSyncExternalStore(
    subscribeToday,
    getToday,
    getServerToday
  )
  const value = toDate(date)
  const valid = !Number.isNaN(value.getTime())

  return (
    <time
      data-slot="marker-time"
      dateTime={valid ? value.toISOString() : undefined}
      suppressHydrationWarning
      className={cn("tabular-nums", className)}
      {...props}
    >
      {format
        ? valid
          ? format(value)
          : null
        : formatDay(value, today === null ? null : new Date(today), locale)}
    </time>
  )
}

export { Marker, MarkerContent, MarkerIcon, MarkerTime, markerVariants }
export type { MarkerProps, MarkerTimeProps, MarkerVariant }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Marker,
  MarkerContent,
  MarkerIcon,
  MarkerTime,
} from "@/components/ui/marker"
```

```tsx
<Marker variant="separator" sticky>
  <MarkerContent>
    <MarkerTime date={message.sentAt} />
  </MarkerContent>
</Marker>
```

## Composition

```text
Marker
├── MarkerIcon
└── MarkerContent
    └── MarkerTime
```

## Examples

### Variants

`default` is a quiet note, `separator` centers it between two hairlines, and `border` underlines it like a section heading.

```tsx title="components/examples/marker/variants.tsx"
import { IconInfoCircle } from "@tabler/icons-react"

import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"

export function MarkerVariants() {
  return (
    <div className="flex w-full max-w-md flex-col gap-6">
      <Marker>
        <MarkerIcon>
          <IconInfoCircle />
        </MarkerIcon>
        <MarkerContent>Default: an icon and a short note.</MarkerContent>
      </Marker>
      <Marker variant="separator">
        <MarkerContent>Separator</MarkerContent>
      </Marker>
      <Marker variant="border">
        <MarkerContent>Border: a heading for the section below</MarkerContent>
      </Marker>
    </div>
  )
}
```

### Sticky dates

With `sticky`, a marker stays at the top of its scroll area. Once it sticks, its lines fade and the label turns into a floating pill so it stays readable over messages. Put each day in its own section so the next date pushes the previous one away.

```tsx title="components/examples/marker/sticky.tsx"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Marker, MarkerContent, MarkerTime } from "@/components/ui/marker"

const day = 24 * 60 * 60 * 1000

const days = [
  {
    date: new Date(Date.now() - 3 * day),
    messages: [
      "Kicking off the marker component today.",
      "Shadcn's version is a good base. Let's add sticky dates.",
      "Agreed, chats need them.",
    ],
  },
  {
    date: new Date(Date.now() - day),
    messages: [
      "Sticky dates work. They turn into a pill when they stick.",
      "Nice. Does it respect reduced motion?",
      "Yes, the pill just appears.",
    ],
  },
  {
    date: new Date(),
    messages: ["Docs are up.", "Shipping it.", "🎉"],
  },
]

export function MarkerSticky() {
  return (
    <div
      tabIndex={0}
      role="region"
      aria-label="Activity by day"
      className="h-80 w-full max-w-md overflow-y-auto overscroll-none rounded-xl border px-4 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
    >
      {days.map((group) => (
        <section
          key={group.date.toDateString()}
          className="flex flex-col gap-3 py-3"
        >
          <Marker variant="separator" sticky>
            <MarkerContent>
              <MarkerTime date={group.date} />
            </MarkerContent>
          </Marker>
          {group.messages.map((message, index) => (
            <Bubble
              key={message}
              align={index % 2 === 0 ? "start" : "end"}
              variant={index % 2 === 0 ? "secondary" : "default"}
            >
              <BubbleContent>{message}</BubbleContent>
            </Bubble>
          ))}
        </section>
      ))}
    </div>
  )
}
```

### Relative dates

`<MarkerTime />` renders a `<time>` that reads Today, Yesterday, a weekday within the last week, then a short date. It updates itself at midnight. Pass `format` for your own wording.

```tsx title="components/examples/marker/time.tsx"
"use client"

import { Marker, MarkerContent, MarkerTime } from "@/components/ui/marker"

const day = 24 * 60 * 60 * 1000
const dates = [0, 1, 3, 40, 400].map((ago) => new Date(Date.now() - ago * day))

export function MarkerTimeDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      {dates.map((date) => (
        <Marker key={date.getTime()} variant="separator">
          <MarkerContent>
            <MarkerTime date={date} />
          </MarkerContent>
        </Marker>
      ))}
      <Marker variant="separator">
        <MarkerContent>
          <MarkerTime
            date={dates[0]}
            format={(date) =>
              date.toLocaleTimeString("en-US", {
                hour: "numeric",
                minute: "2-digit",
              })
            }
          />
        </MarkerContent>
      </Marker>
    </div>
  )
}
```

### Activity

Render markers as list items for an activity log. Links inside are underlined until hovered.

```tsx title="components/examples/marker/events.tsx"
import {
  IconGitMerge,
  IconLock,
  IconPencil,
  IconTag,
} from "@tabler/icons-react"

import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"

export function MarkerEvents() {
  return (
    <ol className="flex w-full max-w-md flex-col gap-3">
      <Marker render={<li />}>
        <MarkerIcon>
          <IconTag />
        </MarkerIcon>
        <MarkerContent>
          Jun added the <a href="#">design</a> label
        </MarkerContent>
      </Marker>
      <Marker render={<li />}>
        <MarkerIcon>
          <IconPencil />
        </MarkerIcon>
        <MarkerContent>Mira renamed the pull request</MarkerContent>
      </Marker>
      <Marker render={<li />}>
        <MarkerIcon>
          <IconGitMerge className="text-success" />
        </MarkerIcon>
        <MarkerContent>Sol merged into main</MarkerContent>
      </Marker>
      <Marker render={<li />}>
        <MarkerIcon>
          <IconLock />
        </MarkerIcon>
        <MarkerContent>Conversation locked</MarkerContent>
      </Marker>
    </ol>
  )
}
```

### Long content

Notes wrap and unbroken strings break instead of widening the layout. Separator lines keep a minimum length on both sides.

```tsx title="components/examples/marker/long-content.tsx"
import { IconAlertTriangle } from "@tabler/icons-react"

import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"

export function MarkerLongContent() {
  return (
    <div className="flex w-72 max-w-full flex-col gap-6">
      <Marker>
        <MarkerIcon>
          <IconAlertTriangle className="text-warning" />
        </MarkerIcon>
        <MarkerContent>
          Messages older than 90 days were archived to
          archive-2026-workspace-wide-retention-policy@example.com
        </MarkerContent>
      </Marker>
      <Marker variant="separator">
        <MarkerContent>
          A separator label that is long enough to wrap onto two lines
        </MarkerContent>
      </Marker>
    </div>
  )
}
```

### Right to left

Icons and text follow the reading direction.

```tsx title="components/examples/marker/rtl.tsx"
import { IconUserPlus } from "@tabler/icons-react"

import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"

export function MarkerRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-md flex-col gap-4">
      <Marker variant="separator">
        <MarkerContent>اليوم</MarkerContent>
      </Marker>
      <Marker>
        <MarkerIcon>
          <IconUserPlus />
        </MarkerIcon>
        <MarkerContent>أضافت ميرا جون إلى المحادثة</MarkerContent>
      </Marker>
    </div>
  )
}
```

## Accessibility

- Markers are plain text, so screen readers read them in place. Icons are hidden from them.
- `<MarkerTime />` keeps the exact moment in `dateTime` while showing a friendly label.
- The sticky pill appears without motion when reduced motion is on.

## API reference

### Marker

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "separator" \| "border"` | `"default"` |  |
| `sticky` | `boolean` | `false` | Stick to the top of the scroll area and become a pill while stuck. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="marker"` | Target markers in CSS. |
| `data-variant` | The current variant. |
| `data-sticky` | Present when sticky is on. |
| `data-stuck` | Present while the marker is stuck to the top. |
| `--marker-sticky-top` | Distance from the top while stuck. Defaults to 0.5rem. |

### MarkerContent

| Attribute | Description |
| --- | --- |
| `data-slot="marker-content"` | The text. Becomes the pill while stuck. |

### MarkerIcon

| Attribute | Description |
| --- | --- |
| `data-slot="marker-icon"` | A 16px icon box, hidden from screen readers. |

### MarkerTime

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `Date \| string \| number` | – | Required. Anything new Date() accepts. |
| `locale` | `Intl.LocalesArgument` | `"en-US"` | Fixed by default so the server and browser agree. |
| `format` | `(date: Date) => ReactNode` | – | Replace the relative label with your own. |

| Attribute | Description |
| --- | --- |
| `data-slot="marker-time"` | The time element, with an ISO dateTime. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
