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 buttonThe 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>| Component | Attribute | Live property |
|---|---|---|
mdc-checkbox | default-checked | checked |
mdc-radio-button | default-checked | checked |
mdc-toggle-button | default-checked | checked |
mdc-toggle-icon-button | default-checked | checked |
mdc-navigation-tab | default-checked | checked |
mdc-switch | default-selected | selected |
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 / selected | default-checked / default-selected | |
|---|---|---|
| Meaning | Current value | Initial / default value |
| When read | Any time; updates reactively | Once when the element connects (and when the property flips true) |
| Reflects to an attribute | Yes (checked / selected) | Yes, as authored |
| User interaction changes it | Yes | No |
| Used by form reset | No | Yes - reset returns to the default |
| Native HTML analogue | input.checked | input.defaultChecked / the checked content attribute |
Practical guidance:
- For an element's initial, declarative state (and correct
<form>reset), usedefault-checked/default-selected. - For runtime control and reading the current state, use the
.checked/.selectedproperty (or the reflectingchecked/selectedattribute). - Both can appear together:
default-checkedseeds the state andcheckedreflects the current value. default-checked/default-selectedcan 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.
| Subpath | Resolves to | Purpose |
|---|---|---|
@sandlada/mdc/components/<name>/index | build/components/<name>/index.js | Registers <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.js | Shared 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-csspackage. - 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.