# Splash screen

> A native launch-screen bridge over react-native-bootsplash, shared by every adapter.

`@symbiote-native/splash-screen` wraps
[`react-native-bootsplash`](https://github.com/zoontek/react-native-bootsplash) so every
SymbioteNative adapter can hide the native launch screen and drive its fade-out — without
importing that library's React hook body. Unlike the
[slider wrapper](/docs/packages/slider/), bootsplash has no native **view** to
register: it exposes only an imperative TurboModule (`hide`/`isVisible`/`getConstants()`), so
there is nothing for the engine's `ViewConfig` path to reach — every adapter calls straight into
the same native module.

| OS platform | Support |
| ----------- | ------- |
| iOS         | ✅ live |
| Android     | ✅ live |

| Framework adapter | Support |
| ----------------- | ------- |
| React             | ✅ live |
| Vue               | ✅ live |
| Angular           | ✅ live |
| Svelte            | ✅ live |
| Solid             | ✅ live |

## Installation

```sh
npm install @symbiote-native/splash-screen
```

Scaffolding or extending a SymbioteNative app? `npx @symbiote-native/cli new --splash-screen` (or
`add --splash-screen` in an existing app) installs and wires this for you — see
[`@symbiote-native/cli`](https://github.com/OneEyed1366/symbiote-native/tree/master/packages/cli).

`@symbiote-native/splash-screen` itself is a workspace package (`packages/splash-screen`), not
yet published — add it as a workspace dependency the same way the examples do. It ships as the
sole autolinked native proxy for `react-native-bootsplash` (`react-native.config.cjs` +
`symbiote-splash-screen.podspec`), so an app never lists `react-native-bootsplash` directly.

<Aside type="danger" title="Two setup steps, not one">
  Installing the JS package is not enough — the native launch screen itself (the
  thing shown *before* JS runs) has to be generated and wired into your native
  project files first. Follow [How to: add a native splash
  screen](/docs/howtos/splash-screen/) for the full asset-generation +
  Android/iOS wiring steps before reaching for `hide()` below.
</Aside>

## Usage

### The imperative case: `hide()`

Once the native launch screen is wired up and shown at boot, call `hide()` after your JS tree
has mounted. `hide()`/`isVisible()` carry zero framework dependency — they are the same
`react-native-bootsplash` functions, re-exported verbatim by every adapter.

<Tabs syncKey="framework">
  <TabItem label="React">
    ```tsx
    import { useEffect } from 'react';
    import { hide } from '@symbiote-native/splash-screen/react';

    export default function App() {
      useEffect(() => {
        hide();
      }, []);

      // ...
    }
    ```

  </TabItem>
  <TabItem label="Vue">
    ```vue
    <script setup lang="ts">
    import { onMounted } from 'vue';
    import { hide } from '@symbiote-native/splash-screen/vue';

    onMounted(() => hide());
    </script>
    ```

  </TabItem>
  <TabItem label="Angular">
    ```ts
    import { Component } from '@angular/core';
    import { hide } from '@symbiote-native/splash-screen/angular';

    @Component({ standalone: true, template: `...` })
    export class AppRoot {
      constructor() {
        hide();
      }
    }
    ```

  </TabItem>
  <TabItem label="Svelte">
    ```svelte
    <script lang="ts">
      import { hide } from '@symbiote-native/splash-screen/svelte';

      $effect(() => {
        hide();
      });
    </script>
    ```

    Bare `hide()` on mount, matching `examples/svelte/App.svelte` — this repo's own canary calls
    it exactly this way at the app root, inside an `$effect` with no dependency, since a Svelte
    component's `<script>` body itself runs only once and there is no ongoing state to react to.

  </TabItem>
  <TabItem label="Solid">
    ```tsx
    import { onMount } from 'solid-js';
    import { hide } from '@symbiote-native/splash-screen';

    export default function App() {
      onMount(() => {
        hide();
      });

      // ...
    }
    ```

    Bare `hide()` inside `onMount`, matching `examples/solid/App.tsx` - this repo's own canary
    calls it exactly this way at the app root.

  </TabItem>
</Tabs>

### The animated case: `useHideAnimation`

<Aside type="note">
  `useHideAnimation` does not render anything itself — it returns style/prop
  bags your own `View`/`Image` bind to. It fires `hide()` exactly once, only
  after layout has committed, both images (if you asked for them) have loaded,
  and your own `ready` flag is true — a faithful port of
  `react-native-bootsplash`'s own `useHideAnimation`.
</Aside>

<Tabs syncKey="framework">
  <TabItem label="React">
    ```tsx
    import { useState } from 'react';
    import { Animated } from '@symbiote-native/react';
    import { useHideAnimation } from '@symbiote-native/splash-screen/react';
    import manifest from '../assets/bootsplash/manifest.json';
    import logo from '../assets/bootsplash/logo.png';

    export default function AnimatedSplash() {
      const [opacity] = useState(() => new Animated.Value(1));
      const [ready, setReady] = useState(false);

      const { container, logo: logoProps } = useHideAnimation({
        manifest,
        logo,
        ready,
        animate: () => {
          Animated.timing(opacity, { toValue: 0, duration: 250, useNativeDriver: true }).start();
        },
      });

      return (
        <Animated.View {...container} style={[container.style, { opacity }]}>
          <image {...logoProps} />
        </Animated.View>
      );
    }
    ```

  </TabItem>
  <TabItem label="Vue">
    ```vue
    <script setup lang="ts">
    import { ref } from 'vue';
    import { useHideAnimation } from '@symbiote-native/splash-screen/vue';
    import manifest from '../assets/bootsplash/manifest.json';
    import logo from '../assets/bootsplash/logo.png';

    const ready = ref(false);

    const hideAnimation = useHideAnimation(() => ({
      manifest,
      logo,
      ready: ready.value,
      animate: () => {
        /* your own fade-out */
      },
    }));
    </script>

    <template>
      <view v-bind="hideAnimation.container">
        <image v-bind="hideAnimation.logo" />
      </view>
    </template>
    ```

    `useHideAnimation` takes a config **getter**, not a plain value — a Vue composable's setup body
    runs once, so the getter lets it keep reading whatever reactive refs it closes over.

  </TabItem>
  <TabItem label="Angular">
    ```ts
    import { Component, inject, signal } from '@angular/core';
    import { HideAnimationService } from '@symbiote-native/splash-screen/angular';
    import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';
    import manifest from '../assets/bootsplash/manifest.json';
    import logo from '../assets/bootsplash/logo.png';

    @Component({
      standalone: true,
      imports: [SYMBIOTE_ELEMENTS],
      template: `
        <view
          [style]="hideAnimation().container.style"
          (layout)="hideAnimation().container.onLayout()"
        >
          <image
            [source]="hideAnimation().logo.source"
            (loadEnd)="hideAnimation().logo.onLoadEnd?.()"
          />
        </view>
      `,
    })
    export class AnimatedSplash {
      readonly ready = signal(false);

      readonly hideAnimation = inject(HideAnimationService).connect(() => ({
        manifest,
        logo,
        ready: this.ready(),
        animate: () => {
          /* your own fade-out */
        },
      }));
    }
    ```

    `HideAnimationService.connect()` is Angular's twin of the React hook / Vue composable — Angular
    has no per-instance hook, so state and lifecycle live in DI instead; `connect()` also takes a
    config getter and returns a `Signal`. Unlike React's `{...container}` spread or Vue's
    `v-bind="hideAnimation.container"`, Angular has no generic prop-spread — `onLayout`/`onLoadEnd`
    must be wired explicitly through the real `(layout)`/`(loadEnd)` outputs `View`/`Image` expose,
    calling the readiness callbacks `container.onLayout`/`logo.onLoadEnd` by hand; skipping them
    means the readiness gate never completes and `hide()` never fires.

  </TabItem>
  <TabItem label="Svelte">
    ```svelte
    <script lang="ts">
      import { Animated } from '@symbiote-native/svelte';
      import { useHideAnimation } from '@symbiote-native/splash-screen/svelte';
      import manifest from '../assets/bootsplash/manifest.json';
      import logo from '../assets/bootsplash/logo.png';

      const opacity = new Animated.Value(1);
      let ready = $state(false);

      const hideAnimation = useHideAnimation(() => ({
        manifest,
        logo,
        ready,
        animate: () => {
          Animated.timing(opacity, { toValue: 0, duration: 250, useNativeDriver: true }).start();
        },
      }));
    </script>

    <Animated.View
      {...hideAnimation.current.container}
      style={[hideAnimation.current.container.style, { opacity }]}
    ><image {...hideAnimation.current.logo} /></Animated.View>
    ```

    `useHideAnimation` takes a config **getter**, not a plain value — a Svelte component's
    `<script>` body runs once, like Vue's `setup`, so the getter is what lets it keep reading
    `ready` as it changes. The return value is a getter too (`hideAnimation.current`), the same
    boxed-getter convention as this adapter's other runes, since a raw `$state` doesn't survive
    being returned from a plain function.

  </TabItem>
  <TabItem label="Solid">
    ```tsx
    import { createSignal } from 'solid-js';
    import { Animated } from '@symbiote-native/solid';
    import { createHideAnimation } from '@symbiote-native/splash-screen/solid';
    import manifest from '../assets/bootsplash/manifest.json';
    import logo from '../assets/bootsplash/logo.png';

    export default function AnimatedSplash() {
      const opacity = new Animated.Value(1);
      const [ready, setReady] = createSignal(false);

      const hideAnimation = createHideAnimation(() => ({
        manifest,
        logo,
        ready: ready(),
        animate: () => {
          Animated.timing(opacity, { toValue: 0, duration: 250, useNativeDriver: true }).start();
        },
      }));

      return (
        <Animated.View
          {...hideAnimation().container}
          style={[hideAnimation().container.style, { opacity }]}
        >
          <image {...hideAnimation().logo} />
        </Animated.View>
      );
    }
    ```

    `createHideAnimation` takes a config **accessor**, not a plain value - a Solid component body
    runs once, so the accessor is what lets it keep reading `ready` as it changes. The return value
    is an accessor too (`hideAnimation()`), Solid's own primitive convention, the same shape as this
    adapter's other `create*` primitives.

  </TabItem>
</Tabs>

## API

### `hide()` / `isVisible()`

| Signature                                          | Description                                                                                                                                                                                    |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hide(config?: { fade?: boolean }): Promise<void>` | Hides the native splash screen. `fade: true` fades the _native_ view out before removing it; omitted/`false` removes it immediately — independent of the JS-side `useHideAnimation` fade below |
| `isVisible(): boolean`                             | Reads whether the native splash screen is currently shown. Synchronous — iOS exports it as a blocking sync method, Android returns a plain boolean                                             |

### `useHideAnimation()` config

| Field                      | Type               | Default      | Description                                                                                                                                |
| -------------------------- | ------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `manifest`                 | `IManifest`        | _(required)_ | The generated `assets/bootsplash/manifest.json` — background color plus logo size, optionally a dark background and/or brand               |
| `logo` / `darkLogo`        | `IImageSourceProp` | —            | The logo image to fade in; omit to skip the logo readiness gate entirely                                                                   |
| `brand` / `darkBrand`      | `IImageSourceProp` | —            | A secondary "powered by"-style image below the logo; only read when `manifest.brand` is present                                            |
| `ready`                    | `boolean`          | `true`       | Your own extra readiness gate (e.g. "auth check done") — `animate` waits for this to be `true` too                                         |
| `animate`                  | `() => void`       | _(required)_ | Called exactly once, the moment every readiness condition (layout committed + images loaded + `ready`) is met — put your own fade-out here |
| `statusBarTranslucent`     | `boolean`          | `false`      | Android-only: skip the extra top margin compensation when your app already draws a translucent status bar                                  |
| `navigationBarTranslucent` | `boolean`          | `false`      | Android-only: skip the extra bottom margin compensation when your app already draws a translucent navigation bar                           |

### `useHideAnimation()` return value

| Field       | Type                                                     | Description                                                                                                            |
| ----------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `container` | `{ style, onLayout }`                                    | Bind onto your own `View` — `style` positions and colors the full-screen backdrop, `onLayout` reports layout readiness |
| `logo`      | `{ source, fadeDuration, resizeMode, style, onLoadEnd }` | Bind onto your own `Image` — `onLoadEnd` reports logo-load readiness                                                   |
| `brand`     | `{ source, fadeDuration, resizeMode, style, onLoadEnd }` | Same shape as `logo`, bound onto a second `Image` for the optional brand asset                                         |

## Notes

- **Calling `hide()` on your very first render is safe — it queues rather than failing.** iOS holds
  the resolver until a 0.35 s timer started during native init reports that the system launch
  screen has faded out; Android re-posts the request every 100 ms while the module is still
  initializing or no activity is resumed. The promise settles late; it does not reject.
- **`useHideAnimation` hides the native screen without a fade.** It calls `hide({ fade: false })`
  itself the moment the readiness gate closes, because your `animate()` owns the fade-out —
  `hide({ fade: true })` only matters when you hide the splash by hand.
- **`animate()` runs at most once per mount.** The controller latches after the first call, and the
  logo/brand readiness flags are captured when it is constructed — a config that later drops its
  `logo`/`brand` source does not flip them back. If `ready` never becomes `true`, or a readiness
  callback is never wired, `hide()` never fires and the splash stays up.
- **Native constants are read once, on first render.** `darkModeEnabled` is what picks
  `darkBackground`/`darkLogo`/`darkBrand`, so switching the system theme while the splash is still
  on screen does not re-pick the dark assets.
- **Only Android reports layout constants.** iOS exports `darkModeEnabled` alone;
  `statusBarHeight`, `navigationBarHeight` and `logoSizeRatio` are Android-only, which is why the
  container's negative margins and the logo scaling apply there and nowhere else. On Samsung One
  UI 4 devices `logoSizeRatio` is `0.5`, so the logo renders at half its manifest size by design.

## How the wrapper works

`@symbiote-native/splash-screen` ships **zero native metadata** of its own — the native launch
screen is generated once by the bundled `symbiote-splash-screen` CLI (a thin passthrough to
`react-native-bootsplash`'s own generator) and wired into the app's native entry points by hand,
see the [native-setup how-to](/docs/howtos/splash-screen/). The package's own
code is a pure JS bridge:

```
packages/splash-screen/src/
├── core/     # framework-agnostic: hide/isVisible re-export, getHideAnimationConstants
│             # (reads RNBootSplash's TurboModule via getEnforcingNativeModule),
│             # HideAnimationController (readiness state machine), computeHideAnimationStyles
├── react/    # React lifecycle (hooks) over the core
├── vue/      # Vue lifecycle (composables) over the core
├── angular/  # Angular lifecycle (a DI service) over the core
└── svelte/   # Svelte lifecycle (runes/use-hide-animation.svelte.ts) over the core
```

Every adapter's `useHideAnimation` constructs the **same** `HideAnimationController` once, reads
native constants once, and re-syncs the controller's config through its own reactivity primitive
— a React effect with no dependency array, a Vue `watchEffect`, an Angular `effect()`, a Svelte
`$effect`. The style
computation itself (`computeHideAnimationStyles`) is a faithful, framework-agnostic port of
`react-native-bootsplash`'s own `useHideAnimation` `useMemo` body, so the container/logo/brand
prop bags are byte-for-byte the same shape upstream produces — the same logic/lifecycle split as
every other SymbioteNative component (see
[how it works](/docs/how-it-works/)).
