Action Button

action-button.svelte

Buttons that represent actions, typically used for navigation or important tasks.

Demo

⌃+e GO TO SVELTE.DEV
Usage
001 <script lang="ts">
002 import ActionButton from '$lib/components/action-button.svelte';
003 </script>
004
005 <div class="flex flex-col">
006 <ActionButton hotkey={['control', 's']} onclick={() => console.log('Save action triggered')}>
007 SAVE
008 </ActionButton>
009 <ActionButton onclick={() => console.log('No hotkey action triggered')}>NO HOTKEY</ActionButton>
010 <ActionButton
011 href="https://svelte.dev"
012 hotkey={['control', 'e']}
013 target="_blank"
014 rel="noopener noreferrer"
015 >
016 GO TO SVELTE.DEV
017 </ActionButton>
018 </div>
019
action-button.svelte
001 <script lang="ts" module>
002 import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
003
004 /** A keyboard combo: modifier names plus a key, e.g. `['control', 't']`. */
005 export type Hotkey = ('control' | 'alt' | 'shift' | 'meta' | string)[];
006
007 /**
008 * Polymorphic: renders `<a>` when `href` is set, `<button>` otherwise. `href`
009 * discriminates the allowed attributes.
010 */
011 export type ActionButtonProps =
012 | (HTMLButtonAttributes & { ref?: HTMLElement | null; hotkey?: Hotkey; href?: undefined })
013 | (HTMLAnchorAttributes & { ref?: HTMLElement | null; hotkey?: Hotkey; href: string });
014 </script>
015
016 <script lang="ts">
017 import { on } from 'svelte/events';
018
019 let {
020 class: className,
021 ref = $bindable(null),
022 hotkey = [],
023 href,
024 children,
025 ...restProps
026 }: ActionButtonProps = $props();
027
028 const modifiers: Record<string, string> = {
029 control: '⌃',
030 alt: '⌥',
031 shift: '⇧',
032 meta: '⌘'
033 };
034
035 // Fire the button's action when its hotkey combo is pressed. Matches against
036 // the keydown's modifier flags (ctrlKey/altKey/shiftKey/metaKey) plus e.key,
037 // so it works whether the OS fires separate modifier keydowns or a single
038 // keydown with modifier flags set. Re-entrant guard: one combo press → one fire.
039 $effect(() => {
040 if (!hotkey.length) return;
041 const target = new Set(hotkey.map((k) => k.toLowerCase()));
042 const modFlags: Record<string, 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'> = {
043 control: 'ctrlKey',
044 alt: 'altKey',
045 shift: 'shiftKey',
046 meta: 'metaKey'
047 };
048 let fired = false;
049
050 const held = (e: KeyboardEvent) => {
051 // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient, never read by the view
052 const s = new Set<string>([e.key.toLowerCase()]);
053 for (const [k, flag] of Object.entries(modFlags)) if (e[flag]) s.add(k);
054 return s;
055 };
056 const match = (e: KeyboardEvent) => [...target].every((k) => held(e).has(k));
057
058 const fire = () => ref?.click();
059
060 const onKeydown = (e: KeyboardEvent) => {
061 if (!fired && match(e)) {
062 fired = true;
063 fire();
064 }
065 };
066 const onKeyup = (e: KeyboardEvent) => {
067 if (target.has(e.key.toLowerCase())) fired = false;
068 };
069
070 const cleanups = [on(window, 'keydown', onKeydown), on(window, 'keyup', onKeyup)];
071 return () => cleanups.forEach((fn) => fn());
072 });
073 </script>
074
075 {#if href}
076 <a
077 bind:this={ref}
078 class={`action-button ${className ?? ''}`}
079 {href}
080 {...restProps as HTMLAnchorAttributes}
081 >
082 {#if hotkey.length}
083 <kbd class="hotkey">
084 {hotkey.map((key) => modifiers[key] || key).join('+')}
085 </kbd>
086 {/if}
087 <span class="button-content">
088 {@render children?.()}
089 </span>
090 </a>
091 {:else}
092 <button
093 bind:this={ref}
094 class={`action-button ${className ?? ''}`}
095 {...restProps as HTMLButtonAttributes}
096 >
097 {#if hotkey.length}
098 <kbd class="hotkey">
099 {hotkey.map((key) => modifiers[key] || key).join('+')}
100 </kbd>
101 {/if}
102 <span class="button-content">
103 {@render children?.()}
104 </span>
105 </button>
106 {/if}
107
108 <style>
109 .action-button {
110 font-family: inherit;
111 font-size: inherit;
112 line-height: inherit;
113 color: inherit;
114 text-decoration: none;
115 display: flex;
116 flex-direction: row;
117 align-items: center;
118 max-width: fit-content;
119 border: 0;
120 background-color: var(--button-secondary-fg);
121 padding: 0 0.25ch; /* px-0.5 */
122 user-select: none;
123 cursor: pointer;
124 transition: background-color 0.2s ease-in-out;
125 }
126
127 .action-button:hover {
128 background-color: var(--focus-ring);
129 }
130
131 .action-button:focus-visible {
132 outline: none;
133 background-color: var(--focus-ring);
134 }
135
136 .action-button:disabled,
137 .action-button[aria-disabled='true'] {
138 pointer-events: none;
139 opacity: 0.5;
140 }
141
142 .hotkey {
143 padding: 0 0.5ch;
144 text-transform: uppercase;
145 font-family: var(--font-family-mono);
146 }
147
148 .button-content {
149 margin: 0;
150 background-color: var(--button-secondary-bg);
151 padding: 0 1ch;
152 line-height: var(--line);
153 }
154 </style>
155