Skip to content
edmi-ui

Layout Picker

Dashboard or navbar choice cards, plus a first-visit corner toast that saves the choice in a cookie.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/layout-picker
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/layout-picker
Terminal window
yarn dlx shadcn@latest add @edmi-ui/layout-picker
Terminal window
bunx shadcn@latest add @edmi-ui/layout-picker

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/layout-picker
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/layout-picker
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/layout-picker
Terminal window
bunx shadcn-vue@latest add @edmi-ui/layout-picker

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/layout-picker.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { LayoutPicker } from "@/components/layout-picker/layout-picker";
import { Button } from "@/components/ui/button";
<div className="flex flex-col items-start gap-4">
<LayoutPicker />
<Button variant="outline">
Show corner toast
</Button>
</div>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import { LayoutPicker, LayoutPickerToast } from "@/components/ui/layout-picker";
</script>
<template>
<div class="flex flex-col items-start gap-4">
<LayoutPicker />
<Button variant="outline">Show corner toast</Button>
<LayoutPickerToast
default-open
/>
</div>
</template>
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import {
LayoutPicker,
LayoutPickerToast,
} from "$lib/components/ui/layout-picker";
</script>
<div class="flex flex-col items-start gap-4">
<LayoutPicker />
<Button variant="outline">Show corner toast</Button>
{#if toast}
<LayoutPickerToast defaultOpen />
{/if}
</div>
registry/blocks/layout-picker/layout-picker.tsx
"use client";
import { cn } from "cn";
import * as React from "react";
import { XIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
import {
controlLevel,
type Elevation,
surfaceElevation,
useElevation,
} from "@/registry/edmi/ui/elevation";
type Layout = "dashboard" | "navbar";
const LAYOUT_COOKIE = "edmi-layout";
/** Reads the saved layout from a cookie string (defaults to `document.cookie`); `undefined` when unset. */
function getLayoutCookie(cookie?: string): Layout | undefined {
const source =
cookie ?? (typeof document === "undefined" ? "" : document.cookie);
const m = source.match(
new RegExp(`(?:^|;\\s*)${LAYOUT_COOKIE}=(dashboard|navbar)`),
);
return m?.[1] as Layout | undefined;
}
/** Saves the layout for a year (DESIGN §3/§6: a cookie so SSR renders the right shell with no flash). */
function setLayoutCookie(layout: Layout) {
document.cookie = `${LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; samesite=lax`;
}
const bar =
"block rounded-sm bg-[color-mix(in_srgb,var(--muted-foreground)_20%,var(--card))]";
function Wireframe({ layout }: { layout: Layout }) {
return (
<div className="flex h-24 w-full overflow-hidden rounded-lg border border-sk-bd bg-sk-bg shadow-sunken">
{layout === "dashboard" ? (
<>
<div className="flex w-[46px] flex-col gap-1.5 border-r border-border p-1.5">
{[0, 1, 2, 3].map((i) => (
<span key={i} className={cn(bar, "h-1.5")} />
))}
</div>
<div className="flex flex-1 flex-col gap-1.5 p-2">
<span className={cn(bar, "h-2 w-3/5")} />
<span className={cn(bar, "h-10")} />
</div>
</>
) : (
<div className="flex flex-1 flex-col">
<div className="flex h-4 items-center gap-3 border-b border-border px-2">
<span className={cn(bar, "h-1.5 w-[30px]")} />
<span className={cn(bar, "h-1.5 w-[60px]")} />
</div>
<div className="flex flex-col gap-1.5 p-2">
<span className={cn(bar, "h-2 w-3/5")} />
<span className={cn(bar, "h-10")} />
</div>
</div>
)}
</div>
);
}
const options: { value: Layout; label: string }[] = [
{ value: "dashboard", label: "Dashboard" },
{ value: "navbar", label: "Navbar" },
];
// Two choice cards (native radios, so arrows/Space work). Controlled or uncontrolled.
function LayoutPicker({
className,
value,
defaultValue = "dashboard",
onValueChange,
name,
elevation,
...props
}: Omit<
React.ComponentProps<"div">,
"children" | "defaultValue" | "onChange"
> & {
value?: Layout;
defaultValue?: Layout;
onValueChange?: (value: Layout) => void;
name?: string;
/** ✦ depth of the option cards: sunken -1, flat 0, raised +1, floating +2 (the checked card keeps its ring). */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "control");
const groupName = React.useId();
const [inner, setInner] = React.useState<Layout>(defaultValue);
const current = value ?? inner;
return (
<div
data-slot="layout-picker"
role="radiogroup"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{options.map((o) => (
<label
key={o.value}
className={cn(
"group/lp flex w-[220px] cursor-pointer flex-col gap-2.5 rounded-xl border border-border bg-card p-3.5 has-[:checked]:border-ring has-[:checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_var(--ring)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring",
surfaceElevation[level],
)}
>
<input
type="radio"
className="sr-only"
name={name ?? groupName}
value={o.value}
checked={current === o.value}
onChange={() => {
setInner(o.value);
onValueChange?.(o.value);
}}
/>
<Wireframe layout={o.value} />
<span className="flex w-full items-center justify-between">
<span className="text-sm font-medium">{o.label}</span>
<span className="flex size-[18px] items-center justify-center rounded-full border border-input bg-card group-has-[:checked]/lp:border-primary group-has-[:checked]/lp:after:size-[9px] group-has-[:checked]/lp:after:rounded-full group-has-[:checked]/lp:after:bg-primary group-has-[:checked]/lp:after:content-['']" />
</span>
</label>
))}
</div>
);
}
// First-visit corner toast. Renders nothing once a layout cookie exists. Choosing saves the cookie;
// closing without choosing saves the default (`dashboard`) so it does not return.
function LayoutPickerToast({
className,
title = "Choose your layout",
description = "You can switch any time.",
defaultOpen,
defaultValue = "dashboard",
onValueChange,
onClose,
elevation,
...props
}: Omit<React.ComponentProps<"div">, "title" | "children"> & {
title?: React.ReactNode;
description?: React.ReactNode;
/** Skip the cookie check and show immediately (docs/previews). */
defaultOpen?: boolean;
/** Initially selected layout. */
defaultValue?: Layout;
onValueChange?: (value: Layout) => void;
onClose?: () => void;
/** ✦ depth of the toast (floating is its natural level); raised +1 / floating +2 also raise the option cards. */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "overlay");
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState<Layout>(defaultValue);
React.useEffect(() => {
setOpen(defaultOpen ?? getLayoutCookie() === undefined);
}, [defaultOpen]);
if (!open) return null;
return (
<div
data-slot="layout-picker-toast"
role="dialog"
aria-label="Choose layout"
className={cn(
"fixed right-4 bottom-4 z-50 w-[min(92vw,500px)] rounded-xl border border-border bg-popover p-4 text-popover-foreground",
surfaceElevation[level],
className,
)}
{...props}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{title}</div>
<div className="text-[13px] text-muted-foreground">{description}</div>
</div>
<Button
variant="ghost"
size="icon-xs"
aria-label="Close"
onClick={() => {
if (getLayoutCookie() === undefined) setLayoutCookie(value);
setOpen(false);
onClose?.();
}}
>
<XIcon
/>
</Button>
</div>
<LayoutPicker
className="mt-3 flex-nowrap"
elevation={controlLevel(elevation)}
value={value}
onValueChange={(v) => {
setValue(v);
setLayoutCookie(v);
onValueChange?.(v);
}}
/>
</div>
);
}
export type { Layout };
export {
getLayoutCookie,
LAYOUT_COOKIE,
LayoutPicker,
LayoutPickerToast,
setLayoutCookie,
};
registry/ui/layout-picker/LayoutPicker.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { Layout } from './layout'
import { computed, ref, useId } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, surfaceElevation, useElevation } from '@/registry/edmi/ui/elevation'
import LayoutPickerWireframe from './LayoutPickerWireframe.vue'
const props = withDefaults(defineProps<{
modelValue?: Layout
defaultValue?: Layout
name?: string
/** ✦ depth of the option cards: sunken -1, flat 0, raised +1, floating +2 (the checked card keeps its ring) */
elevation?: Elevation
class?: HTMLAttributes['class']
}>(), {
defaultValue: 'dashboard',
elevation: undefined,
})
const level = useElevation(() => props.elevation, 'control')
const emit = defineEmits<{
(e: 'update:modelValue', value: Layout): void
}>()
const options: { value: Layout, label: string }[] = [
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'navbar', label: 'Navbar' },
]
const groupName = useId()
const inner = ref<Layout>(props.defaultValue)
const current = computed(() => props.modelValue ?? inner.value)
function choose(v: Layout) {
inner.value = v
emit('update:modelValue', v)
}
</script>
<template>
<!-- Two choice cards (native radios, so arrows/Space work). Controlled (`v-model`) or uncontrolled. -->
<div data-slot="layout-picker" role="radiogroup" :class="cn('flex flex-wrap gap-3', props.class)">
<label
v-for="o in options"
:key="o.value"
:class="cn(
'group/lp flex w-[220px] cursor-pointer flex-col gap-2.5 rounded-xl border border-border bg-card p-3.5 has-[:checked]:border-ring has-[:checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_var(--ring)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring',
surfaceElevation[level],
)"
>
<input
type="radio"
class="peer sr-only"
:name="name ?? groupName"
:value="o.value"
:checked="current === o.value"
@change="choose(o.value)"
>
<LayoutPickerWireframe :layout="o.value" />
<span class="flex w-full items-center justify-between">
<span class="text-sm font-medium">{{ o.label }}</span>
<span class="flex size-[18px] items-center justify-center rounded-full border border-input bg-card group-has-[:checked]/lp:border-primary group-has-[:checked]/lp:after:size-[9px] group-has-[:checked]/lp:after:rounded-full group-has-[:checked]/lp:after:bg-primary group-has-[:checked]/lp:after:content-['']" />
</span>
</label>
</div>
</template>
registry/ui/layout-picker/LayoutPickerToast.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { Layout } from './layout'
import { X } from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { Button } from '@/registry/edmi/ui/button'
import { controlLevel, type Elevation, surfaceElevation, useElevation } from '@/registry/edmi/ui/elevation'
import { getLayoutCookie, setLayoutCookie } from './layout'
import LayoutPicker from './LayoutPicker.vue'
const props = withDefaults(defineProps<{
title?: string
description?: string
/** Skip the cookie check and show immediately (docs/previews). */
defaultOpen?: boolean
/** Controlled layout (`v-model`). */
modelValue?: Layout
/** Initially selected layout. */
defaultValue?: Layout
/** ✦ depth of the toast (floating is its natural level); raised +1 / floating +2 also raise the option cards */
elevation?: Elevation
class?: HTMLAttributes['class']
}>(), {
title: 'Choose your layout',
description: 'You can switch any time.',
// Boolean props are cast to false when absent; keep undefined so the cookie check decides.
defaultOpen: undefined,
defaultValue: 'dashboard',
elevation: undefined,
})
const level = useElevation(() => props.elevation, 'overlay')
const pickerElevation = computed(() => controlLevel(props.elevation))
const emit = defineEmits<{
(e: 'update:modelValue', value: Layout): void
(e: 'close'): void
}>()
// First-visit corner toast. Renders nothing once a layout cookie exists. Choosing saves the cookie;
// closing without choosing saves the default (`dashboard`) so it does not return.
const open = ref(false)
const inner = ref<Layout>(props.defaultValue)
const value = computed(() => props.modelValue ?? inner.value)
onMounted(() => {
open.value = props.defaultOpen ?? getLayoutCookie() === undefined
})
function onChange(v: Layout) {
inner.value = v
setLayoutCookie(v)
emit('update:modelValue', v)
}
function close() {
if (getLayoutCookie() === undefined)
setLayoutCookie(value.value)
open.value = false
emit('close')
}
</script>
<template>
<div
v-if="open"
data-slot="layout-picker-toast"
role="dialog"
aria-label="Choose layout"
:class="cn(
'fixed right-4 bottom-4 z-50 w-[min(92vw,500px)] rounded-xl border border-border bg-popover p-4 text-popover-foreground',
surfaceElevation[level],
props.class,
)"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">
{{ title }}
</div>
<div class="text-[13px] text-muted-foreground">
{{ description }}
</div>
</div>
<Button variant="ghost" size="icon-xs" aria-label="Close" @click="close">
<X />
</Button>
</div>
<LayoutPicker
class="mt-3 flex-nowrap"
:elevation="pickerElevation"
:model-value="value"
@update:model-value="onChange"
/>
</div>
</template>
registry/ui/layout-picker/LayoutPickerWireframe.vue
<script setup lang="ts">
import type { Layout } from './layout'
defineProps<{ layout: Layout }>()
const bar = 'block rounded-sm bg-[color-mix(in_srgb,var(--muted-foreground)_20%,var(--card))]'
</script>
<template>
<div class="flex h-24 w-full overflow-hidden rounded-lg border border-sk-bd bg-sk-bg shadow-sunken">
<template v-if="layout === 'dashboard'">
<div class="flex w-[46px] flex-col gap-1.5 border-r border-border p-1.5">
<span v-for="i in 4" :key="i" :class="`${bar} h-1.5`" />
</div>
<div class="flex flex-1 flex-col gap-1.5 p-2">
<span :class="`${bar} h-2 w-3/5`" />
<span :class="`${bar} h-10`" />
</div>
</template>
<div v-else class="flex flex-1 flex-col">
<div class="flex h-4 items-center gap-3 border-b border-border px-2">
<span :class="`${bar} h-1.5 w-[30px]`" />
<span :class="`${bar} h-1.5 w-[60px]`" />
</div>
<div class="flex flex-col gap-1.5 p-2">
<span :class="`${bar} h-2 w-3/5`" />
<span :class="`${bar} h-10`" />
</div>
</div>
</div>
</template>
registry/ui/layout-picker/layout.ts
export type Layout = "dashboard" | "navbar";
export const LAYOUT_COOKIE = "edmi-layout";
/** Reads the saved layout from a cookie string (defaults to `document.cookie`); `undefined` when unset. */
export function getLayoutCookie(cookie?: string): Layout | undefined {
const source =
cookie ?? (typeof document === "undefined" ? "" : document.cookie);
const m = source.match(
new RegExp(`(?:^|;\\s*)${LAYOUT_COOKIE}=(dashboard|navbar)`),
);
return m?.[1] as Layout | undefined;
}
/** Saves the layout for a year (DESIGN §3/§6: a cookie so SSR renders the right shell with no flash). */
export function setLayoutCookie(layout: Layout) {
document.cookie = `${LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; samesite=lax`;
}
registry/ui/layout-picker/index.ts
export { default as LayoutPicker } from "./LayoutPicker.vue";
export { default as LayoutPickerToast } from "./LayoutPickerToast.vue";
export type { Layout } from "./layout";
export { getLayoutCookie, LAYOUT_COOKIE, setLayoutCookie } from "./layout";
src/lib/registry/ui/layout-picker/index.ts
export {
getLayoutCookie,
LAYOUT_COOKIE,
type Layout,
setLayoutCookie,
} from "./layout.js";
export { default as LayoutPicker } from "./layout-picker.svelte";
export { default as LayoutPickerToast } from "./layout-picker-toast.svelte";
src/lib/registry/ui/layout-picker/layout-picker-toast.svelte
<script lang="ts">
import XIcon from "phosphor-svelte/lib/X";
import { Button } from "$lib/registry/ui/button/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { getLayoutCookie, setLayoutCookie, type Layout } from "./layout.js";
import LayoutPicker from "./layout-picker.svelte";
import { controlLevel, type Elevation, surfaceElevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let {
ref = $bindable(null),
class: className,
title = "Choose your layout",
description = "You can switch any time.",
defaultOpen,
value = $bindable(),
defaultValue = "dashboard",
onValueChange,
onClose,
elevation = "auto",
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "title" | "children">> & {
title?: string;
description?: string;
/** Skip the cookie check and show immediately (docs/previews). */
defaultOpen?: boolean;
/** Two-way bindable selected layout (`bind:value`). */
value?: Layout;
/** Initially selected layout. */
defaultValue?: Layout;
onValueChange?: (value: Layout) => void;
onClose?: () => void;
/** ✦ depth of the toast (floating is its natural level); raised +1 / floating +2 also raise the option cards. */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "overlay");
const pickerElevation = $derived<Elevation>(controlLevel(elevation) ?? "auto");
// First-visit corner toast. Renders nothing once a layout cookie exists. Choosing saves the cookie;
// closing without choosing saves the default (`dashboard`) so it does not return.
let open = $state(false);
// svelte-ignore state_referenced_locally
let inner = $state<Layout>(defaultValue);
const current = $derived(value ?? inner);
$effect(() => {
open = defaultOpen ?? getLayoutCookie() === undefined;
});
</script>
{#if open}
<div
bind:this={ref}
data-slot="layout-picker-toast"
role="dialog"
aria-label="Choose layout"
class={cn(
"fixed right-4 bottom-4 z-50 w-[min(92vw,500px)] rounded-xl border border-border bg-popover p-4 text-popover-foreground",
surfaceElevation[level.current],
className
)}
{...restProps}
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold">{title}</div>
<div class="text-[13px] text-muted-foreground">{description}</div>
</div>
<Button
variant="ghost"
size="icon-xs"
aria-label="Close"
onclick={() => {
if (getLayoutCookie() === undefined) setLayoutCookie(current);
open = false;
onClose?.();
}}
>
<XIcon
/>
</Button>
</div>
<LayoutPicker
class="mt-3 flex-nowrap"
elevation={pickerElevation}
value={current}
onValueChange={(v) => {
inner = v;
value = v;
setLayoutCookie(v);
onValueChange?.(v);
}}
/>
</div>
{/if}
src/lib/registry/ui/layout-picker/layout-picker-wireframe.svelte
<script lang="ts">
import type { Layout } from "./layout.js";
let { layout }: { layout: Layout } = $props();
const bar = "block rounded-sm bg-[color-mix(in_srgb,var(--muted-foreground)_20%,var(--card))]";
</script>
<div class="flex h-24 w-full overflow-hidden rounded-lg border border-sk-bd bg-sk-bg shadow-sunken">
{#if layout === "dashboard"}
<div class="flex w-[46px] flex-col gap-1.5 border-r border-border p-1.5">
{#each [0, 1, 2, 3] as i (i)}
<span class="{bar} h-1.5"></span>
{/each}
</div>
<div class="flex flex-1 flex-col gap-1.5 p-2">
<span class="{bar} h-2 w-3/5"></span>
<span class="{bar} h-10"></span>
</div>
{:else}
<div class="flex flex-1 flex-col">
<div class="flex h-4 items-center gap-3 border-b border-border px-2">
<span class="{bar} h-1.5 w-[30px]"></span>
<span class="{bar} h-1.5 w-[60px]"></span>
</div>
<div class="flex flex-col gap-1.5 p-2">
<span class="{bar} h-2 w-3/5"></span>
<span class="{bar} h-10"></span>
</div>
</div>
{/if}
</div>
src/lib/registry/ui/layout-picker/layout-picker.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Layout } from "./layout.js";
import { type Elevation, surfaceElevation, useElevation } from "$lib/registry/ui/elevation/index.js";
import LayoutPickerWireframe from "./layout-picker-wireframe.svelte";
let {
ref = $bindable(null),
class: className,
value = $bindable("dashboard"),
onValueChange,
name,
elevation = "auto",
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "children" | "onchange">> & {
/** Two-way bindable (`bind:value`); initial value doubles as the default. */
value?: Layout;
onValueChange?: (value: Layout) => void;
name?: string;
/** ✦ depth of the option cards: sunken -1, flat 0, raised +1, floating +2 (the checked card keeps its ring). */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "control");
const options: { value: Layout; label: string }[] = [
{ value: "dashboard", label: "Dashboard" },
{ value: "navbar", label: "Navbar" },
];
const groupName = $props.id();
</script>
<!-- Two choice cards (native radios, so arrows/Space work). -->
<div
bind:this={ref}
data-slot="layout-picker"
role="radiogroup"
class={cn("flex flex-wrap gap-3", className)}
{...restProps}
>
{#each options as o (o.value)}
<label
class={cn(
"group/lp flex w-[220px] cursor-pointer flex-col gap-2.5 rounded-xl border border-border bg-card p-3.5 has-[:checked]:border-ring has-[:checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] has-[:checked]:shadow-[0_0_0_1px_var(--ring)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring",
surfaceElevation[level.current]
)}
>
<input
type="radio"
class="sr-only"
name={name ?? groupName}
value={o.value}
checked={value === o.value}
onchange={() => {
value = o.value;
onValueChange?.(o.value);
}}
/>
<LayoutPickerWireframe layout={o.value} />
<span class="flex w-full items-center justify-between">
<span class="text-sm font-medium">{o.label}</span>
<span
class="flex size-[18px] items-center justify-center rounded-full border border-input bg-card group-has-[:checked]/lp:border-primary group-has-[:checked]/lp:after:size-[9px] group-has-[:checked]/lp:after:rounded-full group-has-[:checked]/lp:after:bg-primary group-has-[:checked]/lp:after:content-['']"
></span>
</span>
</label>
{/each}
</div>
src/lib/registry/ui/layout-picker/layout.ts
export type Layout = "dashboard" | "navbar";
export const LAYOUT_COOKIE = "edmi-layout";
/** Reads the saved layout from a cookie string (defaults to `document.cookie`); `undefined` when unset. */
export function getLayoutCookie(cookie?: string): Layout | undefined {
const source =
cookie ?? (typeof document === "undefined" ? "" : document.cookie);
const m = source.match(
new RegExp(`(?:^|;\\s*)${LAYOUT_COOKIE}=(dashboard|navbar)`),
);
return m?.[1] as Layout | undefined;
}
/** Saves the layout for a year (DESIGN §3/§6: a cookie so SSR renders the right shell with no flash). */
export function setLayoutCookie(layout: Layout) {
document.cookie = `${LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; samesite=lax`;
}

See the Elevation guide for the levels, roles and layered mode.

Flat by default. elevation sets the depth of the option cards: sunken (-1) wells, raised (+1) the bevel, floating (+2) bevel plus drop; the checked card keeps its ring. LayoutPickerToast takes the same prop for the toast (floating is its natural level) and raises the cards when it is raised or floating.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Part Prop Type Default
LayoutPicker value / defaultValue "dashboard" | "navbar" (Vue v-model, Svelte bind:value) "dashboard"
onValueChange (layout) => void none
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
LayoutPickerToast defaultOpen boolean (skip the cookie check) cookie unset
value / defaultValue selected layout (Vue v-model, Svelte bind:value) "dashboard"
onValueChange / onClose callbacks (Vue emits update:modelValue / close) none
title / description ReactNode "Choose your layout" / "You can switch any time."
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
helpers getLayoutCookie(cookie?) / setLayoutCookie(layout) cookie edmi-layout

The toast renders nothing once the cookie exists. Picking a card saves it; closing without picking saves the default (dashboard) so the toast does not return. Read the cookie on the server (getLayoutCookie(request.headers.get("cookie") ?? "")) to render the right shell without a flash (DESIGN §3, §6).

  • Choice cards are native radios (arrow keys work).