Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change body class via <svelte:body /> #3105

Open
PaulMaly opened this issue Jun 25, 2019 · 76 comments
Open

Change body class via <svelte:body /> #3105

PaulMaly opened this issue Jun 25, 2019 · 76 comments
Labels
feature request popular more than 20 upthumbs

Comments

@PaulMaly
Copy link
Contributor

It's just an idea, but it'll be very convenient if we'll able to switch classes on body element like this:

<svelte:body class:profile={isProfilePage} />
@Conduitry
Copy link
Member

I've considered this before - what should happen with this when compiling for SSR? There's nowhere in the current .render() response where we could say what classes are on <body>.

@PaulMaly
Copy link
Contributor Author

PaulMaly commented Jun 25, 2019

Seems, we already have something similar with head:

const { head, html, css, body } = App.render({ ... });

And can be used in Sapper like this:

<head>
  ...
  %sapper.head%
</head>
<body %sapper.body%>

</body>

I really believe the body class for pages is a common case for many apps.

@jorgegorka
Copy link

In case someone wants to change the class of the body it's very easy.

Add this to the script part of your main layout file:

document.body.classList.add('my-class')

Then in a global css file like public/global.css add the styles for .my-class

@PaulMaly
Copy link
Contributor Author

@jorgegorka Sorry, but everyone knows that. Here we're talking about a universal (client and server) and declarative way to set body classes. The way you described won't work with SSR and not declarative.

@nsivertsen
Copy link

The same kind of thing would be very useful for setting attributes on html, such as lang. react-helmet handles this by returning htmlAttributes and bodyAttributes when calling helmet.renderStatic()[link]. Maybe a similar approach would make sense here?

@ramiroaisen
Copy link

ramiroaisen commented Sep 2, 2019

vote positive on <svelte:body class:name={confition} is usefull

@Chaciej
Copy link

Chaciej commented Oct 9, 2019

+1 It would be great if we could set it like this, from the special component and with SSR.

@CanRau
Copy link

CanRau commented Oct 10, 2019

+1 for

<svelte:body class:name={confition}>

as well as

<svelte:html lang={lang}>

@hgl
Copy link

hgl commented Oct 12, 2019

If you want to change the whole background without having the content being taller than the fold, being able to change body class is pretty vital.

Is there currently a workaround?

If I'm not wrong, using document.body.classList won't work for SSR.

I want to give body different classes based on which page is active.

@hgl
Copy link

hgl commented Oct 21, 2019

BTW, I think this feature might somewhat relate to sveltejs/sapper#374.

That issue is basically asking a way for svelte to render directly at the html/body level, instead of a child node, and that probably affects how components can manipulate {body, html} {classes,attributes}

@3tmaan
Copy link

3tmaan commented Feb 10, 2020

I don't know what is the status of <svelte:body class:name={condition} implementation, but in the meantime, I've managed to overpass this issue by using <svelte:head> instead. As my need was to add styling to my page's body.

<svelte:head>
   {#if isModal}
      <style>
         body {
            overflow: hidden;
         }
      </style>
   {/if}
</svelte:head>	

@techa
Copy link

techa commented Feb 26, 2020

Simple theme chooser.

<script>
  const themes = ['', 'dark-theme', 'light-theme']
  const icons = ['🌙', '🌞', '⭐']
  let index = 0
</script>

<button on:click={() => { index = (index + 1) % themes.length }}>{icons[index]}</button>

<svelte:body class={themes[index]}/>

<svelte:head>
  <style>
    /* CSS file */
    :root {
      --background-color: #a19585;
      --text-color: #f7f7f7;
      --primal-color: #30a5a7;
    }

    .light-theme {
      --background-color: #fffaf4;
      --text-color: #201f20;
    }

    .dark-theme {
      --background-color: #201f20;
      --text-color: #fffaf4;
    }

    body {
      background-color: var(--background-color);
      color: var(--text-color);
    }
  </style>
</svelte:head>

@PaulMaly
Copy link
Contributor Author

PaulMaly commented Mar 6, 2020

@Conduitry @Rich-Harris Any comments here? Seems this proposal is very popular.

@ghost

This comment has been minimized.

@sveltejs sveltejs deleted a comment from dalisoft Jul 3, 2020
@AliBasicCoder
Copy link

i have the same issue

@dalisoft

This comment has been minimized.

@niklasgrewe
Copy link

i also find that dynamic class assignments to the body element are really needed, especially in the sapper environment with SSR

@fran1990Web
Copy link

where is the solution? Someone knows?

@neurocmd
Copy link

neurocmd commented Sep 5, 2020

I want this feature alongside <svelte:html /> . On server side it might look like this:

const { head, html, css, htmlAttrs, bodyAttrs } = App.render({ ... })

Sapper template:

<html %sapper.htmlAttrs%>
<head>
  ...
  %sapper.head%
</head>
<body %sapper.bodyAttrs%>
  ...
</body>
</html>

@sharpcodepro

This comment has been minimized.

@anito
Copy link

anito commented Sep 29, 2020

+1
In the meantime in:
_layout.svelte

<script>
import { stores } from '@sapper/app';
import { onMount } from 'svelte';

const { session } = stores();
let root;

onMount( () => {
    root = document.documentElement;
}
$: root && (hasUser => root.classList.toggle('loggedin', hasUser))(!!$session.user);
</script>

@handfulofcats

This comment has been minimized.

@kwiat1990
Copy link

Well I think it would be very helpful to be able to change a class or any other attribute on body simply by using <svetle:body>. I was a little bit surprised that it isn't currently supported.

@pngwn pngwn added popular more than 20 upthumbs feature request and removed proposal labels Jun 26, 2021
@iolyd
Copy link

iolyd commented Nov 24, 2022

Another possible workaround for SSR would be to leverage the server handle hook's transformPageChunk to replace a placeholder string with a cookie-stored value:

<!-- app.html -->

<html lang="%lang%" class="%theme%">
  <head>
    ...
    %sveltekit.head%
  </head>
  <body>
    %sveltekit.body%
  </body>
</html>
// hooks.server.ts

export const handle: Handle = async ({ event, resolve }) => {
  ...
  const theme = event.cookies.get(Cookie.Theme) ?? ThemeClass.light;
  const res = await resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%theme%', theme),
  });
  return res;
};

And then manage the client's theme using a store that updates the cookie and the html element's class accordingly:

// theme.ts

import jscookie form 'js-cookie';

const COOKIE_LIFETIME = 60 * 60 * 24 * 365;

function updateThemeCookie(theme: string | undefined) {
  if (theme) {
    jscookie.set(Cookie.Theme, theme, { path: '/', expires: Date.now() + COOKIE_LIFETIME });
  } else {
    jscookie.remove(Cookie.Theme, { path: '/' });
  }
}

export const documentTheme = (function () {
  const doc: HTMLElement | null = browser ? document.querySelector(':root') : null;
  const init = [...(doc?.classList?.values() ?? [])]
    .reverse()
    .find((c) => Object.values(ThemeClass).includes(c));
  if (init && browser) {
    updateThemeCookie(init);
  }

  const { subscribe, update } = writable<string | undefined>(init);

  function set(theme: string, cookie: boolean = true) {
    update((prev) => {
      if (doc) {
        doc.className = doc.className.replace(themeClassPattern, '');
        if (theme) {
          doc.classList.add(theme);
        }
        if (cookie) {
          updateThemeCookie(theme);
        }
      }
      return theme;
    });
  }

  function reset(cookie: boolean = false) {
    set(init, cookie);
  }

  return {
    subscribe,
    reset,
    set,
  };
})();

@hartwm
Copy link

hartwm commented Dec 6, 2022

<svelte:body style:--something={whatever} />

This is what I was looking for. Clearly many common use cases for changing body class or variables. This needs to be solved in core

@mathg
Copy link

mathg commented Jan 6, 2023

Would really wish this would be implemented, but in the meantime, I used something similar to what @vegardlarsen has implemented.

This is my utility function

export const bodyClass = (node: HTMLBodyElement, className: string) => {
  node.classList.add(className);

  return {
    destroy() {
      node.classList.remove(className);
    }
  }
}

This is called directly in the +page.svelte.

<svelte:body use:bodyClass={'classname-to-toggle'} />

@cyborgdennett
Copy link

<!-- app.html -->

<html lang="%lang%" class="%theme%">
  ...
</html>

how do you import the class here? I have 2 css files, one for darkmode one for lightmode, currently I import 2 stylesheets. how do you transform stylesheet to html class?

@caleb531
Copy link

caleb531 commented Feb 2, 2023

Chiming in here with my particular use case: I have a similar need, but with data-* attributes instead of CSS classes. Namely, I'd like to do something like the following (in my SvelteKit app):

<!-- +layout.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
</script>

<svelte:body data-page-id={$page.data.id} />

So I think if a solution to this issue is implemented, it should work for any attribute that can be applied to the <body>, not just class.

@rendall
Copy link

rendall commented Feb 7, 2023

Even a non-dynamic <svelte:body class="foo"> would be useful.

@RomanistHere
Copy link

RomanistHere commented Feb 7, 2023

Event a non-dynamic <svelte:body class="foo"> would be useful.

image
src/app.html, works with tailwind

@rendall
Copy link

rendall commented Feb 7, 2023

Thanks for trying to help, but that's not useful for this use case. What is needed is a server-side method to put a class attribute on the <body> tag of nested pages. There does not appear to be any way to do this in Svelte.

@boian-ivanov
Copy link

boian-ivanov commented Feb 10, 2023

I'm not against adding this potentially great feature, but as it was already mentioned once, it seems that you can use the transformPageChunk method to do a similar thing. In my example I have the following in the hooks.server.ts

const response = await resolve(event, {
  transformPageChunk: ({ html }) => html.replace('%lang%', locale), // assume locale is 'en'
});

The resolve method is passed on the handle request and from there you can mutate your base HTML template. In the above case that lang exists on the main tag like so <html lang="%lang%">.
You can emulate the same behaviour for the other tags as well, such as <body class="%bodyClass%"> and so on.
It'd be great for the aforementioned features to be added, but there is a solution working currently.

@FlippingBinary
Copy link

It'd be great for the aforementioned features to be added, but there is a solution working currently.

I don't think anyone is disputing the existence of work-arounds, but it would still be nice to get rid of all that boilerplate that is currently required for something that is extremely common. Your example only shows a small part of what is required to get a functional dark theme switcher to work. You also have to force a full refresh from the server after changing the value or dynamically modify the page in-browser while handling all edge cases and being careful not to disrupt the rest of the state. @iolyd gave a more complete example of what it takes.

It is a great strategy. No doubt about that. But all that boilerplate could be replaced with something as simple as <svelte:html class={getThemeClassName()} /> if Svelte managed the attributes on <html /> and <body /> through the <svelte:tagname /> construct.

@iolyd
Copy link

iolyd commented Feb 10, 2023

I'm siding with @FlippingBinary and the voices claiming handling body or html attributes shouldn't require verbose solutions like mine. They put the burden on devs of making sure things stay in sync across client and server states, and are more prone to dom manipulation errors or race conditions, concerns I feel meta-frameworks are specifically expected to take care of.

Nevertheless, for posterity on my example, here's an updated / cleaned up implementation using a data attribute to foresee a cleaner handling in case I were to use this approach for multiple attributes. Feel free to adapt it to your use case:

<!--
  @component
  # Root Theme
  This singleton component manages the theme class applied to the `:root` element of the app.html.
  The theme can be updated by the client using the global theme store.
  Percolation of theme updates to the theme cookie can also be enabled/disabled for each use cases.
 -->
<script lang="ts" context="module">
  import { browser } from '$app/environment';
  import { COOKIES } from '$utils/enums';
  import { THEMES, type ThemeName } from '$utils/themes';
  import type { RequestEvent } from '@sveltejs/kit';
  import jscookie from 'js-cookie';
  import { onDestroy, onMount } from 'svelte';
  import { writable } from 'svelte/store';
  /**
   * Use this theme to initialize the first SSR result inside hooks.
   */
  export let defaultTheme: ThemeName = THEMES.light;
  const COOKIE_LIFETIME = 60 * 60 * 24 * 365;
  const ROOT = browser ? document.documentElement : undefined;
  function setUserTheme(name: ThemeName | null) {
    if (browser) {
      if (name) {
        jscookie.set(COOKIES.THEME, name, {
          path: '/',
          expires: Date.now() + COOKIE_LIFETIME,
        });
      } else {
        jscookie.remove(COOKIES.THEME, { path: '/' });
      }
    }
  }
  export function getUserTheme(event?: RequestEvent) {
    const cookie = event
      ? event.cookies.get(COOKIES.THEME)
      : browser
      ? jscookie.get(COOKIES.THEME)
      : undefined;
    if (!cookie || !(THEMES as any)[cookie]) {
      return defaultTheme;
    }
    return cookie as ThemeName;
  }
  const init = getUserTheme();
  export const rootTheme = (function () {
    const { subscribe, set: _set } = writable(init);
    function set(name: ThemeName, setCookie: boolean = false) {
      if (ROOT) {
        ROOT.setAttribute('data-theme', name);
      }
      if (setCookie) {
        setUserTheme(name);
      }
      _set(name);
    }
    function reset(setCookie: boolean = false) {
      set(init, setCookie);
    }
    return {
      subscribe,
      reset,
      set,
    };
  })();
</script>

<script lang="ts">
  /**
   * Set a root theme and reset to default theme following the lifecycle this component's instance.
   */
  export let theme: ThemeName;
  onMount(() => {
    rootTheme.set(theme);
  });
  onDestroy(() => {
    rootTheme.reset();
  });
</script>
// hooks/server.ts

export const handle: Handle = async ({ event, resolve }) => {
  // ...
  const theme = THEMES[getUserTheme(event)];
  const res = await resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%app.theme%', theme),
  });
  return res;
};

@cyborgdennett My themes are generated as a series of [data-theme=...] definitions from a custom vite plugin I put together to parse a given js theme into css. You can see it here: https://github.com/CUPUM/nplex/blob/dev/frontend/src/plugins/themes/index.ts

It generates a simple stylesheet like so:

[data-theme='light'] {
  --color-bg-100: hsl(55, 20%, 97%);
  ...
  --rgb-bg-100: 249, 249, 246;
  ...
}

[data-theme='dark'] {
  ...
}

After that, its just a question of importing that stylesheet in your root layout.svelte and things should work as expected.

@barvian
Copy link

barvian commented Feb 22, 2023

I made a small SvelteKit library to address this and add a make-shift svelte:html, all with SSR support. Here's a code sample:

<ska:html lang="en" on:keyup={onHtmlKeyup} />
<svelte:body class:dark={darkModeEnabled} />

Not as good as first-class support in Svelte core obviously, but it should help.

@mmccoy
Copy link

mmccoy commented Mar 8, 2023

@barvian thanks for the library, really hoping this gets considered for core sveltekit.

@eslym
Copy link

eslym commented Mar 31, 2023

it would nice to have this feature however there is some issue might need to discuss before even start implementing it

<!-- Overlay.svelte -->
<script lang="ts">
    export let show = false;
</script>
<svelte:body class:overlay={show} />
<slot/>
<style>
  :global(body.overlay) {
    overflow: none !important;
  }
</style>
<!-- App.svelte -->
<script lang="ts">
    import Overlay from './Overlay.svelte';
</script>
<Overlay show={true}/>
<Overlay show={false}/>

we can see a clear conflict in this case

@FlippingBinary
Copy link

@eslym that looks to me like it should be handled the same as duplicate CSS rules. The last one wins. However, maybe both could be output as CSS by the bundler in the order they appear so the browser can make that decision.

Likewise, if a nested component and its parent both set a body property, both rules could be included in the output CSS so the browser can decide. The order they appear should be based on the order they naturally would be processed by the Svelte compiler so this contradictory use-case does not add much processing overhead. The documentation could note that the order in which duplicate rules are output and which rule takes precedence is not guaranteed.

@wistrix
Copy link

wistrix commented Apr 26, 2023

Little workaround I used, might be useful.

<script>
  let body;
  let darkMode = false;

  const bindBody = (node) => (body = node);
  
  const setDarkClasses = (...classes) => {
    if (!body) return;

    if (darkMode) {
      body.classList.add(...classes);
    } else {
      body.classList.remove(...classes);
    }
};

  $: {
    darkMode;
    setDarkClasses('class1', 'class2', 'class3');
  }
</script>

<svelte:body use:bindBody />

<button on:click={() => (darkMode = !darkMode)}>Dark Mode</button>

@Stephen10121
Copy link

@wistrix This is a workaround but would result in the page flickering due to this being rendered in the client side. We are looking for something that's ssr friendly.

@ghostdevv
Copy link
Member

I have a svelte-body utility library for this purpose - hopefully we can get this in svelte soon though!

@Stephen10121
Copy link

Stephen10121 commented Jun 5, 2023

does it add the class name when the page is rendering in the backend?

@iolyd
Copy link

iolyd commented Jun 5, 2023

does it add the class name when the page is rendering in the backend?

Looking at the package shared by @ghostdevv, no this does not apply to SSR (it's an action-based approach and actions are browser-only since they bind to DOM nodes)

SSR-friendly apparoaches need to provide some handle server hook to parse and replace some value passed by clients (either through cookies or fetch-request bodies)

Look into @barvian's package, its the most complete and cleanest solution for now.

@kosei28
Copy link

kosei28 commented Jun 22, 2023

I made a library svelte-attr that can dynamically change the attributes of html and body tags that also works with SSR.

<HtmlAttr lang="en" />
<BodyAttr data-theme="dark" />

This library does not support the class:name directive, but it should not be a big problem without it.

@PaulMaly
Copy link
Contributor Author

Interesting implementation, but basically it’s not equivalent solution, because your approach is mostly client-side and actual attributes doesn’t applied to the body/html tags during SSR. Anyway, this issue doesn’t have user-land solution and should be built-in to the Svelte itself.

@Stephen10121
Copy link

My problem was that I needed to set the theme colors in the backend to prevent that annoying flicker.
Then I realized that svelte provides a :global() attribute in when working with styles. You can access the body with this attribute even during SSR.

So, in theory you can set the body variable and other styles during the SSR phase by using the :global() attribute.

To implement this solution, I decided to set the classname of an html div element (the class of the div gets set in the backend rendering phase.)

<script lang="ts">
    export let data;

    let theme = data.theme;  // "lightTheme" | "darkTheme" | "systemTheme"
</script>

<div id="themeSetter" class="{theme}" />

Then, in the style section, I use the css :has() attribute, also the :global() attribute that svelte provides to set the body styles and variables.

<style>
    :global(body):has(#themeSetter.lightTheme) {
        --text: #131615;
        --background: #f6f9f7;
        --primary: #59ab7e;
        --secondary: #97d8b5;
        --accent: #69d89d;
    }
</style>

You can go even more in depth:

<style>
    @media (prefers-color-scheme: light) {
        :global(body):has(#themeSetter.systemTheme) {
            --text: #131615;
            --background: #f6f9f7;
            --primary: #59ab7e;
            --secondary: #97d8b5;
            --accent: #69d89d;
        }
    }

    @media (prefers-color-scheme: dark) {
        :global(body):has(#themeSetter.systemTheme) {
            --text: #e9eceb;
            --background: #060907;
            --primary: #54a679;
            --secondary: #276845;
            --accent: #27965b;
        }
    }

    :global(body):has(#themeSetter.darkTheme) {
        --text: #e9eceb;
        --background: #060907;
        --primary: #54a679;
        --secondary: #276845;
        --accent: #27965b;
    }

    :global(body):has(#themeSetter.lightTheme) {
        --text: #131615;
        --background: #f6f9f7;
        --primary: #59ab7e;
        --secondary: #97d8b5;
        --accent: #69d89d;
    }
</style>

This solution worked for me. The :has() attribute seems to be supported by all the major browsers. The flicker no longer exists, and no javascript is required in the frontend to set the color variables because everything is happening in the backend.

@Stephen10121
Copy link

Stephen10121 commented Dec 24, 2023

The only problem is that you're not setting the class name in the body tag. But by setting the class of an html div element and using the :global() and :has() attributes, you are essentially setting the classname for the body.

Also you can change the classname in the frontend without any reloads.

<script lang="ts">
    export let data;

    let theme = data.theme;

    function themeChange(event) {
        document.cookie = `theme=${event.target.value}; expires=Thu, 18 Dec 2030 12:00:00 UTC`;
        theme = event.target.value;
    }
</script>

<div id="themeSetter" class="{theme}" />

<select on:change={themeChange}>
    <!-- excuse the ugly code -->
    <option value="darkTheme" selected={data.theme==="darkTheme"}>Dark</option>
    <option value="lightTheme" selected={data.theme==="lightTheme"}>Light</option>
    <option value="systemTheme" selected={data.theme==="systemTheme"}>System</option>
</select>

@fvilers
Copy link

fvilers commented Dec 24, 2023

I'm solving this issue using a custom action that set the class(es) I need on the body.

classList.ts

import type { Action } from "svelte/action";

export const classList: Action<Element, string | string[]> = (node, classes) => {
  const tokens = Array.isArray(classes) ? classes : [classes];
  node.classList.add(...tokens);

  return {
    destroy() {
      node.classList.remove(...tokens);
    },
  };
};

+page.svelte

<script lang="ts">
  import { classList } from "$lib/actions/classList";
</script>

<svelte:body use:classList={"bg-gray-50"} />

@ivodolenc
Copy link

I guess this is a difficult feature to implement since there is no official solution from the team.

It seems to be a very popular request for a long time, and ultimately it's a really useful and reasonable request to simply change classes and other attributes for html and body tags to achieve content manipulation or other functionality as needed.

I really hope this can be added to the stable version of Svelte 5.


As already suggested above (#1, #2), here is an example that works well for my use case. Also this #3 will not work in SvelteKit since actions are client-side only so it will flicker on page reload.

I needed to change some styles only for a specific page. This is a workaround when it comes to styles.

First, let's set the derived pageId value to the main +layout.svelte so that it can easily change dynamically as we navigate through the page.

<!-- +layout.svelte -->

<script lang="ts">
  import { page } from '$app/stores'
  import '../styles/main.css'

  let { children } = $props()

  // optional parsing step for friendly string output
  function parsePath(path: string) {
    let newPath: string = ''
    if (path === '/') newPath = '/home'
    else newPath = path

    // replaces route `/` with `dash`
    return newPath.replace(/\//g, '-').slice(1)
  }

  let pageId = $derived(parsePath($page.url.pathname))
</script>

<!-- automatically sets `page-id` on each route change -->
<div data-page="{pageId}">{@render children()}</div>

After that you can easily add styles for html or body tag, of course there are no issues for now with this approach, no flicker etc and works in sveltekit with ssr/static.

/* styles/main.css */

/* html styles for 'home' page */
html:has(div[data-page='home']) {
  background: blue;
}

/* body styles for 'about' page */
body:has(div[data-page='about']) {
  background: green;
}

/* etc... */

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request popular more than 20 upthumbs
Projects
None yet
Development

Successfully merging a pull request may close this issue.