MDC

For AI

A machine-first reference for @sandlada/mdc. It answers the questions AI coding assistants get asked most: styling components with CSS, re-theming the color scheme, the mdc-fab open / extendedgotchas, default-checked / default-selected, framework setup, and the npm package layout.

1. Customize a component with CSS

Every component is a custom element with a shadow root. Regular CSS selectors stop at the shadow boundary, so content is styled through two layers of CSS custom properties (which inherit into the shadow root) and, where exposed, ::part().

  • Component tokens - --mdc-<component>-*, for one component's internals. Set them on the element (or an ancestor) to override that component only.
  • System tokens - --md-sys-* from @sandlada/material-design-css, shared by all components. Change these to re-theme the whole library (see section 2).
/* Component tokens are public --mdc-<component>-* custom properties.
   Set them on the element (or any ancestor); they inherit into the shadow root. */
mdc-button {
    --mdc-button-enabled-filled-container-color: #6750a4;
    --mdc-button-enabled-filled-label-color: #ffffff;
}

/* Attribute selectors still target the host element. */
mdc-button[variant="outlined"] {
    --mdc-button-enabled-outlined-outline-color: #6750a4;
}

Some components also expose shadow parts for styling internal nodes that never receive a token. For example,mdc-field exposes input, label, container, and others.

/* Some components expose shadow parts for deeper styling. */
mdc-field::part(input) {
    letter-spacing: 0.02em;
}

/* mdc-field also exposes: container, leading-icon, content, prefix, suffix,
   trailing-icon, label, supporting-wrapper, supporting-text, counter. */

2. Change the color scheme systematically

Color is centralized in @sandlada/material-design-css. Import the preset first to establish the--md-sys-color-* contract and the light/dark color-scheme, then import one prebuilt scheme.

/* 1. The system contract (light/dark color-scheme) */
@import '@sandlada/material-design-css/preset.css';

/* 2. A prebuilt Material color scheme (light + dark + contrast in one file) */
@import '@sandlada/material-design-css/prebuilt-colors/tonal-spot/h0-2025.css';

Prebuilt schemes use light-dark(), so a single import covers both modes. Theme and contrast are driven by attributes on :root (<html>): dark, low-contrast, andhigh-contrast.

// preset.css maps the dark attribute on :root to color-scheme.
document.documentElement.toggleAttribute('dark', isDark);

// Contrast: low-contrast | high-contrast on :root.
document.documentElement.toggleAttribute('high-contrast', useHighContrast);

Built-in scheme variants

Schemes live under prebuilt-colors/ and have 9 variants: monochrome, neutral,tonal-spot, vibrant, expressive, rainbow, fruit-salad,content, and fidelity. Filenames encode hue and spec year (h{hue}-{year}.css, e.g. tonal-spot/h0-2025.css); content andfidelity add chroma and tone (h{hue}c{chroma}t{tone}-{year}.css).

Fully custom scheme

To define your own colors instead of using a prebuilt scheme, override the --md-sys-color-* roles on:root. Because every component consumes these semantic roles, overriding them re-colors the whole library at once - that is the systematic approach.

:root {
    /* Override MD3 system color roles once - every component follows. */
    --md-sys-color-primary: #6750a4;
    --md-sys-color-on-primary: #ffffff;
    --md-sys-color-primary-container: #eaddff;
    --md-sys-color-on-primary-container: #21005d;
    --md-sys-color-secondary: #625b71;
    --md-sys-color-surface: #fffbfe;
    --md-sys-color-on-surface: #1c1b1f;
    --md-sys-color-outline: #79747e;
    /* ...the remaining --md-sys-color-* roles */
}

/* Separate dark values when you do not rely on light-dark(). */
:root[dark] {
    --md-sys-color-primary: #d0bcff;
    --md-sys-color-on-primary: #381e72;
    /* ... */
}

Raw tonal data is also available from @sandlada/material-design-css/prebuilt-palettes/… as--md-ref-palette-{primary|secondary|tertiary|error|neutral|neutral-variant}-{tone} if you want to build a scheme by hand.

3. Why is my mdc-fab invisible?

mdc-fab keeps its internal <button> hidden until the open property becomes true. Setting open runs the show animation and adds an internalshow attribute. A FAB does not render until you set open.

<!-- The internal button stays hidden until open is set. -->
<mdc-fab open>
    <mdc-icon slot="icon">add</mdc-icon>
</mdc-fab>

Programmatically, assign the property:

const fab = document.querySelector('mdc-fab');
fab.open = true; // runs the show animation and reveals the button

The lifecycle also emits open / opened and close / closedevents (open and close are cancelable).

Why is the FAB label / <span> not showing?

The label slot is hidden unless extended is also set. The internal rule hides the label unless the button has both extended and a slotted label. Put the icon in<mdc-icon slot="icon"> and the text in a default-slot <span>.

<!-- The label is hidden unless extended is ALSO set. -->
<mdc-fab open extended>
    <mdc-icon slot="icon">add</mdc-icon>
    <span>Label</span>
</mdc-fab>

<!-- trailing-icon puts the icon after the label. -->
<mdc-fab open extended trailing-icon variant="primary" size="large">
    <mdc-icon slot="icon">add</mdc-icon>
    <span>Large primary</span>
</mdc-fab>

4. Configure default-checked / default-selected

default-checked seeds the initial checked state; default-selected seeds theselected state on the switch. Both are plain boolean attributes: include them to start checked, omit them to start unchecked.

<mdc-checkbox default-checked></mdc-checkbox>

<mdc-radio-button name="plan" value="pro" default-checked></mdc-radio-button>

<mdc-switch default-selected></mdc-switch>

<mdc-toggle-button type="checkbox" value="on" default-checked>
    <mdc-icon slot="icon">favorite</mdc-icon>
</mdc-toggle-button>

<mdc-navigation-tab name="nav" value="/home" label="Home" default-checked>
    <mdc-icon slot="icon">home</mdc-icon>
</mdc-navigation-tab>
ComponentAttributeLive property
mdc-checkboxdefault-checkedchecked
mdc-radio-buttondefault-checkedchecked
mdc-toggle-buttondefault-checkedchecked
mdc-toggle-icon-buttondefault-checkedchecked
mdc-navigation-tabdefault-checkedchecked
mdc-switchdefault-selectedselected

5. default-checked vs checked (and default-selected vs selected)

They are two different things, mirroring native HTML. checked / selected is thelive current state; default-checked / default-selected is theinitial / default state.

checked / selecteddefault-checked / default-selected
MeaningCurrent valueInitial / default value
When readAny time; updates reactivelyOnce when the element connects (and when the property flips true)
Reflects to an attributeYes (checked / selected)Yes, as authored
User interaction changes itYesNo
Used by form resetNoYes - reset returns to the default
Native HTML analogueinput.checkedinput.defaultChecked / the checked content attribute

Practical guidance:

  • For an element's initial, declarative state (and correct <form> reset), use default-checked / default-selected.
  • For runtime control and reading the current state, use the .checked / .selected property (or the reflecting checked / selected attribute).
  • Both can appear together: default-checked seeds the state and checked reflects the current value.
  • default-checked / default-selected can only ever mean "true" - there is no default-unchecked variant; omit the attribute for unchecked.
  • The default is applied before form state restoration, so a value restored by the browser can still override the authored default on load.

6. Use MDC in Vue, Angular, and React

MDC ships standard custom elements. Register a component by importing its entry point for side effects, then use the bare <mdc-*> tag in your templates. Frameworks only need to be told to leavemdc-* tags alone.

Vue

Tell the Vue compiler that mdc-* tags are custom elements:

// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        vue({
            template: {
                compilerOptions: {
                    isCustomElement: (tag) => tag.startsWith('mdc-'),
                },
            },
        }),
    ],
});

Then import and use them:

<template>
    <mdc-button variant="filled"><span>Save</span></mdc-button>
</template>

<script setup lang="ts">
import '@sandlada/mdc/components/button/index';
</script>

Angular

Add CUSTOM_ELEMENTS_SCHEMA to the component or module that uses the tags:

import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@sandlada/mdc/components/button/index';

@Component({
    selector: 'app-root',
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    template: `<mdc-button variant="filled"><span>Save</span></mdc-button>`,
})
export class AppComponent {}

Alternatively, for an NgModule-based app:

import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';

@NgModule({
    declarations: [AppComponent],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}

React

React 19 renders custom elements natively. Import the component and use the tag directly:

import '@sandlada/mdc/components/button/index';

export function App() {
    return (
        <mdc-button variant="filled">
            <span>Save</span>
        </mdc-button>
    );
}

For TypeScript, augment JSX.IntrinsicElements so the tags type-check:

// Teach TypeScript's JSX checking about the mdc-* tags.
import type { DetailedHTMLProps, HTMLAttributes } from 'react';

declare module 'react' {
    namespace JSX {
        interface IntrinsicElements {
            'mdc-button': DetailedHTMLProps<
                HTMLAttributes<HTMLElement>,
                HTMLElement
            > & {
                variant?: 'filled' | 'filled-tonal' | 'elevated' | 'outlined' | 'text';
            };
        }
    }
}

On React 18 and older, attributes are passed as strings, so booleans and objects must be set as propertiesthrough a ref, and custom events must be wired with addEventListener:

import { useEffect, useRef } from 'react';
import '@sandlada/mdc/components/switch/index';

export function Toggle() {
    const ref = useRef<HTMLElement>(null);

    useEffect(() => {
        const el = ref.current as any;
        if (!el) return;
        el.selected = true; // booleans/objects: set as properties, not attributes
        const onChange = () => console.log('toggled:', el.selected);
        el.addEventListener('change', onChange); // custom events: addEventListener
        return () => el.removeEventListener('change', onChange);
    }, []);

    return <mdc-switch ref={ref} />;
}

7. The npm package structure

@sandlada/mdc is a pure-ESM package ("type": "module") built with rolldown usingpreserveModules - there is no single bundle; each source module keeps its path underbuild/ with a matching .d.ts.

SubpathResolves toPurpose
@sandlada/mdc/components/<name>/indexbuild/components/<name>/index.jsRegisters <mdc-<name>> as a side effect; exports its class and interface. One entry point per component - tree-shakable.
@sandlada/mdc/utils/<name>build/utils/<name>/index.jsShared utilities and providers, e.g. utils/context-provider.
@sandlada/mdc/*./*Catch-all passthrough for any other file in the package.
// One entry point per component - import only what you use.
import '@sandlada/mdc/components/button/index';
import '@sandlada/mdc/components/icon/index';
import '@sandlada/mdc/components/text-field/index';

// Shared utilities have their own barrels.
import { GlobalMDCContextProvider } from '@sandlada/mdc/utils/context-provider';
  • No top-level barrel. There is no single "import everything" entry; import each component you need individually (the docs app registers them one by one).
  • Runtime dependencies: lit, @lit/context,@floating-ui/dom, and @sandlada/mdk. Styling tokens come from the separate@sandlada/material-design-css package.
  • Published files: build/** plus the license and README only.
  • Shipped components: appbar, badge, bottom-sheet, button, card, carousel, checkbox, chip, date-picker, dialog, divider, dock-layout, draggable-modal, elevation, expressive-progress-indicator, expressive-slider, fab, field, focus-ring, icon, icon-button, list, loading-indicator, navigation-bar, navigation-drawer, navigation-rail, navigation-tab, on-this-page, popup-controller, progress-indicator, radio-button, ripple, scaffold, search, segmented-button, side-sheet, slider, snackbar, split-button, switch, tabs, text-field, time-picker, tooltip, tooltip-box, typography.