Skip to main contentSkip to docs navigation

Display directional carets on toggles and navigation, and the dropdown indicator on custom form controls.

@layer helpers
Context tokens

.caret draws a directional caret on an element using a CSS mask on a pseudo-element. Reach for it to mark dropdown toggles, collapsible triggers, and navigation items that expand. The caret takes the element's foreground color and trails the content by default.

Default caret Primary caret
HTML
<span class="caret">Default caret</span>
<span class="caret fg-primary">Primary caret</span>

When the $enable-caret setting is on — the default — menu toggles get a caret automatically: every [data-cx-toggle="menu"] element receives one without the .caret class.

Placement

The caret renders on a pseudo-element, and the class chooses which one. .caret draws it on ::after so it trails the content; .caret-before draws it on ::before so it leads. Both apply white-space: nowrap to keep the caret on the same line as the text.

Trailing caret Leading caret
HTML
<span class="caret">Trailing caret</span>
<span class="caret-before">Leading caret</span>

Direction

A caret points down by default. Three modifiers rotate it: .caret-up, .caret-start, and .caret-end. Each sets only the rotation, so pair it with .caret or .caret-before. .caret-start and .caret-end resolve against the writing direction and flip automatically under dir="rtl".

Down Up Start End
HTML
<span class="caret">Down</span>
<span class="caret caret-up">Up</span>
<span class="caret caret-start">Start</span>
<span class="caret caret-end">End</span>

Combine a placement class with a direction modifier to point a leading caret inward — for example, a back control whose caret sits before the label and points to the inline-start.

Back
HTML
<span class="caret-before caret-start">Back</span>

Customization

Caret appearance is controlled through --cx-caret-* custom properties. None are set globally — each falls back to a Sass default baked into the class — so overriding one on an element or any ancestor restyles the carets inside that subtree without re-declaring the class.

Each property maps to one aspect of the caret:

PropertyDefaultControls
--cx-caret-iconDefault caret SVGMask image used for the caret glyph
--cx-caret-size1.25emWidth, height, and mask size
--cx-caret-colorvar(--cx-fg-color)Fill color shown through the mask
--cx-caret-spacing.5em 0 / 0 .5emmargin-inline for trailing / leading carets
--cx-caret-angle0degRotation; the direction modifiers set this
--cx-caret-vertical-align-.225emVertical alignment against the text

Override the properties inline or on a parent to resize or recolor the caret:

Large primary caret
HTML
<span class="caret" style="
  --cx-caret-size: 2em;
  --cx-caret-color: var(--cx-primary);
  --cx-caret-vertical-align: -.5em;
">Large primary caret</span>

Some components set their own caret properties on the element — buttons and chips define --cx-caret-spacing, navs adjust it, and buttons and menus set --cx-caret-size. A value declared on the element wins over an inherited one, so an ancestor override reaches plain .caret instances but not these components, which carry their own caret values.

Form caret

.form-caret adds the caret that select inputs show to any control styled as .form-input. It sits at the inline-end, with trailing padding reserved so it never overlaps the value. Native select.form-input elements receive it automatically; apply .form-caret to custom controls — such as a combobox toggle or a ghost input — that should read as a select.

Choose an option
Choose an option
HTML
<div class="form-input form-caret">Choose an option</div>
<div class="form-input form-caret disabled">Choose an option</div>

The icon dims in the disabled state. Apply disabled (or a native [disabled] / :disabled) and the helper swaps to the muted --cx-form-caret-disabled icon, so a disabled control still signals that it is a dropdown.

The $enable-caret setting gates only the automatic caret. Compiling with $enable-caret: false stops native selects and comboboxes from drawing it, and their validation feedback icons reposition flush to the inline-end. The .form-caret helper stays available either way — apply it to opt any control back into the dropdown caret.

The form caret is a background-image, not a masked pseudo-element — a form input has no ::after to mask — so it cannot be recolored at runtime the way .caret follows --cx-caret-color. Its color is baked into the SVG at build time from the $cx-icon-form-input-select-caret design token, in four copies (idle and disabled, each light and dark) exposed as --cx-form-caret and --cx-form-caret-disabled.

CSS

.caret and .caret-before include the caret() mixin to paint a masked caret on a pseudo-element, and the direction modifiers override its rotation through --cx-caret-angle.

.caret {
  white-space: nowrap;
  @include caret();
}
.caret-before {
  white-space: nowrap;
  @include caret($position: before);
}
.caret-up::after,
.caret-up::before {
  transform: rotate(180deg);
}
.caret-start::after,
.caret-start::before {
  transform: rotate(var(--logical-angle-90, 90deg));
}
.caret-end::after,
.caret-end::before {
  transform: rotate(var(--logical-angle-270, 270deg));
}

Sass variables

Sass variables supply the fallback values compiled into each --cx-caret-* custom property.

// In case --caret-* is not set.
// $caret-icon:               "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentcolor' viewBox='0 0 24 24'><path d='m11.121 16.648-5-5a1.264 1.264 0 0 1-.273-1.367A1.28 1.28 0 0 1 7.02 9.5h10c.508 0 .937.313 1.133.781a1.263 1.263 0 0 1-.274 1.367l-5 5a1.205 1.205 0 0 1-1.758 0Z'/></svg>";
$caret-icon:                "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentcolor' viewBox='0 0 24 24'><path d='m11.121 16.629-7.5-7.5a1.205 1.205 0 0 1 0-1.758 1.205 1.205 0 0 1 1.758 0l6.64 6.602 6.602-6.602a1.205 1.205 0 0 1 1.758 0c.508.469.508 1.29 0 1.758l-7.5 7.5a1.205 1.205 0 0 1-1.758 0Z'/></svg>";
$caret-size:                1.25em;
$caret-spacing:             (after: .5em 0, before: 0 .5em);
$caret-color:               var(--fg-color);
$caret-transition:          transform .2s ease-in-out;
$caret-vertical-align:      $icon-vertical-align;

Sass mixins

The caret() mixin emits the pseudo-element rule, so any custom selector can carry a caret without adding the class to markup. Pass $direction for the rotation and $position for the pseudo-element:

SCSS
.custom-toggle {
  @include caret(start, $position: before);
}
/// Render a directional caret icon using a CSS mask on a pseudo-element.
///
/// Each parameter falls back to a CSS custom property (e.g. `--caret-size`),
/// which in turn falls back to the corresponding Sass variable. Override the
/// custom property on the parent to restyle without re-including the mixin.
///
/// The caret is always drawn on `::after` by default, regardless of direction.
/// Pass `$position: before` for a leading caret (e.g. a `start` back-button).
///
/// @param {String} $direction [down] - Caret direction: `down`, `up`, `end`, or `start`
/// @param {String} $icon [null] - Mask image override; falls back to `var(--caret-icon, svg-icon($caret-icon))`
/// @param {Number|String} $size [null] - Size override; falls back to `var(--caret-size, $caret-size)`
/// @param {Color|String} $color [null] - Color override; falls back to `var(--caret-color, $caret-color)`
/// @param {Number|String} $spacing [null] - `margin-inline` override; falls back to `var(--caret-spacing, …)`
/// @param {String} $position [after] - Pseudo-element to draw on: `after` (default) or `before`
/// @throw If `$direction` is not one of `down`, `up`, `start`, or `end`
/// @throw If `$position` is not `after` or `before`
/// @example scss - Trailing caret (default)
///   .dropdown-toggle {
///     @include caret();
///   }
/// @example scss - Leading caret (start direction, drawn on `::before`)
///   .back-button {
///     @include caret(start, $position: before);
///   }
/// @example scss - Custom icon at compile time
///   .custom-toggle {
///     @include caret($icon: svg-icon($my-custom-icon));
///   }
@mixin caret(
  $direction: down,
  $icon: null,
  $size: null,
  $color: null,
  $spacing: null,
  $position: after
) {
  @if $direction != down and $direction != up and $direction != start and $direction != end {
    @error "caret(): $direction must be `down`, `up`, `start`, or `end`, got `#{$direction}`.";
  }

  @if $position != after and $position != before {
    @error "caret(): $position must be `after` or `before`, got `#{$position}`.";
  }

  $-size:     if(sass($size != null):     $size;      else: var(--caret-size, #{$caret-size}));
  $-icon:     if(sass($icon != null):     $icon;      else: var(--caret-icon, #{svg-icon($caret-icon)}));
  $-color:    if(sass($color != null):    $color;     else: var(--caret-color, #{$caret-color}));
  $-spacing:  if(sass($spacing != null):  $spacing;   else: var(--caret-spacing, #{map.get($caret-spacing, $position)}));
  $-angles:  (
    down:   0deg,
    up:     180deg,
    start:  var(--logical-angle-90, 90deg),
    end:    var(--logical-angle-270, 270deg),
  );

  &::#{$position} {
    display: inline-block;
    flex-shrink: 0;
    width: $-size;
    height: $-size;
    margin-block: auto;
    margin-inline: $-spacing;
    vertical-align: var(--caret-vertical-align, #{$caret-vertical-align});
    content: "";
    background-color: $-color;
    mask-image: $-icon;
    mask-repeat: no-repeat;
    mask-position: center;
    mask-size: $-size;
    transform: rotate(map.get($-angles, $direction));
  }
}

Sass placeholder

.form-caret extends the %form-caret placeholder, which reserves the trailing padding and paints the select caret. Extend it directly to give a custom selector the dropdown caret without the helper class:

SCSS
.custom-select {
  @extend %form-caret;
}

A companion %form-feedback-bg placeholder supplies the caret-less fallback that native selects and comboboxes extend when $enable-caret is off. It keeps the validation feedback background layer without reserving caret space, so feedback icons still render while the caret stays absent.

// Always draws the directional caret, independent of $enable-caret. This is
// the opt-in surface behind the `.form-caret` helper, so the helper stays
// usable even when the automatic caret on selects/comboboxes is disabled.
// $enable-caret only gates the *automatic* @extend on `select.form-input` and
// `.combobox` (see _form-input.scss and _combobox.scss).
%form-caret {
  // Adjust padding to accommodate the caret and potential validation icons.
  padding-inline-end: calc(var(--padding-x) * 2 + var(--caret-size));
  background-image: var(--form-caret), var(--feedback-icon, none);
  background-repeat: no-repeat;
  background-position: $form-select-caret-position;
  background-size: var(--caret-size);

  // Disabled state uses a separate root-level icon baked with the disabled caret color.
  &[disabled],
  &.disabled,
  &:disabled,
  &:has(> :not(option, optgroup):is(.disabled, :disabled)) {
    background-image: var(--form-caret-disabled), var(--feedback-icon, none);
  }
}

// Feedback-only background layer for selects/comboboxes when the caret is
// disabled via `$enable-caret: false`. Draws no caret and reserves no caret
// padding, but still composites the validation feedback icon — selects and
// comboboxes paint it through this background layer. The matching feedback
// layout for this case lives in _validation.scss under the same gate.
%form-feedback-bg {
  background-image: var(--feedback-icon, none);
  background-repeat: no-repeat;
}