Kalendar

Theming#

Every visual decision a Kalendar view makes — colour, text style, corner shape, size, spacing, motion, and the words it speaks to a screen reader — resolves through an ambient set of design tokens. Nothing is hard-coded, and nothing reads your MaterialTheme at render time.

The rule to hold onto:

Kalendar reads Material only to derive default token values. No Material widget renders the calendar. An app with its own design system controls rendering completely, through tokens and slots.

Concretely: the only Material types the view package touches are MaterialTheme, ColorScheme, and Typography, and only inside KalendarThemeDefaults to read default colours and text styles. Every pixel is drawn with Compose Foundation — the text, the icons, the jump picker, the dividers. Replace the default token values and Material is out of the picture entirely.

That means a Material 3 app gets a calendar that matches its colour scheme for free, and a non-Material app can restyle every pixel without fighting a Material dependency it never wanted.

The shape of the API#

It mirrors MaterialTheme deliberately, so it should already feel familiar:

PieceWhat it is
KalendarColors, KalendarTypography, KalendarShapes, KalendarDimensions, KalendarAnimations, KalendarStringsThe six token sets.
KalendarThemeTokensAll six bundled into one value.
KalendarTheme { }The composable that provides tokens to everything inside it. Two overloads: one taking the six sets, one taking a KalendarThemeTokens bundle.
KalendarTheme (the object)The accessors that read the ambient tokens — KalendarTheme.colors, KalendarTheme.dimensions, and so on.
KalendarThemeDefaultsThe built-in token values, and the three palettes.

Wrapping is optional. A view used outside any KalendarTheme falls back to KalendarThemeDefaults, so reading a token is always safe.

Overriding one token#

Each token set is independent, so overriding one leaves the other five alone:

kotlin
import com.himanshoe.kalendar.theme.KalendarTheme
import com.himanshoe.kalendar.theme.KalendarThemeDefaults

KalendarTheme(
    colors = KalendarThemeDefaults.colors().copy(selectionBackground = BrandPurple),
) {
    KalendarMonth(selectedDate = today)
}

KalendarColors, KalendarTypography, and KalendarShapes have no default constructor values — every field is required — so you must start from an existing instance and copy it. The other three are fully defaulted, so you can construct them directly:

kotlin
KalendarTheme(dimensions = KalendarDimensions(hourHeight = 96.dp)) {
    KalendarSchedule(state = rememberKalendarScheduleState(initialDate = today))
}

Warning: constructing a token set directly resets every field you did not name back to the built-in default. Inside a nested KalendarTheme, that silently discards the outer theme's values. To layer on top of whatever is already ambient, copy the accessor instead:

kotlin
KalendarTheme(dimensions = KalendarTheme.dimensions.copy(hourHeight = 96.dp)) {
    KalendarSchedule(state = rememberKalendarScheduleState(initialDate = today))
}

Note: the token classes are @Immutable classes with hand-written members, not data classes. copy(), equals(), hashCode(), and toString() all work exactly as you would expect — which matters, because value equality is what lets Compose skip recomposition when a theme is rebuilt with the same values. Destructuring (val (a, b) = tokens) is the one data-class convenience they do not have.

Reading tokens#

The KalendarTheme object exposes the ambient values, for building custom cells and slots that match the rest of the calendar:

kotlin
@Composable
fun MyDayCell(scope: KalendarDayScope<*>) {
    val colors = KalendarTheme.colors
    val typography = KalendarTheme.typography

    Text(
        text = scope.date.day.toString(),
        color = if (scope.isToday) colors.todayContent else colors.dayContent,
        style = if (scope.isSelected) typography.dayNumberEmphasized else typography.dayNumber,
    )
}
AccessorType
KalendarTheme.colorsKalendarColors
KalendarTheme.typographyKalendarTypography
KalendarTheme.shapesKalendarShapes
KalendarTheme.dimensionsKalendarDimensions
KalendarTheme.animationsKalendarAnimations
KalendarTheme.stringsKalendarStrings
KalendarTheme.tokensKalendarThemeTokens — all six at once

All of them are @Composable getters, and all fall back to KalendarThemeDefaults outside a KalendarTheme.

Colours#

KalendarColors carries fourteen required colours, three derived ones, and four alpha values.

TokenUsed forMaterial defaultlightColors()darkColors()
backgroundFill behind the whole calendarsurface#FFFBFE#1C1B1F
selectionBackgroundThe selected day's fill and the sliding indicatorprimaryContainer#E8DEF8#4F378B
onSelectionBackgroundContent on top of selectionBackgroundonPrimaryContainer#1D192B#EADDFF
todayContentToday's day number when it is not selectedprimary#6750A4#D0BCFF
dayContentOrdinary day numbersonSurface#1C1B1F#E6E1E5
dayLabelContentDay-of-week column labels and hour-gutter labelsonSurfaceVariant#49454F#CAC4D0
headerContentThe navigation header's title and its iconsonSurface#1C1B1F#E6E1E5
headerContentDisabledA header arrow whose direction is blocked by minDate / maxDateheaderContent at 38%
focusIndicatorThe ring around a keyboard-focused day celltodayContent
hoverBackgroundThe wash over a day cell under the pointerdayContent at 8%
eventIndicatorEvent dots and blocks when KalendarEvent.eventColor is nullprimary#6750A4#D0BCFF
nowIndicatorThe current-time line on the Schedule viewserror#B3261E#F2B8B5
gridLineHour lines and separatorsoutlineVariant#CAC4D0#49454F
dragGhostBackgroundThe drop-target ghost while dragging an eventsecondaryContainer#E8DEF8#4A4458
popupBackgroundFill of the month/year jump pickersurfaceContainerHigh#ECE6F0#2B2930
popupBorderOutline around the jump pickeroutlineVariant#CAC4D0#49454F
agendaCardFill of one event row on KalendarAgendasurfaceContainerLow#F7F2FA#26242A

And the four alphas, which are the same in every palette:

TokenDefaultApplied to
disabledContentAlpha0.38fDay cells rejected by KalendarViewConfig.disabledDates, and adjacent-month dates.
eventBlockAlpha0.18fAn event block's fill, over the event's accent colour. The stripe and text stay opaque.
eventBlockDraggingAlpha0.45fAn event block's fill while it is being dragged.
dragGhostAlpha0.6fdragGhostBackground.

Note: the calendar's container fill has two sources. KalendarColors.background is the token; KalendarViewConfig.background is a per-view Brush? that overrides it when non-null. Use the token for a colour, and the config only for something a colour cannot express — a gradient, say.

Apps that use Material 3#

Do nothing. KalendarThemeDefaults.colors() derives from the ambient MaterialTheme.colorScheme, so your brand colours and your light/dark scheme are already applied.

Apps without Material 3#

MaterialTheme.colorScheme falls back to Compose's baseline light scheme when no MaterialTheme is present — which means a calendar in a non-Material app would stay light even in dark mode. Pass one of Kalendar's own Material-free palettes instead:

kotlin
KalendarTheme(colors = KalendarThemeDefaults.systemColors()) {
    KalendarMonth(selectedDate = today)
}
FunctionBehaviour
KalendarThemeDefaults.colors()Derived from the ambient MaterialTheme.colorScheme. The default.
KalendarThemeDefaults.systemColors()Kalendar's own palette, following the system light/dark setting via isSystemInDarkTheme(). No Material dependency.
KalendarThemeDefaults.lightColors()The light palette, chosen explicitly.
KalendarThemeDefaults.darkColors()The dark palette, chosen explicitly.

Use lightColors() / darkColors() when your app drives its own theme switch rather than following the system:

kotlin
KalendarTheme(
    colors = if (myAppIsInDarkMode) {
        KalendarThemeDefaults.darkColors()
    } else {
        KalendarThemeDefaults.lightColors()
    },
) {
    KalendarMonth(selectedDate = today)
}

Or map your own design system onto the tokens directly, cutting the Material link entirely:

kotlin
KalendarTheme(
    colors = KalendarColors(
        background = MyTheme.surface,
        selectionBackground = MyTheme.accentMuted,
        onSelectionBackground = MyTheme.onAccentMuted,
        todayContent = MyTheme.accent,
        dayContent = MyTheme.textPrimary,
        dayLabelContent = MyTheme.textSecondary,
        headerContent = MyTheme.textPrimary,
        eventIndicator = MyTheme.accent,
        nowIndicator = MyTheme.danger,
        gridLine = MyTheme.divider,
        dragGhostBackground = MyTheme.accentMuted,
        popupBackground = MyTheme.surfaceRaised,
        popupBorder = MyTheme.divider,
    ),
) {
    KalendarMonth(selectedDate = today)
}

Every colour is a required constructor parameter, so the compiler tells you when a release adds one rather than letting a new surface silently fall back to something arbitrary.

Dark mode#

There is no dark-mode switch on Kalendar itself. Dark mode is whatever the colours say it is:

  • With KalendarThemeDefaults.colors(), dark mode follows your MaterialTheme.
  • With KalendarThemeDefaults.systemColors(), it follows the OS setting.
  • With lightColors() / darkColors(), it follows whatever you pass.
  • With a hand-built KalendarColors, it follows your own design system.

The other five token sets are mode-independent — sizes, shapes and motion do not change between light and dark — so a single KalendarTheme can swap only colors and leave the rest alone.

Typography#

KalendarTypography covers every piece of text a view draws. Defaults come from MaterialTheme.typography.

TokenUsed forDefault
headerTitleThe navigation header's title, the Timeline's month titles, the jump picker's yeartitleMedium
monthLabelMonth names inside KalendarYear's gridlabelLarge
dayOfWeekLabelThe day-of-week column headerslabelSmall
dayNumberThe day number in an ordinary cellbodyLarge
dayNumberEmphasizedThe day number when the cell is selected or is todaybodyLarge in bold
weekDayNumberThe date number in KalendarScheduleWeek's day-column headerslabelLarge
resourceTitleA lane's name in KalendarResourceView's column headerslabelLarge
eventLabelEvent block titles and all-day chip titleslabelSmall
eventOverflowLabelThe compact +N labellabelSmall at 7.sp
hourLabelThe hour-gutter labels on the Schedule viewslabelSmall
agendaEventTitleAn event's name in KalendarAgenda's rows, and its empty-state messagetitleSmall
agendaEventSubtitleAn event's description and time range in KalendarAgenda's rowsbodySmall

To drop the selected/today emphasis entirely, set dayNumberEmphasized equal to dayNumber:

kotlin
val type = KalendarTheme.typography
KalendarTheme(typography = type.copy(dayNumberEmphasized = type.dayNumber)) {
    KalendarMonth(selectedDate = today)
}

Plugging in a non-Material type scale is the same pattern as colours:

kotlin
KalendarTheme(
    typography = KalendarThemeDefaults.typography().copy(
        headerTitle = MyTheme.type.heading3,
        dayNumber = MyTheme.type.numeric,
    ),
) {
    KalendarMonth(selectedDate = today)
}

Shapes#

TokenUsed forDefault
dayCellThe day cell's selection fill, the sliding selection indicator, and the drag ghostCircleShape
eventIndicatorThe per-event dots below a day numberCircleShape
eventBlockEvent blocks on the Schedule views, and all-day chipsRoundedCornerShape(4.dp)
popupThe month/year jump picker surfaceRoundedCornerShape(12.dp)
agendaCardOne event row on KalendarAgendaRoundedCornerShape(12.dp)

dayCell is one token shared by three separate composables that have to agree visually — the cell, the indicator that animates behind it, and the drag ghost. That is exactly why it lives on the theme rather than on each composable.

kotlin
KalendarTheme(
    shapes = KalendarTheme.shapes.copy(dayCell = RoundedCornerShape(8.dp)),
) {
    KalendarMonth(selectedDate = today)
}

Dimensions#

Every field of KalendarDimensions has a default, so this is the one token set you can construct from scratch as a one-liner.

TokenDefaultControls
dayCellPadding2.dpInset around each day cell, the selection indicator, and the drag ghost.
dayCellAspectRatio1fWidth-to-height ratio of a day cell. Above 1f gives short, wide cells.
eventIndicatorSize4.dpDiameter of one event dot.
eventIndicatorSpacing2.dpGap between adjacent event dots.
eventIndicatorBottomPadding2.dpGap below the event-dot row, between it and the cell's bottom edge.
eventIndicatorStripHeight14.dpHeight reserved for the dot/overflow strip. Fixed, so a cell with events and one without put the day number on the same baseline.
spanBarHeight4.dpThickness of one multi-day event's span bar.
spanBarSpacing2.dpGap below each span bar. One lane of the stack is therefore spanBarHeight + spanBarSpacing tall.
spanBarCornerRadius2.dpCorner radius on a bar's real ends only — the day the event starts and the day it ends. A bar cut by the end of a week stays square.
spanBarEndInset3.dpHow far a bar is pulled back from the cell edge at a real end. 0.dp relies on the corner radius alone.
spanBarMaxLanes2How many span bars may stack in one day cell. 0 disables span bars; must be at least 0 or the constructor throws.
headerPaddingPaddingValues(horizontal = 4.dp, vertical = 8.dp)Padding around the navigation header's contents.
headerIconSlotWidth48.dpWidth reserved on each side of the header title for icons. The title's available width is computed from it.
headerContentSpacing8.dpGap between the header's title and the controls beside it.
hourHeight64.dpVertical space for one hour on the Schedule views.
hourGutterWidth48.dpWidth of the Schedule views' hour-label gutter.
gridLineThickness1.dpThickness of hour lines and separators.
nowIndicatorThickness2.dpThickness of the current-time line.
eventBlockGap1.dpHorizontal gap between side-by-side event blocks.
eventBlockAccentWidth3.dpWidth of an event block's leading accent stripe. 0.dp removes it.
eventBlockPaddingPaddingValues(horizontal = 4.dp, vertical = 2.dp)Padding around an event block's label.
eventBlockResizeHandleHeight12.dpHeight of the grab strip at each edge of a block that starts a resize rather than a move. Narrowed on short blocks so the middle stays grabbable.
allDayRowPaddingPaddingValues(horizontal = 16.dp, vertical = 4.dp)Padding around the all-day chip row.
allDayChipSpacing4.dpGap between all-day chips.
allDayChipPaddingPaddingValues(horizontal = 6.dp, vertical = 2.dp)Padding inside one all-day chip.
resourceColumnMinWidth96.dpNarrowest a KalendarResourceView lane may become before the lanes stop dividing the width evenly and start scrolling horizontally.
resourceHeaderPadding4.dpPadding inside a resource column header.
resourceHeaderAccentHeight2.dpThickness of a lane header's accent underline.
monthSpacing16.dpVertical gap between month grids in KalendarYear.
sectionPaddingPaddingValues(horizontal = 16.dp, vertical = 8.dp)Padding around section titles — the Timeline's month titles and sticky header, KalendarYear's month names, and KalendarAgenda's date headers.
agendaRowPaddingPaddingValues(horizontal = 12.dp, vertical = 10.dp)Padding inside one KalendarAgenda row.
agendaRowSpacing4.dpVertical gap between KalendarAgenda rows.
agendaRowGap12.dpGap between an agenda row's accent dot and its text.
agendaDotSize10.dpDiameter of an agenda row's accent dot.

Two of these are shared across composables for the same reason shapes.dayCell is: dayCellPadding aligns the cell, the sliding indicator, and the drag ghost; hourGutterWidth sizes both the gutter and the header spacer above it.

kotlin
KalendarTheme(
    dimensions = KalendarTheme.dimensions.copy(
        hourHeight = 96.dp,
        eventBlockAccentWidth = 0.dp,
    ),
) {
    KalendarSchedule(state = rememberKalendarScheduleState(initialDate = today))
}

Note: KalendarSchedule, KalendarScheduleWeek and KalendarResourceView also take an hourHeight: Dp parameter, which defaults from KalendarDimensions.hourHeight and overrides it for that one calendar. Use the parameter for a single view, the token for every view. KalendarResourceView does the same with minResourceColumnWidth.

Animations#

TokenDefaultControls
enabledtrueMaster switch. When false, every animation below snaps to its target and pressScale is ignored.
selectionColorSpecspring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)The day cell's selection fill fading in and out.
selectionIndicatorSpecspring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium)The indicator that slides between selected day cells.
pressScale0.92fThe scale a day cell shrinks to while pressed. 1f disables the effect.
pressScaleSpecspring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium)The pressScale animation.
eventIndicatorFadeSpecspring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)The row of event dots fading in and out as a day gains or loses events.
titleTransitionMillis200Total duration of the header and sticky-title fade, split evenly between out and in.
titleFadeOutEasingFastOutLinearInEasingEasing of the half that takes the old title away.
titleFadeInEasingLinearOutSlowInEasingEasing of the half that brings the new title back.

Every spec is written out in full rather than left to spring()'s defaults, so retuning one is a matter of changing a number rather than of first discovering what the default was. Motion under a finger is a spring — interruptible, and carrying its velocity into whatever interrupts it — while motion that only swaps information is a fixed, short, asymmetric fade: quick to leave, unhurried to arrive. pressScaleSpec is the one spec that bounces, because a press is the only motion a finger is actually resting on.

Reduced motion#

Set enabled = false and every transition becomes instant — the correct response to a platform reduced-motion preference, and what screenshot tests want for determinism:

kotlin
KalendarTheme(animations = KalendarAnimations(enabled = false)) {
    KalendarMonth(selectedDate = today)
}

Wire it to the platform preference rather than hard-coding it:

kotlin
KalendarTheme(animations = KalendarAnimations(enabled = !prefersReducedMotion)) {
    KalendarMonth(selectedDate = today)
}

With enabled = false, the selection fill, the press-scale, the event-indicator row, and the header title crossfade all change instantly instead of animating.

To keep motion but soften it, override the individual specs and leave enabled at true:

kotlin
KalendarTheme(
    animations = KalendarTheme.animations.copy(
        pressScale = 1f,
        titleTransitionMillis = 80,
    ),
) {
    KalendarMonth(selectedDate = today)
}

Strings#

KalendarStrings holds every accessibility announcement and short label. It is a token set like any other, so it is provided through KalendarTheme — see Localization for the full field list and how to feed it your app's resources.

kotlin
KalendarTheme(
    strings = KalendarStrings(
        previousPage = stringResource(R.string.previous),
        nextPage = stringResource(R.string.next),
        today = stringResource(R.string.today),
    ),
) {
    KalendarMonth(selectedDate = today)
}

Bundling a look with KalendarThemeTokens#

KalendarThemeTokens bundles all six sets into one value, so a whole look can be built once, passed around, and copied:

kotlin
@Composable
fun rememberBrandKalendarTokens(): KalendarThemeTokens =
    KalendarTheme.tokens.copy(
        colors = KalendarThemeDefaults.systemColors().copy(selectionBackground = BrandPurple),
        shapes = KalendarTheme.shapes.copy(dayCell = RoundedCornerShape(8.dp)),
        animations = KalendarAnimations(enabled = false),
    )

Apply it with the tokens overload, which takes the bundle directly:

kotlin
KalendarTheme(tokens = rememberBrandKalendarTokens()) {
    KalendarMonth(selectedDate = today)
}

Overriding one calendar#

KalendarTheme(tokens = …) is also how you restyle a single calendar without dressing its surroundings. Where the six-parameter overload is for an app-wide look, this one is for "this one calendar is different": read the ambient bundle from KalendarTheme.tokens, copy only what changes, and everything you did not name keeps coming from the theme above.

kotlin
KalendarTheme(colors = appCalendarColors) {
    KalendarMonth(selectedDate = today)

    // Compact, but still in the app's colours.
    KalendarTheme(
        tokens = KalendarTheme.tokens.copy(
            dimensions = KalendarDimensions(hourHeight = 32.dp),
        ),
    ) {
        KalendarSchedule(state = rememberKalendarScheduleState(initialDate = today))
    }
}

The override is scoped to the content lambda, so it wins over the enclosing theme and is invisible to siblings. Nesting composes in the obvious direction: the innermost copy wins, and each level above supplies whatever that level did not touch.

tokens is nullable, and null means inherit the surrounding theme untouched — the ambient bundle is passed through by identity, so overriding nothing costs nothing. That is what makes it the right type for a theme-style parameter on a screen of your own:

kotlin
@Composable
fun BookingScreen(
    bookings: List<Booking>,
    theme: KalendarThemeTokens? = null,
) {
    KalendarTheme(tokens = theme) {
        KalendarMonth(selectedDate = today, events = bookings)
    }
}

Note: the tokens are provided unconditionally, even for a null override. Wrapping conditionally would give the content a different group shape in the two cases, so a caller toggling an override on and off would discard the calendar's remembered state along with it.

Which overload to reach for#

KalendarTheme(colors = …, shapes = …) { }Stating a look from its parts. Each parameter defaults to the ambient value, so it nests too.
KalendarTheme(tokens = …) { }Applying a bundle you already hold, or overriding one calendar from KalendarTheme.tokens.copy(…).

Both provide the same six token sets and nest the same way; tokens simply skips the step of taking a bundle apart into six arguments so the composable can put it back together.

Setting a theme once, app-wide#

Provide the theme high in your tree, next to your own:

kotlin
@Composable
fun AppTheme(content: @Composable () -> Unit) {
    MaterialTheme(colorScheme = myColorScheme) {
        KalendarTheme(
            shapes = KalendarTheme.shapes.copy(dayCell = RoundedCornerShape(8.dp)),
            dimensions = KalendarTheme.dimensions.copy(dayCellPadding = 4.dp),
            content = content,
        )
    }
}

Because colors and typography are left at their defaults here, they still track the MaterialTheme around them — including its dark-mode switch.

Where to go next#