defuss-shadcn / Data Display / indicator
IndicatorMOL
Pins a badge, a status dot or a button to any of nine points of an element - a count on a button, a dot on a card, a "Required" tag on an input. .indicator wraps the element, .indicator-item sits centered on its anchor point, and .indicator-dot is the status dot, with ping, bounce and pulse animations. No JavaScript.
On this page (19)
§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