Code Monkey home page Code Monkey logo

capacitor-dark-mode's Introduction

capacitor-dark-mode  npm version

This Capacitor 6 plugin is a complete dark mode solution for Ionic web, iOS and Android.

❗️Breaking changes

In order to conform to Ionic 8’s built in dark mode support when importing @ionic/vue/css/palettes/dark.class.css, two changes have been made:

  • When running on Ionic 8+, the default dark mode class is now .ion-palette-dark. If you were using the default .dark class, replace all usages of .dark in your CSS with .ion-palette-dark.

  • The dark mode class is now applied to the html element instead of the body.

In order to conform with the Capacitor 6 listener interface, addAppearanceListener now returns only a Promise and must be awaited.

Motivation

On the web and iOS, dark mode works easily with Ionic because browsers and WKWebView correctly handle the prefers-color-scheme CSS property. On Android, on the other hand, prefers-color-scheme is well and truly broken. I have never seen it work reliably in an Ionic app, even with Capacitor 5 and the Android DayNight theme.

With this plugin, you can easily enable and control dark mode in your app across all platforms, guaranteed! This means that on Android versions prior to 10 (API 29), which is the first version to support system dark mode, you can allow the user to toggle dark mode.

Keep it DRY

If you implement dark mode as a user preference, you cannot rely solely on the CSS prefers-color-scheme query anyway; you have to use a class to indicate whether or not you are in dark mode. Maintaining an identical set of CSS variables for prefers-color-scheme: dark and a dark class selector is error-prone, extra maintenance, and in general violates the DRY principle.

This plugin relies solely on a dark class selector to indicate whether or not you are in dark mode, and manages the dark class for you based on the system dark mode and/or the user preference.

Installation | Configuration | Usage | API

Features

  • Uniform API for enabling and controlling dark mode across all platforms. 👏
  • Automatic dark mode detection (in systems that support dark mode). 👀
  • Support for user dark mode switching. ☀️🌛
  • Support for custom dark mode preference storage. 💾
  • Updates the status bar to match the dark mode, even on Android. 🚀
  • Custom status bar colors on Android. 🌈
  • Register listeners for system dark mode changes. 🔥
  • Extensive documentation. 📚

Installation

In your app:

pnpm add @aparajita/capacitor-dark-mode

Configuration

Once the plugin is installed, you need to:

  • Provide a dark mode in your CSS if using Ionic < 8, or import '@ionic/vue/css/palettes/dark.class.css' in Ionic 8+.
  • Initialize the plugin.

Dark mode CSS

This plugin adds or removes a CSS class to the html element when necessary. By default, the class is .dark on Ionic < 8 and .ion-palette-dark on Ionic 8+, but you can configure it to be whatever you want.

👉🏽 Note: If you are using Tailwind’s dark mode support, set darkMode: 'class' in your Tailwind config file.

It is up to you to configure your CSS to actually implement dark mode when that class is present. A good place to start is the standard Ionic dark mode, which relies on the CSS variables that control Ionic component appearance.

If you have an existing CSS dark theme which relies on prefers-color-scheme, you should remove all @media (prefers-color-scheme: dark) rules and instead use html.dark (or simply .dark) as the dark mode selector. There is NO need to duplicate the dark mode in both a @media (prefers-color-scheme: dark) block and a html.dark block. That’s one of the advantages of using this plugin!

/* Remove all prefers-color-scheme selectors! */
@media (prefers-color-scheme: dark) {
  html {
    --ion-color-primary: #428cff;
    /* ... */
  }
}

/* Replace with this */
html.dark {
  --ion-color-primary: #428cff;
  /* ... */
}

Plugin configuration

If you are using the default dark mode CSS class and you don’t allow the user to manually set light or dark mode — and thus don’t need to store a preference — you are all set! The plugin does all of the hard work for you.

If you are using a dark mode CSS class other than the default, you need to configure the plugin. You will want to do this just before the app is mounted to avoid any visual glitches. For example, if your app uses a dark mode CSS class of .dark-mode, you would configure the plugin like this in a Vue-based Ionic app:

main.ts

const app = createApp(App).use(IonicVue, config).use(router)

router
  .isReady()
  .then(() => {
    // configure() is a synonym for init()
    DarkMode.init({ cssClass: 'dark-mode' })
      .then(() => {
        app.mount('#app')
      })
      .catch(console.error)
  })
  .catch(console.error)

Use the equivalent in a React or Angular-based Ionic app.

👉🏽 Note: Using a custom dark mode class will not work on Ionic 8+ if you are importing @ionic/vue/css/palettes/dark.class.css You must use the default (.ion-palette-dark) in that case.

Custom preference storage

If you want to store the user’s dark mode preference in a custom location (such as localStorage), you must create a getter function that returns the preference and a setter that stores the preference, and pass those functions to the init or configure method.

prefs.ts

import type { DarkModeGetterResult } from '@aparajita/capacitor-dark-mode'
import { DarkModeAppearance } from '@aparajita/capacitor-dark-mode'

const kDarkModePref = 'dark-mode'

export function getAppearancePref(): DarkModeGetterResult {
  return localStorage.getItem(kDarkModePref)
}

export function setAppearancePref(appearance: DarkModeAppearance) {
  localStorage.setItem(kDarkModePref, appearance)
}

main.ts

import { getAppearancePref, setAppearancePref } from './prefs'

router
  .isReady()
  .then(() => {
    DarkMode.init({
      cssClass: 'dark-mode',
      getter: getAppearancePref,
      setter: setAppearancePref,
    })
      .then(() => {
        app.mount('#app')
      })
      .catch(console.error)
  })
  .catch(console.error)

The example above uses a synchronous function, but you may also use an async getter that returns a Promise, so there are no constraints on how or where you store the preference.

Android status bar customization

On Android, there are several additional options you can pass to init()/configure() that control what happens to the status bar when dark mode is toggled.

syncStatusBar
If syncStatusBar is true, the status bar will be updated to match the dark mode. This is the default behavior.

statusBarBackgroundVariable
When syncStatusBar is true, by default the status bar background will set to the value of the --background CSS variable on the ion-content element, which is defined by ion-content as:

ion-content {
  /*
    The stock Ionic theme sets --ion-background-color
    in dork mode.
   */
  --background: var(--ion-background-color, #fff);
}

If you want to use a different color for the status bar, you can set statusBarBackgroundVariable to the name of a different CSS variable. You can then set that variable accordingly in your CSS.

If the value of the variable is not a valid 3 or 6-digit '#'-prefixed hex color, no change is made.

statusBarStyleGetter
When syncStatusBar is true and a valid background color is set, by default the status bar style will be set according to the luminance of the background color:

// Default threshold is 0.5
const statusBarStyle = isDarkColor(color) ? Style.Dark : Style.Light

If you want to use a different style, you can set statusBarStyleGetter to a function that returns the style to use. The function will be called with the current Style (based on the appearance setting, not the background color) and the status bar background color, and should return the Style that the status bar should be set to.

For example, you could use isDarkColor() (which is exported by the plugin) with a different threshold:

import { Style } from '@capacitor/status-bar'

const statusBarStyleGetter = (style?: Style, color?: string) => {
  if (color) {
    const isDark = isDarkColor(color, 0.4)
    return isDark ? Style.Dark : Style.Light
  }

  return style
}

👉🏽 Note: The getter is also called when syncStatusBar is 'textOnly'.

Usage

I could spend a lot of time explaining detailed usage, but perhaps the best explanation is a full example that uses the entire plugin API and shows how to handle user dark mode preference changes. Check out the demo app here. You will especially want to look at prefs.ts and DarkModeDemo.vue.

API

init(...)

init(options?: DarkModeOptions) => Promise<void>

Initializes the plugin and optionally configures the dark mode class and getter used to retrieve the current dark mode state. This should be done BEFORE the app is mounted but AFTER the dom is defined (e.g. at the end of the <body>) to avoid a flash of the wrong mode.

Param Type
options DarkModeOptions

configure(...)

configure(options?: DarkModeOptions) => Promise<void>

A synonym for init.

Param Type
options DarkModeOptions

isDarkMode()

isDarkMode() => Promise<IsDarkModeResult>

web: Returns the result of the prefers-color-scheme: dark media query.

native: Returns whether the system is currently in dark mode.

Returns: Promise<IsDarkModeResult>


setNativeDarkModeListener(...)

setNativeDarkModeListener(options: Record<string, unknown>, callback: DarkModeListener) => Promise<string>
Param Type
options Record<string, unknown>
callback DarkModeListener

Returns: Promise<string>


addAppearanceListener(...)

addAppearanceListener(listener: DarkModeListener) => Promise<DarkModeListenerHandle>

Adds a listener that will be called whenever the system appearance changes, whether or not the system appearance matches your current appearance. The listener is called AFTER the dark mode class and status bar are updated by the plugin. The listener will be called with DarkModeListenerData indicating if the current system appearance is dark.

The returned handle contains a remove function which you should be sure to call when the listener is no longer needed, for example when a component is unmounted (which happens a lot with HMR). Otherwise there will be a memory leak and multiple listeners executing the same function.

Param Type
listener DarkModeListener

Returns: Promise<DarkModeListenerHandle>


update(...)

update(data?: DarkModeListenerData) => Promise<DarkModeAppearance>

Adds or removes the dark mode class on the html element depending on the dark mode state. You do NOT need to call this when the system appearance changes.

If you are manually setting the appearance and you have specified a getter function, you should call this method AFTER the value returned by the configured getter changes.

Returns the current appearance.

Param Type
data DarkModeListenerData

Returns: Promise<DarkModeAppearance>


Interfaces

DarkModeOptions

The options passed to configure.

Prop Type Description
cssClass string The CSS class name to use to toggle dark mode.
getter DarkModeGetter If set, this function will be called to retrieve the current dark mode state instead of isDarkMode. For example, you might want to let the user set dark/light mode manually and store that preference somewhere. If the function wants to signal that no value can be retrieved, it should return null or undefined, in which case isDarkMode will be used.

If you are not providing any storage of the dark mode state, don't pass this in the options.
setter DarkModeSetter If set, this function will be called to set the current dark mode state when update is called. For example, you might want to let the user set dark/light mode manually and store that preference somewhere, such as localStorage.
disableTransitions boolean If true, the plugin will automatically disable all transitions when dark mode is toggled. This is to prevent different elements from switching between light and dark mode at different rates. <ion-item>, for example, by default has a transition on all of its properties.

Set this to false if you want to handle transitions yourself.
syncStatusBar DarkModeSyncStatusBar Android only

If statusBarStyleGetter is set, this option is unused.

If true, on Android the status bar background and content will be synced with the current DarkModeAppearance.

If 'textOnly', on Android only the status bar content will be synced with the current DarkModeAppearance: a light color when the appearance is dark and vice versa.

On iOS this option is not used, the status bar background is synced with dark mode by the system.
statusBarBackgroundVariable string Android only

If set, this CSS variable will be used instead of '--background' to set the status bar background color.
statusBarStyleGetter StatusBarStyleGetter Android only

If set, and syncStatusBar is true, this function will be called to retrieve the current status bar style instead of basing it on the dark mode. If the function wants to signal that no value can be retrieved, it should return a falsey value, in which case the current appearance will be used to determine the style.

IsDarkModeResult

Result returned by isDarkMode.

Prop Type
dark boolean

DarkModeListenerData

Your appearance listener callback will receive this data, indicating whether the system is in dark mode or not.

Prop Type
dark boolean

DarkModeListenerHandle

When you call addAppearanceListener, you get back a handle that you can use to remove the listener. See addAppearanceListener for more details.

Method Signature
remove () => void

Type Aliases

DarkModeGetter

The type of your appearance getter function.

(): DarkModeGetterResult | Promise<DarkModeGetterResult>

DarkModeGetterResult

Your appearance getter function should return (directly or as a Promise) either:

- A DarkModeAppearance to signify that is the appearance you want

- null or undefined to signify the system appearance should be used

DarkModeAppearance | null |

DarkModeSetter

The type of your appearance setter function.

(appearance: DarkModeAppearance): void | Promise<void>

DarkModeSyncStatusBar

Possible values for the syncStatusBar option.

boolean | 'textOnly'

StatusBarStyleGetter

The type of your status bar style getter function.

(style?: Style, backgroundColor?: string): StatusBarStyleGetterResult | Promise<StatusBarStyleGetterResult>

StatusBarStyleGetterResult

Your style getter function should return (directly or as a Promise) either:

- A Style to signify that is the style you want

- null or undefined to signify the default behavior should be used

Style | null |

Record

Construct a type with a set of properties K of type T

{ [P in K]: T; }

DarkModeListener

The type of your appearance listener callback.

(data: DarkModeListenerData): void

Enums

DarkModeAppearance

Members Value
dark 'dark'
light 'light'
system 'system'

Style

Members Value Description
Dark "DARK" Light text for dark backgrounds.
Light "LIGHT" Dark text for light backgrounds.
Default "DEFAULT" The style is based on the device appearance. If the device is using Dark mode, the statusbar text will be light. If the device is using Light mode, the statusbar text will be dark. On Android the default will be the one the app was launched with.

capacitor-dark-mode's People

Contributors

aparajita avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

capacitor-dark-mode's Issues

How to change the splash style

I am still struggling with changing the splash theme persistently, independendly of the system setting. Inside my app, everything if working fine, also auto updating, when changing the system theme.

In my styles.xml I changed the parent themes to the DayNight variants, as also described in the capacitor 4 migration docs. I also added -night folders to change my splash appearance depending to the system theme. This is working good so far. But when I change the theme inside my app, I would like to also change the theme for the splash on the next app start, but unforunately it always takes the system settings. So I have something like a dark splash (system night mode) and then showing up the app in light (app day mode), but the splash should also be light here.

I tried to use AppCompatDelegate.setDefaultNightMode(), but it has no effect, no matter where I call it (i.e. in override OnCreate before or after super call). I also created a capacitor plugin to calling it programmatically anywhere, but it doesnt change anything. But I also read, that calling this method, does not persistently change the behaviour, so it would be needed to call it on every start?! Do you have any clue how to solve this, or maybe also a way to integrate something to this plugin?

Feature Request: Extend syncStatusBar option

Regarding the options 'syncStatusBar' it would be nice to have the ability to skip setting the background color (https://github.com/aparajita/capacitor-dark-mode/blob/main/src/base.ts#L163). In my case I set the status bar background on transparent on app start (StatusBar.setBackgroundColor({ color: '#00000000' }), because I am using my app in fullscreen (true) but this plugin would override the color if I use the option syncStatusBar. So either a new option should be introduced to skip setting the background color and only set the style of the statusbar to change its text color, or the --background should also handle alpha channels.

I would prefer a new option to prevent an overhead of useless 'setBackgroundColor' calls in my case. In other cases the non alpha hex values should be enough. What do you think?

syncStatusBar doesn't handle non-hex css

Problem:

  • I have a gradient specified for ion-content --background, but capacitor Statusbar can't be set to that, it only takes hex colors.
  • I can set the Statusbar to a compatible myself when I set the app theme, but then it won't sync on dark mode switch. Pink statusbar + dark mode app = no love. Really want dark-switch to sync the statusbar too, even in my

maybe an override css key and something like this in src/base.ts line 163ish?:

 if (content) {

        // Suggesting some kind of override read from ion-content or somewhere else
        const overrideBodyBackgroundColor = getComputedStyle(content)
        .getPropertyValue('--statusbarBackground')
        .trim()

        // or fallback to regular --background
        const bodyBackgroundColor = overrideBodyBackgroundColor || getComputedStyle(content)
          .getPropertyValue('--background')
          .trim()

          if (bodyBackgroundColor) {
          if (this.syncStatusBar !== 'textOnly') {
            await StatusBar.setBackgroundColor({
              color: normalizeHexColor(bodyBackgroundColor)
            })
          }

          setStatusBarStyle = true
        }
      }

Would be cool to override the statusbar style maybe (dark/light), but that should maybe be a different issue? It kind of would fit nicely into the same piece of code though. My app theme auto-calculates the text color (black or white, depending on background color lightness), so I have a css key for that. I use something like this:

StatusBar.setStyle({
      style:
        newcss['--textColor'] === '#000000'
          ? Style.Dark
          : Style.Light,
    });

iPhone Control Center switching dark mode does not trig the plugin

iOS only: Control Center / "Widget" switching dark mode does not trig the plugin, and app does not switch mode until killed.

Workarounds:

  1. Either switch from Settings → Display or
  2. Switch in Control center, kill app and restart. It starts in correct dark/light mode.

Attached video shows working live mode switch when switching from Settings→Display, but not when switching with the Control Center widget/switch.

I first suspected a fake/different darkmode switch from Control Center, but when killing+restarting the app it actually does pick up real dark mode.

light-no-controlcenterswitch.mp4

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.