Theme
On this page (19)
Component Skill — components/indicator/component-skill.md

Native basis

A position: relative wrapper; each item is position: absolute, centered on its anchor point with translate.

Web Platform APIs

translate:dir()prefers-reduced-motion

Classes

.indicator.indicator-item.indicator-dot

Placement (data-position on .indicator-item)

top-start / top-center / top-endTop edge - top-end is the defaultmiddle-start / middle-center / middle-endVertical middlebottom-start / bottom-center / bottom-endBottom edgedata-position-sm / -md / -lg / -xlThe same values from 40 / 48 / 64 / 80rem up

Status dot (.indicator-dot)

data-variantneutral, primary, secondary, accent, success, warning, info, destructivedata-sizexs, sm, md (default), lg, xldata-animateping, bounce, pulse

Accessibility

• A status dot needs role="img" + aria-label

• Put a badge's count in the button's accessible name too

• Animations stop under prefers-reduced-motion: reduce

§Status indicator

A status dot pinned to the top-end corner (the default) of a box.

§Badge as indicator

Any badge becomes an indicator with .indicator-item.

§For a button

An unread count on a button - put the count into the button's accessible name as well.

§For a tab

Wrap the tab trigger: the tablist keeps working, the count rides on the tab.

§For an avatar

A named badge on a profile picture - for presence, the avatar's own .avatar-badge has shaped states.

§For an input

A "Required" tag on a field - the input keeps its own required attribute for the real contract.

§A button as an indicator

Items can be interactive: an Apply button on a job card, pinned to the bottom-end corner. The wrapper is inline-sized - w-full stretches it.

§In the center of an image

data-position="middle-center" lays the item over the middle of the element.

§Placement

Nine anchor points: vertical top / middle / bottom joined with horizontal start / center / end. top-end is the default.

§Multiple indicators

One wrapper, nine items - one per anchor point.

§Responsive

data-position-sm / -md / -lg / -xl take effect from 40 / 48 / 64 / 80rem wide: this badge moves start → center → end → start → end. Try the device sizes in the toolbar.

§Status dots

.indicator-dot is a small colored dot for presence and state - inline next to a label, or pinned as an .indicator-item. Give it role="img" and an aria-label, and pair it with text where there is room: color alone is not a message.

§Status

An inline dot next to its label.

§Status sizes

data-size: xs · sm · md (default) · lg · xl.

§Status colors

neutral, primary, secondary, accent, info, success, warning and destructive (alias error). Secondary and accent take the theme's chart colors (--chart-2, --chart-4) - its --secondary / --accent are pale surfaces; success, warning and info are fixed colors - the theme tokens have no such pairs.

§Ping

data-animate="ping": a ring pulses out of the dot - for something that needs attention now, like a server going down.

§Bounce

data-animate="bounce": the dot hops - unread messages waiting.

§Animated indicators

An animated dot works as an indicator item too - a pulse on a button, a ping on an avatar, a bounce on a card. Under reduced motion every one stands still.

§CSS view file

/* -- Indicator component ------------------------------------------ */
@layer components {
  /* .indicator wraps any element (a button, avatar, card, input, image)
     and pins one or more .indicator-item on it. Each item is CENTERED on an
     anchor point of the wrapper's edge: data-position picks the point -
     vertical top / middle / bottom + horizontal start / center / end
     (default top-end). A single word sets one axis - "start" / "center" /
     "end" horizontal, "top" / "middle" / "bottom" vertical - so "middle-center"
     is the middle of the element. start / end are logical: in RTL, start is
     the right edge. */
  .indicator {
    position: relative;
    display: inline-flex;
    width: max-content;
    max-width: 100%;
  }
  /* the wrapped element fills the wrapper: a no-op at the default
     max-content width, but a wrapper given a width passes it on - a card
     (a size container, no intrinsic width) would otherwise collapse to 0 */
  .indicator > :not(.indicator-item) {
    flex: 1 1 auto;
    min-width: 0;
  }
  .indicator-item {
    /* --_h / --_v: 0 = start / top, 0.5 = center / middle, 1 = end / bottom */
    --_h: 1;
    --_v: 0;
    position: absolute;
    z-index: 1;
    /* --_in: data-inset pulls the item toward the element's center by this
       much per axis (a corner item moves diagonally) - for an icon inside a
       padded button, whose box corner sits well away from the glyph;
       --_dir flips the horizontal pull in RTL */
    --_in: 0px;
    --_dir: 1;
    inset-block-start: calc(var(--_v) * 100%);
    left: calc(var(--_h) * 100%);
    translate:
      calc(-50% + (1 - 2 * var(--_h)) * var(--_in) * var(--_dir))
      calc(-50% + (1 - 2 * var(--_v)) * var(--_in));
    white-space: nowrap;
    &:dir(rtl) { left: calc((1 - var(--_h)) * 100%); --_dir: -1; }
    &[data-inset] { --_in: 0.5rem; }
    &[data-inset="sm"] { --_in: 0.25rem; }
    &[data-inset="lg"] { --_in: 0.75rem; }
    &[data-position^="middle"] { --_v: 0.5; }
    &[data-position^="bottom"] { --_v: 1; }
    &[data-position$="start"] { --_h: 0; }
    &[data-position$="center"] { --_h: 0.5; }
    /* responsive placement (DaisyUI's sm: / md: / lg: / xl:): the same
       values in data-position-sm / -md / -lg / -xl apply from that viewport
       width up (40 / 48 / 64 / 80rem) */
    @media (min-width: 40rem) {
      &[data-position-sm^="top"] { --_v: 0; }
      &[data-position-sm^="middle"] { --_v: 0.5; }
      &[data-position-sm^="bottom"] { --_v: 1; }
      &[data-position-sm$="start"] { --_h: 0; }
      &[data-position-sm$="center"] { --_h: 0.5; }
      &[data-position-sm$="end"] { --_h: 1; }
    }
    @media (min-width: 48rem) {
      &[data-position-md^="top"] { --_v: 0; }
      &[data-position-md^="middle"] { --_v: 0.5; }
      &[data-position-md^="bottom"] { --_v: 1; }
      &[data-position-md$="start"] { --_h: 0; }
      &[data-position-md$="center"] { --_h: 0.5; }
      &[data-position-md$="end"] { --_h: 1; }
    }
    @media (min-width: 64rem) {
      &[data-position-lg^="top"] { --_v: 0; }
      &[data-position-lg^="middle"] { --_v: 0.5; }
      &[data-position-lg^="bottom"] { --_v: 1; }
      &[data-position-lg$="start"] { --_h: 0; }
      &[data-position-lg$="center"] { --_h: 0.5; }
      &[data-position-lg$="end"] { --_h: 1; }
    }
    @media (min-width: 80rem) {
      &[data-position-xl^="top"] { --_v: 0; }
      &[data-position-xl^="middle"] { --_v: 0.5; }
      &[data-position-xl^="bottom"] { --_v: 1; }
      &[data-position-xl$="start"] { --_h: 0; }
      &[data-position-xl$="center"] { --_h: 0.5; }
      &[data-position-xl$="end"] { --_h: 1; }
    }
  }
  /* -- Status dot ----------------------------------------------------
     A small colored dot for presence and state (DaisyUI's status): on its
     own inline, or as an .indicator-item. data-variant colors it,
     data-size sizes it, data-animate="ping" sends a ring pulsing out of it
     (an ::after copy scaling up and fading), "bounce" hops it, "pulse"
     fades it. The dot is currentColor, so a variant is one color line.
     Success / warning / info are literals by design: the tweakcn token set
     has no such pairs (AGENTS.md token rule, like the avatar badge). */
  .indicator-dot {
    position: relative;
    display: inline-block;
    flex-shrink: 0;
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 9999px;
    color: var(--muted-foreground);
    background-color: currentColor;
    vertical-align: middle;
    &[data-variant="neutral"] { color: var(--foreground); }
    &[data-variant="primary"] { color: var(--primary); }
    /* the tweakcn --secondary / --accent are pale SURFACES - too faint for a
       dot: secondary / accent take the theme's vivid chart colors instead */
    &[data-variant="secondary"] { color: var(--chart-2); }
    &[data-variant="accent"] { color: var(--chart-4); }
    &[data-variant="destructive"], &[data-variant="error"] { color: var(--destructive); }
    &[data-variant="success"] { color: #16a34a; }
    &[data-variant="warning"] { color: #f59e0b; }
    &[data-variant="info"] { color: #0ea5e9; }
    &[data-size="xs"] { width: 0.25rem; height: 0.25rem; }
    &[data-size="sm"] { width: 0.375rem; height: 0.375rem; }
    &[data-size="md"] { width: 0.5rem; height: 0.5rem; }
    &[data-size="lg"] { width: 0.75rem; height: 0.75rem; }
    &[data-size="xl"] { width: 1rem; height: 1rem; }
    &[data-animate="ping"]::after {
      content: '';
      position: absolute;
      inset: 0;
      border-radius: inherit;
      background-color: currentColor;
      animation: indicator-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
    }
    &[data-animate="bounce"] { animation: indicator-bounce 1s infinite; }
    &[data-animate="pulse"] { animation: indicator-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
  }
  /* a dot pinned as an indicator stays absolutely positioned (the dot's own
     position: relative comes later in the cascade); a bounce must not fight
     the centering translate, so it moves the pinned dot with margin instead */
  .indicator-item.indicator-dot {
    position: absolute;
    &[data-animate="bounce"] { animation-name: indicator-bounce-margin; }
  }
  @keyframes indicator-ping {
    75%, 100% { scale: 2.25; opacity: 0; }
  }
  @keyframes indicator-bounce {
    0%, 100% { translate: 0 -25%; animation-timing-function: cubic-bezier(0.8, 0, 1, 1); }
    50% { translate: 0 0; animation-timing-function: cubic-bezier(0, 0, 0.2, 1); }
  }
  @keyframes indicator-bounce-margin {
    0%, 100% { margin-top: -0.25em; animation-timing-function: cubic-bezier(0.8, 0, 1, 1); }
    50% { margin-top: 0; animation-timing-function: cubic-bezier(0, 0, 0.2, 1); }
  }
  @keyframes indicator-pulse {
    50% { opacity: 0.5; }
  }
  /* -- Accessibility ------------------------------------------ */
  @media (prefers-reduced-motion: reduce) {
    .indicator-dot[data-animate], .indicator-item.indicator-dot[data-animate] { animation: none; }
    .indicator-dot[data-animate]::after { animation: none; opacity: 0; }
  }
  /* forced colors would repaint the dot with Canvas and erase it: the
     color carries the state, so the dot keeps its own */
  @media (forced-colors: active) {
    .indicator-dot { forced-color-adjust: none; }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub