Drop-in browser library that adds a Stand-with-Ukraine support banner and ribbon to any website. Installable from npm and embeddable with a single script tag.
  • TypeScript 60.5%
  • HTML 29.4%
  • JavaScript 6.4%
  • SCSS 3.3%
  • Just 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-16 16:00:37 -03:00
.github fix(ci): bump outdated action versions for Node 24 compat 2026-07-17 17:09:27 -03:00
docs/screenshots chore(reuse): licenses for screenshots 2026-07-17 21:30:10 -03:00
LICENSES chore: init 2026-07-08 01:40:04 -03:00
pages feat!: require localized urls map on Charity, drop url field 2026-09-26 22:57:26 -03:00
scripts chore: format source files with Prettier 3.9.10 2026-10-12 03:21:27 -03:00
src chore: step tseslint up from recommended to strict 2026-10-15 22:38:20 -03:00
tests chore: step tseslint up from recommended to strict 2026-10-15 22:38:20 -03:00
.gitignore chore: exclude tsconfig.tsbuildinfo from lint 2026-09-06 03:26:55 -03:00
.prettierignore chore: exclude tsconfig.tsbuildinfo from lint 2026-09-06 03:26:55 -03:00
.prettierrc.yaml build: gate formatting on prettier 2026-10-15 22:38:19 -03:00
AGENTS.md feat: defer click rotation until page loses focus 2026-10-10 02:45:46 -03:00
BOOKMARKLET.md docs: add bookmarklet 2026-07-17 21:26:03 -03:00
CITATION.cff chore(version): 2.1.2 2026-10-16 16:00:37 -03:00
CODE_OF_CONDUCT.md chore: init 2026-07-08 01:40:04 -03:00
cog.toml chore: init 2026-07-08 01:40:04 -03:00
CONTRIBUTING.md chore: init 2026-07-08 01:40:04 -03:00
dev.html feat: defer click rotation until page loses focus 2026-10-10 02:45:46 -03:00
eslint.config.js chore: step tseslint up from recommended to strict 2026-10-15 22:38:20 -03:00
justfile chore: add serve script and dev playground controls for new features 2026-07-26 22:55:13 -03:00
lefthook.yaml chore: clean up lefthook 2026-07-17 22:03:22 -03:00
package-lock.json chore(version): 2.1.2 2026-10-16 16:00:37 -03:00
package-lock.json.license chore: init 2026-07-08 01:40:04 -03:00
package.json chore(version): 2.1.2 2026-10-16 16:00:37 -03:00
package.json.license chore: init 2026-07-08 01:40:04 -03:00
projectfile.yaml chore: fix project title 2026-09-19 01:45:59 -03:00
README.md feat: defer click rotation until page loses focus 2026-10-10 02:45:46 -03:00
SECURITY.md chore: init 2026-07-08 01:40:04 -03:00
serve.json chore: serve dev playground at server root 2026-10-10 02:45:56 -03:00
serve.json.license chore: serve dev playground at server root 2026-10-10 02:45:56 -03:00
SUPPORT.md chore: init 2026-07-08 01:40:04 -03:00
tsconfig.json chore: init 2026-07-08 01:40:04 -03:00
tsconfig.json.license chore: init 2026-07-08 01:40:04 -03:00
tsconfig.test.json chore: init 2026-07-08 01:40:04 -03:00
tsconfig.test.json.license chore: init 2026-07-08 01:40:04 -03:00
tsup.config.ts fix(lint): resolve unicorn violations 2026-10-12 03:19:13 -03:00

@damian-buho/support-ukraine

StandWithUkraine NPM Version npm package minimized gzipped size (scoped) NPM Downloads NPM License PRs Welcome Libraries.io dependency status for GitHub repo

Pipeline CodeQL Known Vulnerabilities REUSE status

  • Browser library that adds a Ukraine-support charity banner to any website.
  • Shows randomly selected Ukrainian charity localized to the visitor's language and respects RTL scripts.
  • You can choose kinds of charities.
  • Inspired by hejny/Ukraine.

Screenshots

Demo / Play with settings

I18N support

Dark theme support

RTL Support

Install

npm

npm install @damian-buho/support-ukraine

CDN (no build step)

<script type="module">
  import { supportUkraineBlock } from 'https://cdn.jsdelivr.net/npm/@damian-buho/support-ukraine@2/+esm'

  await supportUkraineBlock()
</script>

For optimal Core Web Vitals, avoid the chained locale fetch by loading a pre-localized build:

<script type="module">
  import { supportUkraineBlock } from 'https://cdn.jsdelivr.net/npm/@damian-buho/support-ukraine@2/dist/es.js'

  await supportUkraineBlock()
</script>

Usage

import { supportUkraineBlock } from '@damian-buho/support-ukraine'

// Auto-detect locale from navigator.language
await supportUkraineBlock()

// Force a specific locale
await supportUkraineBlock({ locale: 'es' })

For optimal Core Web Vitals, let your server or router decide the language and load only the needed build — no second network request:

import { supportUkraineBlock } from '@damian-buho/support-ukraine/es'

await supportUkraineBlock()

Available localized entry points: ar, de, en, es, fr, hi, it, ja, ko, nl, pl, pt, sv, th, uk, zh. The locale option is ignored in these builds — they are already localized.

If you still want to read navigator.language yourself and avoid the library’s chained fetch, detect the base language tag and load the matching bundle:

<script type="module">
  const base = navigator.language.split('-')[0]
  const supported = [
    'ar',
    'de',
    'en',
    'es',
    'fr',
    'hi',
    'it',
    'ja',
    'ko',
    'nl',
    'pl',
    'pt',
    'sv',
    'th',
    'uk',
    'zh'
  ]
  const locale = supported.includes(base) ? base : 'en'

  const { supportUkraineBlock } = await import(
    `https://cdn.jsdelivr.net/npm/@damian-buho/support-ukraine@2/dist/${locale}.js`
  )

  await supportUkraineBlock()
</script>

With a bundler, do not interpolate the locale into the import specifier — Vite/webpack cannot statically resolve a computed package subpath, so the build either fails or pulls in every locale, which defeats the point. Use a literal specifier per branch instead; each one still gets its own chunk:

const bundles = {
  es: () => import('@damian-buho/support-ukraine/es'),
  fr: () => import('@damian-buho/support-ukraine/fr'),
  // … one entry per supported locale
  en: () => import('@damian-buho/support-ukraine/en')
}

const base = navigator.language.split('-')[0]
const { supportUkraineBlock } = await (bundles[base] ?? bundles.en)()
await supportUkraineBlock()

In both cases only one network request is made — the pre-localized dist/<locale>.js — instead of index.js → locale chunk.

The banner is prepended to document.body by default. It displays a randomly selected charity with the format:

🇺🇦 Support Ukraine: Come Back Alive: Strengthening Ukraine's defense

The entire block is a clickable link to the charity's donation page.

Options

Option Type Default Description
element HTMLElement document.body Target mount element for the banner
mode 'shift' | 'overlap' | 'replace' 'shift' 'shift' pushes page content down, 'overlap' floats on top, 'replace' swaps a same-class placeholder element
fontSize string '87.5%' Banner font size; % anchors to the widget's own 16px host box, ignoring document root scaling
charities Charity[] (built-in) Custom charity list; replaces the built-in database
tags CharityTag[] (all) Filter charities by category: 'military', 'humanitarian', 'animals'
exclude string[] (none) Exclude charities by id; unknown ids are ignored
dontRepeat boolean true Avoid repeating charities; seen ids stay in session memory by default
persistSeen boolean false Persist seen charity ids in localStorage across visits (needs visitor consent)
isInConsole boolean true Log the selected charity to the dev console
showRefreshButton boolean false Show a refresh button that loads the next random charity
autoRefreshInterval number 0 Auto-refresh interval in milliseconds; 0 disables automatic refresh
showRefreshAnimation boolean false Show a fade animation when the charity changes (respects prefers-reduced-motion)
refreshOnClick boolean true Show the next random charity after a click on the banner link
refreshOnClickDelay number 2000 Fallback delay in ms before rotating after a click when the page never loses focus
locale string (auto-detected) Override the auto-detected BCP 47 language tag

Filtering by category

Show only military charities:

await supportUkraineBlock({ tags: ['military'] })

Excluding charities

Hide specific charities by id (unknown ids are ignored):

await supportUkraineBlock({ exclude: ['united24'] })

Localized charity URLs

Charities carry per-language donation URLs via a urls map (base language code → URL, en required). The banner follows the visitor’s language and falls back to en:

await supportUkraineBlock({
  charities: [
    {
      id: 'hospitallers',
      name: 'Hospitallers',
      tagline: '…',
      urls: { en: 'https://www.hospitallers.org.uk', de: 'https://www.hospitallers.org.uk/de' },
      tags: ['humanitarian']
    }
  ]
})

Replace mode (no layout shift)

Use replace mode to swap a same-class placeholder element (any tag) so the banner takes its place without shifting page content:

<!-- Static HTML: renders nothing until JS runs. Mirror the host box (font-size 16px,
     min-height 2.5em = the banner's reserved height) so swap-in causes zero shift. -->
<section
  aria-label="Support Ukraine banner"
  class="support-ukraine-block"
  style="font-size:16px;min-height:2.5em"
></section>
await supportUkraineBlock({ mode: 'replace' })

The banner finds the first element with class support-ukraine-block inside the mount element and replaces it in place. If no placeholder is found, it falls back to prepending. This eliminates cumulative layout shift (CLS) because the placeholder already reserves the exact space the banner needs.

Disabling repeat prevention

Allow the same charity to appear on every page load:

await supportUkraineBlock({ dontRepeat: false })

Disabling refresh on click

By default a click on the banner link opens the donation page in a new tab and the banner rotates to the next random charity once the page loses focus (with a short delayed fallback), so the swap never flashes in front of the visitor and a returning visitor sees a fresh charity. Opt out with:

await supportUkraineBlock({ refreshOnClick: false })

Tune the fallback delay (milliseconds, default 2000) for pages that never lose focus (pop-up blocker, background tab):

await supportUkraineBlock({ refreshOnClickDelay: 5000 })

Persisting seen charities

By default the repeat prevention above keeps seen charity ids in session memory only — nothing is written to the visitor’s device. Set persistSeen to remember them in localStorage across visits. Storing data on a visitor’s device needs their consent under the GDPR/ePrivacy rules, so only enable this after the site owner has obtained it:

await supportUkraineBlock({ persistSeen: true })

Locale support

The banner is translated to the visitor's language automatically. The following locales are supported:

Language Code RTL
Arabic ar Yes
Chinese zh
Dutch nl
English en (default fallback)
French fr
German de
Hindi hi
Italian it
Japanese ja
Korean ko
Polish pl
Portuguese pt
Spanish es
Swedish sv
Thai th
Ukrainian uk

RTL scripts are detected automatically and the banner direction is set accordingly.

For best performance, pick the locale yourself (from Accept-Language, <html lang>, or your i18n router) and import the matching entry point. The generic dist/index.js uses navigator.language and a dynamic import() for the locale chunk — that chained request delays the banner and hurts CWV. Each dist/<locale>.js bundles its messages statically, so it renders in a single fetch.

Architecture

src/
├── index.ts          # Auto-detect build (navigator.language + dynamic import)
├── banner.ts         # Shared banner rendering (mountBanner, DEFAULT_CHARITIES)
├── types.ts          # Charity, SupportUkraineBlockOptions, etc.
├── i18n.ts           # Locale detection, loading, merging (generic build only)
├── locales/          # Per-locale translation files
├── charities.yaml    # Built-in charity database
├── entries/          # Per-locale entry points (ar.ts, es.ts, …) — static import, no fetch
└── styles.scss       # Banner CSS (compiled by tsup)

dist/
├── index.js          # Generic auto-detect build + locale chunks (en-*.js, es-*.js …)
├── en.js, es.js …    # Pre-localized self-contained builds — one fetch, no chained request
└── index.d.ts        # Types (shared by all entry points)

Contributing

See CONTRIBUTING.md for development setup and guidelines.

License

MIT